Architecture
This page describes the internals of signalbot: the layers a message passes through in each direction and the conventions that keep them consistent. Read it before adding a new incoming message or a new outgoing message. For the user-facing picture, see How it works.
Only what src/signalbot/__init__.py exports in __all__, plus the public types of each domain
package (messages/, groups/, polls/, reactions/, receipts/, contacts/, attachments/,
context/), is public API. Modules and packages with a leading underscore (_pipeline.py,
_actions/, _client/, _generated/, ...) are internal.
Message flow¶
Incoming¶
%%{init: {"flowchart": {"useMaxWidth": false}}}%%
flowchart LR
WS(["signal-cli-rest-api<br/>websocket"]) --> Parse["parse()"]
Parse --> Queue[["dispatch queue"]]
Queue --> Handler["Handler.handle_xxx()"]
Handler --> Context(["Context"])
MessagePipeline._produce in
_pipeline.py
reads the raw JSON from the websocket, and
parse() turns it into a typed
ReceivedMessage. _dispatch_to_handlers looks up the
message type in _MESSAGE_DISPATCH, picks the registered handlers whose filters and trigger match,
and queues each handler method bound to a fresh Context. MessagePipeline._consume pulls the jobs
from the queue and runs them.
Outgoing¶
%%{init: {"flowchart": {"useMaxWidth": false}}}%%
flowchart LR
Call(["context.send(), .react(), ..."]) --> Actions["*Actions"]
Actions --> Client["HTTP client"]
Client --> HTTP(["signal-cli-rest-api<br/>HTTP endpoint"])
A Context method delegates to one of the *Actions classes on the
bot (bot.messages, bot.reactions, bot.polls, ...), which resolves the recipient, builds the
generated wire request and calls the internal HTTP client (SignalAPI) that sends it to
signal-cli-rest-api.
Layers¶
Roughly innermost to outermost:
- Generated models —
src/signalbot/_generated/holds pydantic models generated from signal-cli-rest-api's swagger schema:_generated/apifor requests,_generated/datafor responses and_generated/receivefor incoming messages. They are never edited by hand, see New signal-cli-rest-api version. - Domain packages —
messages/,groups/,polls/,reactions/,receipts/,contacts/andattachments/hold the public types: the parsed incoming messages, the request models bot authors construct, and thin wrappers around the generated types they expose (see Generated models).messages/parser.pyturns a raw envelope into one of the incoming message types. - HTTP client —
src/signalbot/_client/has one file per API section (messages.py,groups.py,polls.py, ...), each with a*URIsclass for the endpoint paths and a*Clientclass that takes generated request models, serializes them and callsself._request(...). They are all attached toSignalAPIinsignal_api.py. - Actions —
src/signalbot/_actions/has one*Actionsclass per API section (MessageActions,PollActions, ...). Each resolves recipients throughRecipientResolver(_recipients.py), converts the domain request to the generated one, calls the client, logs, and returns a domain-level result.SignalBot._init_actions()inbot.pyattaches them asbot.messages,bot.polls, ...GroupActionsis the exception: it is nested atbot.groups.actionsbecausebot.groupsis already theGroupRegistrycache. - Contexts —
src/signalbot/context/has one*Contextclass per incoming message type. It wraps the bot and the received message, and its convenience methods (context.send(...),context.react(...), ...) delegate to the Actions layer, filling in what is only known once a message has been received, such as the recipient. - Handlers —
src/signalbot/handlers.pyhas one*HandlerABC per incoming message type with a single abstracthandle_xxx(self, context)method, plus the trigger decorators (text_triggered,regex_triggered,reaction_triggered). Each decorator stores its message predicate on the wrapper, which the pipeline checks at dispatch time. - Pipeline —
src/signalbot/_pipeline.pyowns handler registration and dispatch._MESSAGE_DISPATCHis the single map from a parsed message type to(HandlerABC, ContextClass, "handle_xxx"); nothing reaches a handler without an entry there. - Bot —
SignalBotinbot.pyis the object bot authors construct._bot_init.pybuilds the sharedSignalAPIclient, event loop, scheduler and storage backend, andSignalBot._init_actions()wires up the*Actionsinstances and theMessagePipeline.
Runtime¶
SignalBot.start() schedules _async_post_init and runs the event loop. _async_init checks that
signal-cli-rest-api is reachable and has a compatible version and mode, refreshes the group cache,
resolves the group names of the registered handlers to ids and runs the
ReadyHandlers. Then the pipeline starts one producer task,
which reads the websocket, parses and queues the matching handlers, and three consumer tasks, which
pull from the queue and run the handlers. Both loops are wrapped in rerun_on_exception
(_utils/retry.py),
so an uncaught exception restarts the loop instead of killing the bot.
Following one message end to end¶
The PingHandler from How it works, with the
internal classes it passes through:
%%{init: {"sequence": {"useMaxWidth": false}}}%%
sequenceDiagram
participant Author as Bot author code
participant Bot as SignalBot
participant API as signal-cli-rest-api
participant Pipeline as MessagePipeline
participant Handler as PingHandler
participant Context as DataMessageContext
Author->>Bot: bot.register(PingHandler())
Bot->>Pipeline: store handler + filters
Note over Pipeline: bot.start() → resolve_handlers()
API-->>Pipeline: websocket push: raw "!ping" envelope
Pipeline->>Pipeline: parse() → DataMessage
Pipeline->>Pipeline: filters and @text_triggered("!ping") match? dispatch lookup
Pipeline->>Handler: handle_data_message(DataMessageContext(bot, message))
Handler->>Context: context.send(SendMessage(text="pong"))
Context->>Bot: bot.messages.send(message, recipient)
Bot-->>API: HTTP POST /v2/send
API-->>Author: "pong" delivered to the chat