Bot API client
The outgoing channel, chattice.client.Bot, wraps the official
google-apps-chat SDK.
Boundaries
- The client uses event references, authentication providers, Cards, media, and the operation registry. It does not depend on a web server.
- Incoming verification credentials and outgoing API credentials are separate concepts; nothing is shared between them.
- Handlers obtain the Bot through DI by name,
Dispatcher(bot=...), or a per-feedbot=context value.
Bot
bot = Bot(
credentials=service_account.Credentials.from_service_account_file(
"credentials.json",
scopes=["https://www.googleapis.com/auth/chat.bot"],
)
)
sent = await bot.app.messages.create("spaces/AAA", text="hello")
await bot.app.messages.update(sent.name, text="updated")
await bot.app.messages.delete(sent.name)
The SDK client is created lazily on the first call (grpc_asyncio transport — native async). Outbound operations follow the curated facade (ADR-012):
OperationRegistryis the single source of outbound auth truth; every call preflights through it.- Identity-bound resource clients (
bot.app.*/bot.user.*) expose spaces, messages, memberships, reactions, and per-user state; identity is always explicit, never auto-selected. - One
OperationExecutorpath runs every registered operation: spec lookup, preview gate, preflight, identity client, RPC, curated errors. - The raw tier is split by identity:
await bot.raw.app()/await bot.raw.user().
Space and membership primitives use the same facade:
pager = await bot.app.spaces.list(filter='spaceType = "SPACE"')
spaces = await pager.collect()
await bot.app.memberships.create("spaces/AAA", user="users/123")
membership = await bot.app.memberships.get("spaces/AAA/members/123")
pager = await bot.app.memberships.list(parent="spaces/AAA")
memberships = await pager.collect()
await bot.app.memberships.delete("spaces/AAA/members/123")
List operations return an explicit Pager; iterate it or collect() it.
Membership create/delete require the administrator-approved
chat.app.memberships scope; get/list accept that scope or chat.bot. The
registry auth paths for these operations never include USER credentials.
Two official send levels
bot.app.messages.create() is the universal imperative API. It is the right
choice for jobs, workers, event consumers, services without interaction
context, or any call where the space name comes from application data.
When a handler already has Google Chat context, refs and messages offer direct adapters over that same method:
dispatcher = Dispatcher(bot=bot)
@router.message()
async def greet(message: MessageEvent) -> None:
await message.reply("Hello")
await message.thread.send("Same thread")
await message.space.send("New top-level message")
These methods never call spaces.get() or fetch a message/thread. They issue
exactly one bot.app.messages.create() call using identifiers already present
in the event. message.reply() uses the SDK's REPLY_MESSAGE_OR_FAIL option; a missing Bot, space, thread, or
thread parent fails locally before transport work.
Use object methods when you already have a Google Chat context. Use
bot.app.messages.create() when addressing a Space explicitly from services,
jobs, or application code outside an interaction handler.
Media pipeline (explicit USER identity)
Local attachment upload requires USER authentication. A Bot can hold both identity providers, but calls choose a namespace explicitly:
from chattice.media import InputFile
await bot.app.messages.create("spaces/AAA", text="App message")
await bot.user.messages.create(
"spaces/AAA", attachments=[InputFile.from_path("report.pdf")]
)
The attachment call validates the entire input set, uploads each local file,
and creates the message using the same USER identity. It does not fall back
to APP credentials. Contextual message.reply() uses the APP namespace.
Uploads and downloads use the optional chattice[media] extra
(google-api-python-client media endpoints) — the GAPIC client cannot
carry a binary media body. bot.app.attachments.get_metadata() uses the GAPIC
get_attachment (APP-only). Download accepts USER or APP; Drive-backed
references are rejected locally with a Drive-API hint. A
USER-authenticated call acts on behalf of that user.
Every outbound operation — GAPIC and REST alike — runs through the
OperationExecutor: spec lookup, preview gate, preflight, identity
client, and the curated error policy. Media upload and download use the
media REST endpoint inside their executor closure (the GAPIC client is
unused there); each is always called with an explicit identity taken
from its attachments namespace.
Thread semantics
messages.create(..., thread=ThreadRef(...)) sets message.thread.name and, by
default, uses REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD. Pass
CreateMessageRequest.MessageReplyOption.REPLY_MESSAGE_OR_FAIL as
reply_option to require an existing thread. Without a thread, the documented default
starts a new thread. ThreadRef.thread_key IS serialized to the wire: an app
can create its own thread with an app-defined threadKey. Replying into an
existing thread requires the full spaces/.../threads/... resource name in
thread.name.
Idempotency
request_id maps to the documented requestId: retrying with the same ID
returns the originally created message. Google does not document a retention
window — do not rely on one.
Errors
| SDK error | Framework error |
|---|---|
| NotFound | ChatNotFoundError |
| AlreadyExists / Conflict (HTTP 409) | ChatAlreadyExistsError |
| PermissionDenied / Forbidden | ChatPermissionDeniedError |
| InvalidArgument | ChatInvalidArgumentError |
| ResourceExhausted / TooManyRequests | ChatRateLimitError |
| ServiceUnavailable / 5xx | ChatServiceUnavailableError |
| Unauthenticated / Unauthorized | ChatUnauthenticatedError |
| Other GoogleAPICallError | ChatAPIError |
Non-Google exceptions propagate unchanged. Wrapped SDK exceptions are
preserved as __cause__; .code and
.details remain accessible. The framework does not retry blindly — apps
decide using the typed errors. The app must be a member of the space to act
(otherwise Google returns 403 «You are not permitted to use this app»).