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.upload → attachmentDataRef → messages.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 send — media.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 (createMessageNotificationOptionsis 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
UploadedAttachmentfrom 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).