Capabilities
chattice.capabilities answers three DIFFERENT questions with three
mechanisms (the matrix keeps these concerns separate):
ResponseCapabilities— what the ingress response channel can do, derived from the transport plus the concrete interaction event (dialogs, App Home, matched URL, bot/human sender, widget autocomplete).OperationRegistry— which outboundBotoperations a given identity (app/user) and any reliably known credential scopes may attempt; every resource call runs this local preflight before transport.PreviewCapabilities— which Developer Preview features the application explicitly enrolled in, expressed withPreviewFeaturestability flags; this is not an auth capability.
Verified Google facts are sourced from the official Chat API references (see Google API mapping).
Response channel (ingress)
ResponseCapabilities.resolve(transport="http", event=event):
| Capability | HTTP | Pub/Sub | Google fact (verified) |
|---|---|---|---|
| SYNC_RESPONSE | yes | no | 30-second synchronous interaction response; HTTP-only |
| DIALOGS | only command / REQUEST_DIALOG action events |
no | dialogs only in response to interactions, visible only to the opener |
| CARD_UPDATE_BOT | CARD_CLICKED with BOT sender | no | UPDATE_MESSAGE is bot-only |
| CARD_UPDATE_USER | CARD_CLICKED with HUMAN sender, or MESSAGE with a matched URL | no | UPDATE_USER_MESSAGE_CARDS for human-sent cards |
| APP_HOME | APP_HOME / SUBMIT_FORM events | no | RenderActions pushCard + separately configured App Home URL |
| UPDATE_WIDGET | WIDGET_UPDATED (autocomplete) | no | autocomplete responses |
DIALOGS is derived with one shared predicate (can_open_dialog):
commands always may open dialogs; actions only when Google delivered
them with REQUEST_DIALOG metadata; SUBMIT/CANCEL actions cannot
return a new dialog. The HTTP serializer guards with the SAME
predicate, so capability checks and serialization agree on eligible events.
Pub/Sub push and streaming-pull delivery have no synchronous response channel
— both inject an empty ResponseCapabilities value and require() fails fast
on any response attempt. Apps that must react asynchronously feed a Bot
explicitly. HTTP always injects the event-derived value. Thus every supported
ingress supplies the same typed DI key, even when its set is empty.
Outbound operations (Bot)
Outbound preflight is a local registry lookup, not an authorization
guarantee. Every Operation has an OperationSpec listing its auth paths:
the allowed identity (APP/USER) and, per identity, the admissible OAuth
scopes. REGISTRY.require(operation, identity=..., scopes=...) raises
CapabilityNotSupported when the configuration cannot even attempt the call;
REGISTRY.preflight(...) returns the same answer as a bool. Scope
information is optional: with scopes=None the check falls back to the
identity baseline instead of inventing a denial.
| Operation | APP scope (any of) | USER scope (any of) | Google fact (verified) |
|---|---|---|---|
MESSAGES_CREATE |
chat.bot |
chat.messages.create, chat.messages, chat.import |
Chat API spaces.messages.create |
MESSAGES_UPDATE |
chat.bot |
chat.messages, chat.import |
Chat API spaces.messages.update |
MEDIA_UPLOAD |
— (not supported) | chat.messages.create, chat.messages, chat.import |
Chat API media.upload (user auth only) |
MEDIA_DOWNLOAD |
chat.bot |
chat.messages.readonly, chat.messages |
Chat API media.download |
ATTACHMENT_METADATA_GET |
chat.bot |
— (not supported) | Chat API spaces.messages.attachments.get (app auth only) |
MEMBERSHIPS_CREATE |
chat.app.memberships |
— (not supported) | Chat API spaces.members.create; administrator approval |
MEMBERSHIPS_GET |
chat.bot, chat.app.memberships |
— (not supported) | Chat API spaces.members.get |
MEMBERSHIPS_LIST |
chat.bot, chat.app.memberships |
— (not supported) | Chat API spaces.members.list |
MEMBERSHIPS_DELETE |
chat.app.memberships |
— (not supported) | Chat API spaces.members.delete; administrator approval |
SPACES_LIST |
chat.bot |
— (not supported) | Chat API spaces.list; caller-member Spaces |
When scopes are reliably known, preflight passes when any scope in the
identity-specific rule is present. For example, the broader chat.messages
user scope supports message creation even when the narrower
chat.messages.create scope is absent. A known empty or nonmatching set fails
closed before transport. When scopes are unknown, the identity baseline is
preserved so Chattice does not invent a denial or perform a discovery call.
Bot reads scopes locally when credentials are lazily resolved. For user
credentials, available granted_scopes takes precedence over the credential's
requested/configured scopes; for app credentials, configured explicit/default
scopes are considered. This inspection performs no token-info, Google API, or
other network request.
Configured chat.app.* scopes do not prove one-time administrator approval.
Likewise, space membership, resource roles, and resource state remain known
only to Google Chat. A call that passes local preflight can therefore still
receive a server-side 403, surfaced as ChatPermissionDeniedError.
Beyond the registry, Bot enforces deterministic surface rules before
transport: private_to requires APP auth and rejects
accessory-widget combinations, notify is strict-validated and
APP-gated, and user-auth CARDS are rejected without the documented
stable facade. A Dispatcher preview flag does not authorize USER card sends;
use await bot.raw.user() for a separately supported Google preview flow.
require() guards
Both ResponseCapabilities and the operation registry expose require()
raising CapabilityNotSupported (a RuntimeError):
from chattice.auth import AuthMode
from chattice.capabilities import (
REGISTRY,
CapabilityNotSupported,
Operation,
)
try:
REGISTRY.require(
Operation.MEDIA_UPLOAD,
identity=AuthMode.USER,
scopes={"https://www.googleapis.com/auth/chat.messages"},
)
except CapabilityNotSupported as error:
print(error)
The message style is operation + actionable hint, so apps can fail with a useful error instead of a bare permission denial:
MEDIA_UPLOAD is not supported in this configuration. media.upload
requires user authentication.
Pre-transport enforcement contract
Guards run before Chat API network I/O. The executor resolves the (explicit or auto-classified) auth mode and locally available credential scopes, then runs the registry check before issuing the Chat API request. No separate authorization or token-introspection request is made. This contract is pinned by tests that count transport invocations: unsupported combinations never reach the transport (call counter stays zero), supported ones do.
Developer Preview enrollment
Preview capability enrollment is explicit configuration. Message actions are
now generally available and do not require enrollment; the legacy
PreviewFeature.MESSAGE_ACTION value remains accepted for compatibility.
Handlers may inject PreviewCapabilities to inspect the immutable enrollment.
A caller cannot bypass configuration by passing a replacement value to
feed_update().
Experimental namespace
Experimental integrations live in chattice.experimental; APIs there may
change or disappear without notice. Preview feature flags remain in the stable
capability model because they gate stable parsing/routing boundaries without
making the preview feature itself stable. Stable core never imports the
experimental namespace; a grep-test enforces that dependency direction.
Define handlers at module level
Handler functions and their annotations must be defined at module level —
importing dependencies inside a handler (or injecting handler-local
classes into annotations) breaks annotation resolution
(get_type_hints) and with it the dependency-injection machinery. This
is covered by DI tests: local imports resolve to None
types, module-level ones resolve correctly.