How it works
Signalbot moves messages in two directions: incoming messages arrive over a websocket and are routed to your code, outgoing messages are sent by your code through an HTTP API. This page shows how the pieces fit together.
Architecture¶
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"])
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"])
- Incoming: The internal pipeline reads the websocket, an internal
parse()turns the raw JSON into aReceivedMessage, dispatches it to the matchingHandlerandContextsubclass before yourhandle_xxxmethod runs. - Outgoing: calling a method on
context(or directly onbot.messages/bot.reactions/ ...) resolves the recipient, builds a request object, and sends it through an internal HTTP client tosignal-cli-rest-api.
Registration¶
Before any of this runs, handlers need to be registered with the bot — typically once at startup,
e.g. bot.register(PingHandler()). The bot.register() just stores the handler and its filters;
nothing is invoked yet. From then on, every incoming message is checked against each registered
handler's filters and invoked with a fresh Context.
Registration is the opening step of the walk-through below (steps 1-2).
Following one message end to end¶
Take a bot with a single handler:
class PingHandler(DataMessageHandler):
@text_triggered("!ping")
async def handle_data_message(self, context: DataMessageContext) -> None:
await context.send(SendMessage(text="pong"))
%%{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 match? dispatch lookup
Pipeline->>Handler: handle_data_message(DataMessageContext(bot, message))
Note over Handler: @text_triggered("!ping") matches
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
Every incoming message type has its own Handler/Context pair and dispatch entry (see the table
in Extending signalbot) — this walk-through uses
DataMessage because it's the most common case, but reactions, typing indicators, remote deletes,
and group updates all follow the same shape.