Skip to content

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 a ReceivedMessage, dispatches it to the matching Handler and Context subclass before your handle_xxx method runs.
  • Outgoing: calling a method on context (or directly on bot.messages / bot.reactions / ...) resolves the recipient, builds a request object, and sends it through an internal HTTP client to signal-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.