Extending signalbot
Signalbot has two directions of message flow, and adding support for a new one touches a small, predictable set of files:
- Incoming — signal-cli-rest-api pushes a message over the websocket, signalbot parses it into a typed object and dispatches it to a handler.
- Outgoing — a bot author calls a method that turns into an HTTP request against a signal-cli-rest-api endpoint.
Neither list below is exhaustive — some message types need extra plumbing (e.g.
GroupUpdate also touches the group registry in
src/signalbot/groups/) —
so find the closest existing example and follow its shape.
New incoming message¶
Every incoming type follows the same name chain from the wire to the handler. Match it when
adding a new one — Xxx (wrapped class) → XxxHandler → XxxContext → handle_xxx
(snake_case of Xxx):
| Generated class | Wrapped class | Handler ABC | Context class | Handler method |
|---|---|---|---|---|
DataMessage |
DataMessage |
DataMessageHandler |
DataMessageContext |
handle_data_message |
Reaction |
Reaction |
ReactionHandler |
ReactionContext |
handle_reaction |
RemoteDelete |
RemoteDelete |
RemoteDeleteHandler |
RemoteDeleteContext |
handle_remote_delete |
TypingMessage |
TypingMessage |
TypingHandler |
TypingContext |
handle_typing |
GroupInfo |
GroupUpdate |
GroupUpdateHandler |
GroupUpdateContext |
handle_group_update |
Steps:
-
Get a message envelope. Set a breakpoint in
_parse_main_messages,_parse_sync_messages, or_parse_data_message_variantinsrc/signalbot/messages/parser.py— i.e. on the already-validatedmessage_envelope: MessageEnvelopeobject. Trigger the message from another Signal client so that it is received by the bot. -
Identify the relevant envelope fields. Check whether the fields exists and are already covered by the generated
Don't hand-add fields to the generated models directly._generated.receivemodels (src/signalbot/_generated/receive/). Decide whether this is a genuinely new type or a variant ofdata_message/sync_message— compare with_parse_data_message_variantinparser.py, which already branches on thereaction/remote_delete/group-update sub-fields of a data message. If a field is missing from the generated models, it is missing from upstream signal-cli-rest-api's own swagger schema — open an issue or a PR against that repo to add it, then pull the updated schema in and regenerate here with: -
Write the parsed message class with a
from_message_envelope(...)classmethod, following an existing example —src/signalbot/messages/typing_message.pyfor a simple case, orsrc/signalbot/reactions/reaction.pyfor one that reaches back into the envelope's nested data. SubclassBaseMessageorBaseMessageWithGroupas appropriate. -
Wrap any nested
_generatedtypes your class exposes. Nosignalbot._generatedtype may appear on the public API, directly or nested inside a field —tests/unit/test_public_api_surface.pyenforces this and will fail your PR if you skip it. For each generated type your class reaches: -
If nothing about it needs to change, still give it a thin domain subclass with a docstring —
class GroupInfo(GeneratedGroupInfo): """..."""— even with zero added fields. The generated tree is produced from a bare JSON schema and carries no docstrings of its own, so this subclass is the only place that documentation exists, and it's what actually gets rendered underdocs/reference/. - Add real fields or methods only when the domain type needs state or behavior the wire format
doesn't have — pattern:
Attachment.base64_contentinsrc/signalbot/attachments/attachment.py. -
If a container field then needs to point at one of these wrapped types instead of the generated type it replaces, that's a genuine override of the generated base class. pyright flags this as
reportIncompatibleVariableOverridebecause narrowing a mutable attribute's type in a subclass is unsound in general — but it's sound here: every wrapped type is a strict superset of the generated type it replaces (same fields, same validation, plus extra), and pydantic validates the value against the narrower type on construction, so nothing bypasses it. Annotate the field with a bare# pyright: ignore[reportIncompatibleVariableOverride]— the rationale above is why. SeeGroupEntry.permissionsinsrc/signalbot/groups/group_entry.pyorQuote.attachmentsinsrc/signalbot/messages/data_message_content.pyfor the pattern. -
Wire it into the parser. Add a branch in
_parse_main_messagesand/or_parse_sync_messagesinparser.py, and extend theReceivedMessagetype alias. -
Write a
Contextclass insrc/signalbot/context/(pattern:TypingContextintyping_context.py), and aHandlerABC insrc/signalbot/handlers.py(pattern:TypingHandler) with one abstracthandle_xxx(self, context: ...)method. -
Register the dispatch. Add an entry to
_MESSAGE_DISPATCHinsrc/signalbot/_pipeline.pymapping your new class to(YourHandler, YourContext, "handle_xxx"). This is the one place that ties parsing to dispatch — nothing reaches a handler without an entry here. -
(Optional) Write a trigger decorator in
handlers.pyif handlers for this type commonly filter on a field — followreaction_triggeredas the smallest example: it checksisinstance(context, YourContext), filters, and calls through. -
Write a test. Follow
tests/unit/messages/test_message.py's pattern: build the raw envelope JSON inline (this repo doesn't use fixture files for envelopes), callparse(signal, raw_json_str), and assertisinstance(result, YourClass)plus field values. Add dispatch coverage intests/unit/test_pipeline.pyif relevant. -
Write an example handler under
examples/handlers/orexamples/commands/, followingexamples/commands/reaction.py(@text_triggered+context.react(...)) orexamples/handlers/reaction.py(ReactionHandler+@reaction_triggered) as templates, and register it in one of the example bots (examples/simple_bot.py/examples/bot.py) withbot.register(YourHandler()). -
Add a new page to the docs under
docs/examples/, under the section of the bot that you are editing.
New outgoing action¶
Every outgoing action follows the same name chain from the wire request to the bot author's call site. Match it when adding a new one:
| Generated request | Request class | Actions method | Context shortcut |
|---|---|---|---|
SendMessageV2 |
SendMessage |
bot.messages.send |
Context.send |
RemoteDeleteRequest |
built inline | bot.messages.remote_delete |
DataMessageContext.remote_delete |
TypingIndicatorRequest |
built inline | bot.messages.start_typing / .stop_typing |
Context.start_typing / Context.stop_typing |
SendReactionRequest |
built inline | bot.reactions.react |
DataMessageContext.react |
Receipt |
built inline | bot.receipts.send |
DataMessageContext.send_receipt |
UpdateGroupRequest |
UpdateGroup |
bot.groups.actions.update |
Context.update_group |
CreatePollRequest |
CreatePoll |
bot.polls.create |
Context.create_poll |
UpdateContactRequest |
UpdateContact |
bot.contacts.update |
Context.update_contact |
Naming patterns to follow:
- Generated request → request class drops the
Requestsuffix.UpdateGroupRequest→UpdateGroup,CreatePollRequest→CreatePoll,UpdateContactRequest→UpdateContact.SendMessageV2. - Context shortcut = the Actions method's bare verb, unless that verb is already taken.
Contextflattens every domain's actions into one namespace, so a verb already used by another action gets qualified with its noun to disambiguate; otherwise it stays bare.remote_delete,react,start_typing/stop_typingall carry over unchanged from theirbot.<noun>.<verb>call.sendis already claimed byContext.send(messages), so receipts'sendbecomessend_receiptinstead.updateis used by both groups and contacts, so neither gets the bare name — both keep the noun (update_group,update_contact). GroupActionsattaches atbot.groups.actions, not directly on the bot —bot.groupsis already theGroupRegistrycache, soGroupActionsnests underneath it instead of taking a top-levelbot.<noun>name of its own.
Steps:
-
Find the endpoint in the signal-cli-rest-api Swagger docs — note its HTTP verb, path, and request/response JSON shape.
-
Find or generate the wire models in
src/signalbot/_generated/api/(request) andsrc/signalbot/_generated/data/(response). If the endpoint/shape is missing fromsrc/signalbot/_generated/json_schema/signal-cli-rest-api.json, it's missing from upstream signal-cli-rest-api's own swagger schema — open an issue or a PR against that repo first, then pull the updated schema in and re-runuv run datamodel-codegen --profile signal-cli-rest-api(seesrc/signalbot/_generated/README.md). Don't hand-add wire models. -
Write the domain-facing request model that a bot author actually constructs, in the relevant top-level package (e.g.
src/signalbot/polls/). Pick the shape based on whether every field is knowable at construction time: -
Straight subclass of the generated request —
class X(GeneratedXRequest): """..."""— when nothing needs to be filled in later. Narrowing a field to a wrapped type (as in the incoming-flow guidance above) is fine here too, same rule: additive narrowing is sound, mark it with# pyright: ignore[reportIncompatibleVariableOverride]and a reason. -
Standalone
BaseModelwith ato_generated()method when a field the wire format requires won't be known until later — most commonlyrecipient/group_id_or_name. Don't put it on the model asstr | None; leave it off the model entirely and thread it through as a required parameter instead: onto_generated()if the wire model needs it (followSendMessageinsend_message.py,UpdateContact, orCreatePoll/CreatedPollinpoll.py), or straight onto the*Actionsmethod alone if it's only ever used to resolve a separate id and never touches the wire model (followUpdateGroup/GroupActions.update, wheregroup_id_or_nameresolves to agroup_idURL path segment and never appears in the request body). Either way, aContextconvenience method fills the parameter in from the received message. -
Add a client method in the relevant
src/signalbot/_client/file (or a new file for a new API section): add a URI method on the*URIsclass (pattern:MessagesURIs.remote_delete_uri()) and a method on the*Clientclass, typed to accept the generated request model, that builds the payload withmodel_dump_json(exclude_none=True, by_alias=True), callsself._request(verb, uri, error_cls=..., payload=...), and parses the response (pattern:MessagesClient.remote_deleteinsrc/signalbot/_client/messages.py). Define a dedicated*Error(SignalAPIError)class alongside it. -
Expose it on
SignalAPIif it's a new section (src/signalbot/_client/signal_api.py) — existing sections (.messages,.reactions,.groups, ...) already route to their client class. -
Add a method on the matching
*Actionsclass insrc/signalbot/_actions/(pattern:MessageActions.remote_deleteinsrc/signalbot/_actions/messages.py): resolve any recipient viaself._recipients.resolve(...), convert to the wire request — call.to_generated()on the domain model if it has one (pattern:PollActions.create), or construct the generated model directly for simple cases that never needed a domain wrapper (pattern:MessageActions.remote_deletebuilding aRemoteDeleteRequestinline) — call the client method, log viaself._logger.info(...), and return a friendly domain-level result if useful (e.g.SentMessage). -
Wire it up if it's a new Actions class — instantiate and attach it to
SignalBotinsrc/signalbot/_bot_init.pynext toself.messages/self.reactions/etc. -
Add a
Contextconvenience method insrc/signalbot/context/context.pyif handlers should be able to call it directly — follow howcontext.react(...)/context.send(...)delegate to the Actions layer. This is usually exactly where the deferred parameter from step 3 gets filled in, by passing it straight through as an extra argument (e.g.self.bot.polls.create(create_poll_request, received_message.source_or_group_id())). -
Write a test for the new
Actionsmethod (mock/stubSignalAPI, assert the right client method and payload) — checktests/unitfor the existing pattern for_actions/*classes. -
Write an example command/handler under
examples/commands/orexamples/handlers/that calls the new action (pattern:examples/commands/reaction.py), registered in one of the example bots. -
Add a new page to the docs under
docs/examples/, under the section of the bot that you are editing.