signal-bot/signal_bot/counting.py
Krzysztof kuhy Rudnicki 6bde3493a4 feat: rewrite the bot so it runs, and gate it like the other repos
The bot did not work. `run.sh` started a FastAPI app whose
`@app.on_event("startup")` awaited `asyncio.gather()` on two infinite
tasks, so uvicorn never finished starting up and no HTTP was ever served;
the readme's `python main.py` was a no-op with no `__main__` block and no
`uvicorn.run()` call. All three CI workflows were red, one referencing an
`environment.yml` that does not exist.

Rewritten as a package behind an injectable transport seam, which is what
makes the offline environment possible.

Bugs fixed:
- startup deadlock: lifespan spawns the loops instead of awaiting them
- sticker filter read dataMessage.sticker even on syncMessage envelopes, so
  the account's own stickers were never filtered (e038772, 53028cc, 07fa90d,
  2416cef all fought this symptom)
- no websocket reconnect: a container restart killed the bot silently
- blocking `requests` inside async functions stalled the event loop
- every counted message DMed the whole tally to the bot itself
- images were written to CWD under a filename taken from the remote URL
- one global 10s cooldown shared across all users and commands
- dead code: RECEIVE_URL, get_attachments (no call site), USER_MESSAGE_COUNT
- .env.sample declared PRIMARY/SECONDARY_PHONE_NUMBER and MODE, none read

Scoping is now an enforced invariant. A linked device receives every DM and
every group on the account; GROUP_ID was the only thing narrowing that and
defaulted to "". Config refuses to start unscoped, direct messages are
dropped explicitly rather than by a falsy check, and a scenario asserts that
DMs and other groups produce zero outbound messages.

Offline environment: fake_signal/ serves the real protocol surface, so the
unmodified bot connects to it by pointing API_BASE_URL at it. scripts/e2e.sh
boots both, replays 7 scenarios and exits non-zero on mismatch. A local
Signal server is not achievable (signal-cli compiles its endpoints and trust
store in; Signal-Server needs FoundationDB), so faking this boundary is the
only way to test every feature without an account.

Transport pinned to bbernhard/signal-cli-rest-api:0.100 with
MODE=json-rpc-native. The readme pinned 0.167-dev; the dev tag series counts
separately, so that was older than 0.100, not newer. /v1/receive is only a
WebSocket in a json-rpc mode, so the mode is verified at startup.

Repo standards: ruff select=ALL, mypy --strict, 189 tests at 100% branch
coverage, 250-line cap, exact pins at newest stable, shared gates delegated
to ~/utils, one CI workflow replacing three red ones. No noqa, no
type: ignore, no coverage omits anywhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yaBT45KhHqxTKVCTA87iy
2026-08-29 13:05:42 +02:00

45 lines
1.6 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Which messages count toward the daily tally, and which chats we listen to."""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from signal_bot.config import Config
from signal_bot.models import IncomingMessage
def is_watched_group(message: IncomingMessage, config: Config) -> bool:
"""True only for messages from the one group the bot is scoped to.
A linked Signal device receives every direct conversation and every group
on the account. This predicate is the entire boundary between "a bot in
one group" and "a bot reading all of your chats", so it is deliberately
written as an explicit group-id equality rather than a falsy check: a
direct message has no group id at all and must never reach a handler.
"""
if message.group_id is None:
return False
return message.group_id == config.group_id
def should_count(message: IncomingMessage) -> bool:
"""True when a message should add to its sender's tally.
Reactions and stickers are not messages for tally purposes, and a frame
with no text body carries nothing to count.
"""
if message.is_reaction or message.has_sticker:
return False
return bool(message.text)
def format_tally(rows: list[tuple[str, int]]) -> str:
"""Render the daily report, or a placeholder when nobody spoke."""
if not rows:
return "Brak wiadomosci dzisiaj."
width = max(len(name) for name, _ in rows)
lines = [f"{name.ljust(width)} {count}" for name, count in rows]
return "\n".join(lines)