Skip to content

How it works

Signalbot moves messages in two directions: incoming messages arrive from signal-cli-rest-api and are routed to the handlers you registered, outgoing messages are sent by your code back through signal-cli-rest-api. This page shows how the pieces fit together from the point of view of a bot author. The internals are described in Architecture.

Message flow

Incoming

%%{init: {"flowchart": {"useMaxWidth": false}}}%%
flowchart LR
    API(["signal-cli-rest-api"]) --> Bot["SignalBot"]
    Bot --> Match{"filters and<br/>trigger match?"}
    Match --> Handler["Handler.handle_xxx(context)"]

Outgoing

%%{init: {"flowchart": {"useMaxWidth": false}}}%%
flowchart LR
    Call(["context.send(), .react(), ...<br/>bot.messages.send(), ..."]) --> Bot["SignalBot"]
    Bot --> API(["signal-cli-rest-api"])
  • Incoming: every message the bot's account receives is turned into a typed message object (a ReceivedMessage), checked against the filters and trigger of each registered handler, and passed to every matching handler wrapped in a Context.
  • Outgoing: the methods on context (context.send(...), context.react(...), ...) answer the message the handler received, so the recipient is filled in for you. The same actions are available on the bot itself (bot.messages, bot.reactions, bot.polls, ...) when you need to pass the recipient explicitly, e.g. from a scheduled job.

Message types

Each kind of incoming message has its own handler base class. Subclass it, implement its handler method, and that method receives the matching context:

Message Handler Context Handler method
DataMessage, EditMessage DataMessageHandler DataMessageContext handle_data_message
Reaction ReactionHandler ReactionContext handle_reaction
RemoteDelete RemoteDeleteHandler RemoteDeleteContext handle_remote_delete
TypingMessage TypingHandler TypingContext handle_typing
GroupUpdate GroupUpdateHandler GroupUpdateContext handle_group_update

A ReadyHandler doesn't handle messages: its handle_ready runs once, after the bot has connected and before it starts processing incoming messages.

Registration

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 trigger decorator (@text_triggered, @regex_triggered, @reaction_triggered), and every handler that matches is invoked with a fresh Context.

Exclusive handlers

By default every matching handler runs, so a handler without a trigger also sees the messages another handler already answered. Registering handlers with a priority makes them exclusive: of the exclusive handlers that match a message, only the one with the highest priority runs, the first registered one on ties. Handlers without a priority still run alongside it.

# not exclusive: every message
bot.register(Handler1())

# exclusive: if Handler2 and Handler3 match, only Handler2 runs
bot.register(Handler2(), priority=1)

# exclusive: only runs if Handler2 does not match
bot.register(Handler3(), priority=0)

See the handler priorities example for a complete bot.

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 Handler as PingHandler

    Author->>Bot: bot.register(PingHandler())
    Author->>Bot: bot.start()

    API-->>Bot: "!ping" received
    Bot->>Bot: filters and @text_triggered("!ping") match?
    Bot->>Handler: handle_data_message(context)
    Handler->>Bot: context.send(SendMessage(text="pong"))
    Bot-->>API: send "pong"
    API-->>Author: "pong" delivered to the chat

This walk-through uses a DataMessage because it's the most common case, but reactions, typing indicators, remote deletes, and group updates all follow the same shape with their own handler and context from the table above.