New outgoing messages
An outgoing message starts as a bot author's call and turns into an HTTP request against a signal-cli-rest-api endpoint (see Architecture). Adding support for a new one touches a small, predictable set of files.
The steps below are not exhaustive, so find the closest existing action and follow its shape.
Naming¶
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 or shape is missing, it is missing from upstream — get it added there and regenerate the models, as described in New signal-cli-rest-api version. -
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 is fine here too, under the same rule as for incoming messages (see Wrapped types): additive narrowing is sound, mark it with# pyright: ignore[reportIncompatibleVariableOverride]. -
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
SignalBotinSignalBot._init_actions()insrc/signalbot/bot.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.