signal-bot/signal_bot/dispatcher.py
Krzysztof kuhy Rudnicki 405541be9b
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
release: 0.2.6 «Stephanus I» — dodano !analityka
One row per command attempt, not two aggregates written only on success.

command_uses holds last-use only and daily_uses counts successes; both are
rate-limiter state and both are written by store.record_use, which the
dispatcher calls only when a handler returns cleanly. A gate refusal and a
crash returned before it, so a command that failed every time looked exactly
like one nobody ran, and nothing knew which command a counted message was.

command_events now takes every attempt with its outcome, its duration and
the item it served; upstream_calls takes every outbound request made while
serving one. The two writes stay separate on purpose -- recording a refusal
as a use is what slid the cooldown window forward every time somebody was
told to wait -- and test_dispatcher_outcomes asserts last_used does not move.

Backfilled from journald joined against message_events, with daily_uses
filling the residual so the two sources cannot double-count. Two things were
wrong on the first attempt and both were found by measuring rather than by
reasoning: the window has to be symmetric, because sent_at_ms comes from the
sender's phone and the journal stamp from this host (194 of 299 lines landed
*before* their own message, by up to 3.2 s); and matching has to be a global
closest-pair assignment, because per-row greedy cannot separate two commands
seconds apart. Together those took the dropped rows from 137 to zero, and
recovered 418 invocations back to 29 August.

Also: !analityka in the group, twelve Polish lines, naming at most three
people and only ones whose display name has been seen; an Analytics tab with
Nivo charts; and continuous latency measurement, which answers the open
question in DOCS-latency.md -- a text send is 235 ms and an image send about
2.2 s, so the upload really is where the time goes.

EPHEMERAL_TABLES was doing two unrelated jobs and is now two tuples: what a
schema bump rebuilds, and what leaving a group clears. daily_uses left the
first (it is a backfill source now) and stayed in the second, and
_drop_reshaped took over the repair a bump used to do for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4QTp3h5Q3MvfrwGV8xqPf
2026-08-31 23:19:51 +02:00

173 lines
7.2 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.analytics import probe, recorder
from signal_bot.context import CommandContext
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.stats.recorder import record_message, sent_at_ms_of
from signal_bot.unknown import answer_unknown
if TYPE_CHECKING:
from signal_bot.config import Config
from signal_bot.discovery import GroupDirectory
from signal_bot.models import IncomingMessage
from signal_bot.registry import CommandRegistry
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 _answer_unknown(self, message: IncomingMessage, group: str) -> None:
"""Answer a "!" word nothing claims, and remember it.
Guarded on its own because it runs *above* the try that wraps a
handler, and Bot.listen has no exception handling of its own: one
raise here ends the receive loop and the bot goes silently deaf
until the watchdog notices. A sqlite insert and a network send are
exactly the two things in this branch that can raise.
"""
try:
await answer_unknown(
message=message,
group_id=group,
registry=self.registry,
store=self.store,
transport=self.transport,
)
except Exception:
logger.exception("could not answer an unrecognised command in %s", group)
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.
#
# Scoped to this group, and that is the whole enforcement: a command
# a group is not allowed comes back as None here, so it is not
# answered, not recorded as a command and never named in a log line
# outside the groups it belongs to.
resolved = self.registry.resolve_with_args(message.text, group)
command = resolved.command if resolved is not None else None
# Computed once and reused below. should_count is False for a
# reaction and for the bot's own envelope, and the second of those
# is the loop guard: handle() does not check is_own anywhere else,
# so without this the bot could answer itself.
counted = should_count(message)
if counted:
record_message(
self.store.events,
group,
message,
self.clock(),
is_command=command is not None,
)
if command is None:
# is_command above stays False, and this stays below the
# recording rather than in front of it: an unrecognised word is
# not a command just because the bot now says something about it.
if counted:
await self._answer_unknown(message, group)
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(),
args=resolved.args if resolved is not None else (),
)
# The envelope's own timestamp, not the clock, and the same value
# record_message keyed its row on -- that shared key is what lets
# the two tables be joined at all.
sent_at_ms = sent_at_ms_of(message, now)
# Two recordings, and they are deliberately not the same write.
#
# The *history* takes every attempt: a refusal and a crash are rows,
# because a command that fails every time used to be indistinguishable
# from one nobody ran. The *use* is success-only and feeds the
# cooldown -- recording a refusal there would push the wait N seconds
# further away every time somebody was told to wait N seconds.
# See analytics.recorder for the invariant and the test that holds it.
with probe.probing() as notes, recorder.attempting(command.name) as attempt:
await command.handler(ctx)
recorder.record_attempt(
self.store.commands,
recorder.Invocation(
group_id=group,
user_uuid=message.source_uuid,
command=command.name,
args=ctx.args,
sent_at_ms=sent_at_ms,
),
attempt,
notes,
)
if not attempt.ok:
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)