Skip to content

Files, Images, and Media

Google Chat has two different media surfaces. Chattice keeps them apart instead of pretending they are one feature:

Surface What it is Google primitive Auth
Card Image A picture rendered inside a card from an HTTPS URL, optionally published from local bytes first Cards v2 Image + optional AssetPublisher any card-sending auth
Message attachment A file uploaded to Chat and attached to a message media.uploadattachmentDataRefmessages.create USER auth for the WHOLE send — upload AND the final messages.create

A Card Image always reaches Google as an HTTPS URL. Chattice can either use an already hosted URL or ask an application-provided AssetPublisher to publish local PNG/JPEG bytes before an outbound send. This is separate from Google Chat attachments and never changes the message identity.

Choose the surface from the data's security requirements:

Requirement Chattice API
Static or public image rendered inside a Card Image.from_url()
Generated non-sensitive image rendered inside a Card Image.from_bytes() + GCSAssetPublisher
Confidential PNG delivered as a native Chat attachment InputFile + USER authentication
Confidential domain-only document that need not render inline Private Drive file + Button(open_link=...)
Confidential PNG specifically inside Card.Image Google Chat does not provide a clean general-purpose solution

Card Image URLs are not a confidential-media channel

GCSAssetPublisher is intended for Card images that may be exposed through an anonymously fetchable HTTPS URL. Do not use it for confidential reports unless a custom publishing strategy provides the security properties required by your application and remains fetchable by Google Chat for the Card's lifetime.

For confidential generated files, prefer native Google Chat attachments with USER authentication. For domain-restricted files that do not need to render inline, store them privately in Drive and link to them from a Card. A URL embedded in Card.Image must be retrievable by Google's image fetcher; Chattice cannot attach your application's authorization headers to that request.

Local images in Cards

For an already published image, no publisher or network work is needed:

from chattice.cards import Image

logo = Image.from_url(
    "https://assets.example.com/static/logo.png",
    alt_text="Company logo",
)

For generated non-sensitive images, configure one publisher on the Bot and keep storage details out of handlers:

from chattice.client import Bot
from chattice.integrations.gcs import GCSAssetPublisher

publisher = GCSAssetPublisher(
    bucket="company-chat-assets",
    namespaces={
        "generated": "generated/",
        "previews": "tmp/previews/",
    },
    default_namespace="generated",
)

bot = Bot(credentials=credentials, asset_publisher=publisher)

The bucket must already expose objects anonymously at https://storage.googleapis.com/..., or public_url_base must point to a public HTTPS CDN. Chattice uploads objects but never creates buckets, changes IAM, or configures lifecycle rules. Time-limited signed URLs are a poor fit for persistent Cards and are not generated by this integration.

cache_control defaults to None. This means Chattice does not set Cache- Control metadata on uploaded objects. Applications may opt into an aggressive policy for truly public, content-addressed assets:

publisher = GCSAssetPublisher(
    bucket="company-chat-assets",
    cache_control="public, max-age=31536000, immutable",
)

The word public in Cache-Control controls shared-cache behavior; it does not make an object publicly readable and does not change bucket IAM or an object ACL. Conversely, a restrictive cache policy does not make a public URL confidential. Access control and cache policy are independent infrastructure decisions.

Handler code stays storage-agnostic:

from chattice.cards import Card, Image, Section


@router.message(F.text == "status")
async def status(message: MessageEvent) -> None:
    png = await create_public_status_png()
    await message.reply(
        card=Card(
            sections=[
                Section(
                    widgets=[
                        Image.from_bytes(
                            png,
                            filename="status.png",
                            namespace="generated",
                            alt_text="Public service status",
                        )
                    ]
                )
            ]
        )
    )

Image.from_path("report.png") is also lazy: it stores the absolute path but does not open the file until a resource-client messages.create() or messages.update() call. Mutable bytearray and memoryview inputs to from_bytes() are immediately snapshotted into immutable bytes.

Local Card images support PNG and JPEG. Google recommends images no larger than 2 MB. Publication happens only for authenticated outbound Bot calls; local images cannot be used in direct Card.to_proto() serialization or a synchronous handler return. The original immutable Card is never modified.

Install the optional GCS integration with:

pip install "chattice[gcs]"

A custom S3, CDN, or corporate publisher only needs the structural async AssetPublisher.publish(data, *, filename, content_type, namespace) contract.

Lifecycle belongs to infrastructure

A useful bucket policy keeps permanent assets and generated images apart:

static/*   -> retained
generated/* -> delete after the application's chosen retention period

Configure that policy in Terraform, the Cloud console, or another infrastructure layer. Chattice does not delete published assets.

Send a local file

import os

import imgkit

from chattice.media import InputFile

path = f"{os.path.abspath(os.getcwd())}/static/out3.png"
imgkit.from_string(body, path, options={"xvfb": ""})

await message.reply(attachments=[InputFile.from_path(path)])

Chattice uploads the file through Google's media API, receives the attachmentDataRef, and creates the message with the attachment — the application never touches MediaFileUpload, attachmentDataRef, or any upload boilerplate.

This is the recommended path for a confidential generated PNG. The result is a native Chat attachment sent by the configured USER identity, not an image inside Card.Image:

await message.reply(
    text="Confidential report",
    attachments=[
        InputFile.from_bytes(
            report_png,
            filename="report.png",
            content_type="image/png",
        )
    ],
)

For a private Drive document, keep Drive authorization in the application and link to the file without republishing it as a Card image:

Button("Open confidential report", open_link=private_drive_url)

Bytes work the same way:

await bot.user.messages.create(
    "spaces/AAA",
    attachments=[
        InputFile.from_bytes(
            png_bytes,
            filename="result.png",
            content_type="image/png",
        )
    ],
)

Multiple files are preflighted together (paths, sizes, filenames, auth, space consistency) before the first upload, then uploaded sequentially in the order you passed them.

Attachment messages are USER-authenticated end to end

A local Chat attachment cannot currently be published as an APP-authenticated bot message through Chattice's stable media flow. Google Chat requires USER authentication for media.upload, and live integration testing shows an APP-authenticated messages.create cannot consume an attachment uploaded by the USER identity — the cross-identity handoff is rejected by Google ("Caller does not have permission to access requested attachment"). Chattice therefore performs the whole attachment send — upload AND the final messages.create — with the USER credentials, and fails locally with an actionable error when no USER identity exists. For an app-auth UI picture use a hosted HTTPS Card Image instead.

One Bot, two identities

Google forces two identity classes on a Chat app: app auth (chat.bot, ordinary messages and cards) and user auth (media.upload and other user-scoped operations). Chattice keeps ONE Bot that holds both:

from chattice.auth import (
    DelegatedUserCredentialsProvider,
    ServiceAccountCredentialsProvider,
)
from chattice.client import Bot

bot = Bot(
    app_credentials_provider=ServiceAccountCredentialsProvider.from_service_account_file(
        "/run/secrets/chat-service-account.json"
    ),
    user_credentials_provider=DelegatedUserCredentialsProvider.from_service_account_file(
        "/run/secrets/chat-service-account.json",
        subject="user@example.com",
    ),
)

The Bot picks the identity per operation: ordinary sends use the app identity, attachments=[InputFile(...)] uses the user identity for the entire sendmedia.upload AND the final messages.create run on the USER client; the APP client is not used for attachment messages. Handler code stays the same:

await message.reply("Ordinary message")  # APP identity, sender = Chat app

await message.reply(  # USER identity: upload AND create
    attachments=[InputFile.from_path("photo.png")]
)

DelegatedUserCredentialsProvider uses Google Workspace Domain-Wide Delegation: the service account impersonates a configured user (with_subject), and Google treats those calls as user authentication. This requires the Workspace administrator to configure the delegation and OAuth scopes. DWD is the unattended Workspace/server option — it makes the attachment send a call on behalf of the impersonated technical user; it does not magically turn the attachment message into an APP/bot-authenticated message. For production, prefer a dedicated technical Workspace user over silently using a developer's personal account. Ordinary end-user OAuth remains equally valid: pass a UserCredentialsProvider (see the development recipe below) — acquisition, consent and token storage stay the application's concern either way.

User-auth calls act on behalf of a user

A message created through a user-authenticated call is attributable to that user — an attachment message is sent from the USER identity, sender.type = HUMAN. With DWD the sender is the impersonated Workspace user; with ordinary OAuth it is the OAuth-authorized user. The explicit bot.user namespace selects this identity for both upload and message creation.

Ordinary USER OAuth (local development)

DWD is not the only USER-auth path. For local development an application may obtain ordinary OAuth credentials with Google's normal tooling and inject them into the same dual-identity Bot:

from google.oauth2.credentials import Credentials as UserCredentials

from chattice.auth import ServiceAccountCredentialsProvider, UserCredentialsProvider
from chattice.client import Bot

# Obtained by the APPLICATION through Google's OAuth flow (or a CLI
# helper); Chattice never acquires or stores OAuth tokens itself.
user_credentials = UserCredentials.from_authorized_user_info(
    {
        "client_id": "...",
        "client_secret": "...",
        "refresh_token": "...",
        "scopes": ["https://www.googleapis.com/auth/chat.messages"],
    }
)

bot = Bot(
    app_credentials_provider=ServiceAccountCredentialsProvider.from_service_account_file(
        "/run/secrets/chat-service-account.json"
    ),
    user_credentials_provider=UserCredentialsProvider(user_credentials),
)

Both providers produce the same behavior: plain sends use APP auth, attachment sends use the USER identity end to end.

Show a hosted image in a Card

from chattice.cards import Card, Image, Section

card = Card(
    sections=[
        Section(
            widgets=[
                Image.from_url(
                    "https://example.com/result.png",
                    alt_text="Result",
                )
            ]
        )
    ]
)
await message.reply(card=card)

The existing Image("https://...") constructor remains supported. on_click reuses the existing Action / OpenLink facades. data: URLs and file:// URLs remain invalid.

An interactive update with a static URL never calls the publisher:

REPORT_URL = "https://assets.example.com/static/report.png"


@router.action("report")
async def report(event: ActionEvent, bot: Bot) -> None:
    await bot.app.messages.update(
        event.message.name,
        card=Card(
            sections=[
                Section(
                    widgets=[
                        TextParagraph("Report"),
                        Image.from_url(REPORT_URL),
                    ]
                )
            ]
        ),
    )

Receive and inspect attachments

Inbound attachments stay lossless (message.attachments is untouched) and gain a typed view:

from chattice.client import Bot


@router.message()
async def on_file(message: MessageEvent, bot: Bot) -> str:
    for attachment in message.attachment_refs:
        if attachment.is_uploaded:
            content = await bot.app.attachments.download(attachment)
            # or: await bot.app.attachments.download(attachment, destination="out.bin")
            return f"Got {attachment.filename} ({attachment.mime_type}), {len(content)} bytes"
        if attachment.is_drive:
            return f"Drive file {attachment.drive_file_id} — use the Drive API"
    return "No attachments"

attachment_refs distinguishes UPLOADED_CONTENT from DRIVE_FILE. thumbnail_uri / download_uri are human-facing links; programmatic downloads use attachment_data_ref.resourceName via bot.app.attachments.download / bot.user.attachments.download.

The symmetric metadata flow:

uploaded = await bot.user.attachments.upload(space, InputFile.from_path("x.pdf"))
metadata = await bot.app.attachments.get_metadata(
    "spaces/.../messages/.../attachments/..."
)
content = await bot.app.attachments.download(metadata)  # or metadata.resource_name

attachments.get_metadata requires app auth + chat.bot (spaces.messages.attachments.get is APP-only); download works with USER or APP scopes; upload is USER-only.

What is rejected locally

Before any network call, Chattice validates the whole attachment set:

  • no USER identity on the Bot + attachments → local error (the whole attachment send is USER-authenticated, see the warning above)
  • notify + attachments → local error (createMessageNotificationOptions is APP-auth-specific)
  • card + attachments → local error (USER-auth card creation is a Developer Preview; attachment sends are USER-authenticated)
  • private_to + attachments → local error (Google: private messages omit attachments)
  • accessory_widgets + attachments → local error (Google restriction)
  • missing path / directory / FIFO / device instead of a regular file
  • file larger than 200 MB (Google's upload ceiling)
  • empty filename, filename with / or \, filename without an extension
  • an UploadedAttachment from Space A sent into Space B

Zero-byte files are allowed (Google documents a maximum but no minimum). File-type restrictions are NOT duplicated locally: Google's blocked-file-type list is authoritative and may change.

Optional dependency

Uploads and downloads use the official Google API Client Library media endpoints (the GAPIC client cannot carry a binary media body), shipped as an optional extra:

pip install "chattice[media]"

Without the extra, media operations raise an actionable error with the install command. chattice.media itself imports without the extra.

See also: Messages & Threads (private-message rules), Authentication (the three auth capabilities), Cards, Forms & Dialogs (typed Image).