mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-12 08:51:04 +01:00
DOCS-signal-envelopes.md exists because this wire format is not obvious and guessing at it has cost real bugs -- the sticker read off the wrong nesting level, and 0.0.3's !gekon shipping green on an assumption nobody had checked against the live API. The recurring cost is that answering "what does a frame with an attachment actually look like?" needed a code change, a restart, and somebody sending one at the right moment. So at DEBUG the dispatcher now logs each frame's *shape*: keys and value types, never values. Two fields keep their values because they are wire format rather than anything a person said -- a MIME type and a groupInfo type. A message body, a name, a number, a uuid, quoted text and a sticker id all come out as "str". An album collapses to one element and a count instead of printing the same dict twenty times. Verified against a live frame: the body, the quoted text, the uuid and the phone number do not survive, and contentType does. This also had to fix DEBUG being unusable. LOG_LEVEL=DEBUG set the root logger, which turns on httpx, httpcore and asyncio; the bot polls a group listing every 30 seconds, so the one interesting line arrived buried and journald started dropping. Libraries are now held at WARNING whatever the bot is set to -- at INFO httpx was already emitting an "HTTP Request: GET ... 200 OK" line every thirty seconds, forever. Measured on the running unit: 17 library lines per minute down to 2. Two bugs in that setup found by its own tests rather than by review: basicConfig is a documented no-op once the root logger has a handler, so the level silently did not apply under pytest; and the floor belongs at INFO too, not only below it. A self-send does not produce a capturable attachment envelope, by the way -- signal-cli emits no syncMessage for a send made over the same connection. Two probe images returned HTTP 201 and no frame came back, so the inbound shape still needs a human to post one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
109 lines
4.1 KiB
Python
109 lines
4.1 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Routes one inbound frame: scope it, count it, maybe run a command."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
import logging
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING
|
|
|
|
from signal_bot.counting import should_count, watched_group
|
|
from signal_bot.envelope_shape import shape_of
|
|
from signal_bot.models import parse_envelope
|
|
from signal_bot.registry import CommandContext, CommandRegistry
|
|
from signal_bot.stats.recorder import record_message
|
|
|
|
if TYPE_CHECKING:
|
|
from signal_bot.config import Config
|
|
from signal_bot.discovery import GroupDirectory
|
|
from signal_bot.store import Store
|
|
from signal_bot.transport_base import Transport
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
Clock = Callable[[], dt.datetime]
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Dispatcher:
|
|
"""Applies the scoping rules and dispatches commands."""
|
|
|
|
config: Config
|
|
directory: GroupDirectory
|
|
registry: CommandRegistry
|
|
store: Store
|
|
transport: Transport
|
|
clock: Clock
|
|
|
|
async def handle(self, frame: str) -> None:
|
|
"""Process one raw websocket frame end to end."""
|
|
# Keys and types only, never values -- see envelope_shape. This is
|
|
# how "what does a frame with an attachment look like?" gets answered
|
|
# without a code change and a lucky moment.
|
|
logger.debug("envelope shape: %s", shape_of(frame))
|
|
message = parse_envelope(frame)
|
|
if message is None:
|
|
return
|
|
|
|
# Before the scoping gate: a group-update frame arrives while its
|
|
# group is still unknown, and it is what vouches for that group.
|
|
self.directory.note_group_update(message)
|
|
|
|
# The scoping gate. Direct messages and every other group the account
|
|
# belongs to stop here, before counting and before any handler runs.
|
|
# The canonical id it yields is the reply address and the key for
|
|
# every piece of per-group state, so a message can only ever be
|
|
# counted, rate-limited and answered in the group it came from.
|
|
group = watched_group(message, self.directory)
|
|
if group is None and message.group_id is not None:
|
|
# The bot was probably just added: Signal is already delivering
|
|
# this group's messages while our listing still predates it.
|
|
# Refreshing here is what makes the *first* command after an add
|
|
# work, instead of the one after the next 30-second sweep.
|
|
group = await self.directory.refresh_for(message.group_id)
|
|
if group is None:
|
|
logger.debug(
|
|
"ignoring message from group %r; watching %r",
|
|
message.group_id,
|
|
list(self.directory.groups),
|
|
)
|
|
return
|
|
|
|
# Resolved before recording, not after: is_command is part of the
|
|
# row, so "was this a command" has to be known at write time. It is
|
|
# the same lookup the handler below uses, not a second one.
|
|
command = self.registry.resolve(message.text)
|
|
if should_count(message):
|
|
record_message(
|
|
self.store.events,
|
|
group,
|
|
message,
|
|
self.clock(),
|
|
is_command=command is not None,
|
|
)
|
|
|
|
if command is None:
|
|
return
|
|
|
|
logger.info("running command %s for %s", command.name, message.source_name)
|
|
now = self.clock()
|
|
ctx = CommandContext(
|
|
message=message,
|
|
group_id=group,
|
|
transport=self.transport,
|
|
store=self.store,
|
|
config=self.config,
|
|
command_name=command.name,
|
|
now=now.timestamp(),
|
|
day=now.date().isoformat(),
|
|
)
|
|
try:
|
|
await command.handler(ctx)
|
|
except Exception:
|
|
logger.exception("command %s failed", command.name)
|
|
return
|
|
# Recorded once, here, so stacked gates cannot double-count a use.
|
|
self.store.record_use(group, message.source_uuid, command.name, ctx.now, ctx.day)
|