diff --git a/signal_bot/__main__.py b/signal_bot/__main__.py index ca0b222..4216ad7 100644 --- a/signal_bot/__main__.py +++ b/signal_bot/__main__.py @@ -16,7 +16,7 @@ import logging import uvicorn -from signal_bot.config import load_config +from signal_bot.config_env import load_config from signal_bot.portcheck import PortInUseError, assert_port_free diff --git a/signal_bot/app.py b/signal_bot/app.py index a082eb6..53e9b3a 100644 --- a/signal_bot/app.py +++ b/signal_bot/app.py @@ -18,10 +18,10 @@ import httpx from fastapi import FastAPI, Response, status from pydantic import BaseModel -from signal_bot.config import load_config +from signal_bot.config_env import load_config from signal_bot.selftest import SelfTestError from signal_bot.selftest import run as run_selftest -from signal_bot.startup import Starter +from signal_bot.startup_retry import Starter from signal_bot.store import Store from signal_bot.versioning import POPE, __version__ diff --git a/signal_bot/bot.py b/signal_bot/bot.py index ae74a1f..b36ec8e 100644 --- a/signal_bot/bot.py +++ b/signal_bot/bot.py @@ -10,19 +10,22 @@ 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.animals import register_animal_commands from signal_bot.announce import announce_all from signal_bot.changelog import load as load_changelog -from signal_bot.counting import format_tally from signal_bot.decorators import cooldown from signal_bot.dispatcher import Dispatcher from signal_bot.http_client import ImageFetcher from signal_bot.popes_command import register_pope_command from signal_bot.portraits import PortraitCache from signal_bot.registry import CommandRegistry +from signal_bot.stats import periods +from signal_bot.stats.queries_day import day_rows, day_totals, with_silent +from signal_bot.stats.render_day import render_day from signal_bot.versioning import POPE, __version__ if TYPE_CHECKING: @@ -36,12 +39,20 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) +Clock = Callable[[], dt.datetime] + COOLDOWN_MESSAGE = "BEEP BOOP POCZEKAJ {seconds} SEKUND." def now_local() -> dt.datetime: - """Current local time, timezone-aware.""" - return dt.datetime.now().astimezone() + """Current Warsaw time -- the group's day, not the host's. + + One definition of "day" for the whole bot. The daily scheduler fires + against this, ``CommandContext.day`` keys the daily limits off it, and + every event is filed under it, so a host in another timezone (or a + container on UTC) cannot make those three disagree. + """ + return periods.now() @dataclass @@ -54,30 +65,44 @@ class Bot: directory: GroupDirectory dispatcher: Dispatcher liveness: Liveness + # The same clock the dispatcher files events against. One clock, so + # "which day is it" cannot differ between writing an event and reporting + # on it -- which is precisely the drift the old reset-after-post had. + clock: Clock = now_local async def publish_report(self) -> None: - """Post each group's own tally into that group, and reset it. + """Post each group's report for the last full day. - Each group is isolated on purpose. One group refusing the report -- - the bot removed from it, a transient send failure -- must not cost - every other group its report, and a tally is cleared only after its - report has actually gone out, so a failed send loses nothing. A - group where nobody spoke is skipped entirely. + Yesterday, not "since the last post". The old scheme reset a counter + after publishing, so a group nobody spoke in was never reset and its + next report silently covered two days. A day is now a property of + when a message was sent, which cannot drift. + + Nothing is deleted afterwards. Each group is isolated on purpose: one + group refusing the report -- the bot removed from it, a transient send + failure -- must not cost every other group its report. """ + day = periods.previous_day(self.clock()) for group_id in self.directory.groups: - rows = [(t.display_name or t.uuid, t.count) for t in self.store.tallies(group_id)] + rows = day_rows(self.store.events, group_id, day) if not rows: # Logged, not posted. The bot is in every group it was ever # added to, so posting "nobody spoke" would mean a nightly # ping into every quiet group, forever. - logger.info("no messages in %s today; not posting a report", group_id) + logger.info("no messages in %s on %s; not posting a report", group_id, day) continue + named = self.store.events.roster(group_id, self.directory.member_identities(group_id)) + rows = with_silent(rows, [(p.uuid, p.display_name) for p in named]) + text = render_day( + day, + rows, + day_totals(rows), + since=self.store.events.first_day(group_id), + ) try: - await self.transport.send_text(format_tally(rows), group_id) + await self.transport.send_text(text, group_id) except Exception: logger.exception("daily report failed for group %s", group_id) - continue - self.store.reset_tallies(group_id) async def announce_release(self) -> None: """Tell every watched group about a version it has not seen yet.""" @@ -107,6 +132,7 @@ def build_bot( transport: Transport, directory: GroupDirectory, liveness: Liveness, + clock: Clock = now_local, ) -> Bot: """Assemble the object graph from its injected dependencies.""" registry = CommandRegistry() @@ -129,7 +155,7 @@ def build_bot( registry=registry, store=store, transport=transport, - clock=now_local, + clock=clock, ) return Bot( config=config, @@ -138,4 +164,5 @@ def build_bot( directory=directory, dispatcher=dispatcher, liveness=liveness, + clock=clock, ) diff --git a/signal_bot/config.py b/signal_bot/config.py index 628edce..3dd4e5a 100644 --- a/signal_bot/config.py +++ b/signal_bot/config.py @@ -3,14 +3,10 @@ from __future__ import annotations -import logging -import os from dataclasses import dataclass from typing import Final from urllib.parse import urlsplit, urlunsplit -from dotenv import load_dotenv - from signal_bot.endpoints import ( DEFAULT_CAT_API_URL, DEFAULT_COMMONS_API_URL, @@ -25,7 +21,6 @@ from signal_bot.groups import ( build_alias_map, canonical_group_id, group_id_aliases, - parse_group_ids, ) from signal_bot.trust import parse_trusted_inviters @@ -38,7 +33,6 @@ __all__ = [ "ConfigError", "canonical_group_id", "group_id_aliases", - "load_config", "parse_trusted_inviters", ] @@ -169,79 +163,3 @@ class Config: def image_api_urls(self) -> dict[str, str]: """Image-API endpoint per command name, as the commands want it.""" return {"cat": self.cat_api_url, "dog": self.dog_api_url, "gecko": self.gecko_api_url} - - -def _require(env: dict[str, str], key: str) -> str: - """Return a non-empty environment value or raise ConfigError.""" - value = env.get(key, "").strip() - if not value: - msg = f"{key} is required and must not be empty." - raise ConfigError(msg) - return value - - -def _positive_int(env: dict[str, str], key: str, default: int) -> int: - """Return an int environment value, falling back to default when unset.""" - raw = env.get(key, "").strip() - if not raw: - return default - try: - value = int(raw) - except ValueError: - msg = f"{key} must be an integer, got {raw!r}" - raise ConfigError(msg) from None - if value < 0: - msg = f"{key} must not be negative, got {value}" - raise ConfigError(msg) - return value - - -def load_config(env: dict[str, str] | None = None, *, use_dotenv: bool = True) -> Config: - """Build a Config from the environment, failing loudly on anything missing. - - Args: - env: Mapping to read instead of os.environ. Used by the tests. - use_dotenv: Load a local .env file first. Disabled in tests. - - Returns: - A validated, immutable Config. - """ - if env is None: - if use_dotenv: - load_dotenv() - env = dict(os.environ) - - hour = _positive_int(env, "REPORT_HOUR", DEFAULT_REPORT_HOUR) - minute = _positive_int(env, "REPORT_MINUTE", DEFAULT_REPORT_MINUTE) - if hour > MAX_HOUR or minute > MAX_MINUTE: - msg = f"REPORT_HOUR/REPORT_MINUTE out of range: {hour:02d}:{minute:02d}" - raise ConfigError(msg) - - log_level = env.get("LOG_LEVEL", "").strip().upper() or DEFAULT_LOG_LEVEL - if log_level not in logging.getLevelNamesMapping(): - msg = f"LOG_LEVEL must be a logging level name, got {log_level!r}" - raise ConfigError(msg) - - bind_port = _positive_int(env, "BIND_PORT", DEFAULT_BIND_PORT) - if not 1 <= bind_port <= MAX_PORT: - msg = f"BIND_PORT must be between 1 and {MAX_PORT}, got {bind_port}" - raise ConfigError(msg) - - return Config( - phone_number=_require(env, "PHONE_NUMBER"), - group_ids=parse_group_ids(env), - api_base_url=env.get("API_BASE_URL", "").strip() or DEFAULT_API_BASE_URL, - database_path=env.get("DATABASE_PATH", "").strip() or DEFAULT_DATABASE_PATH, - report_hour=hour, - report_minute=minute, - cooldown_seconds=_positive_int(env, "COOLDOWN_SECONDS", DEFAULT_COOLDOWN_SECONDS), - cat_api_url=env.get("CAT_API_URL", "").strip() or DEFAULT_CAT_API_URL, - dog_api_url=env.get("DOG_API_URL", "").strip() or DEFAULT_DOG_API_URL, - gecko_api_url=env.get("GECKO_API_URL", "").strip() or DEFAULT_GECKO_API_URL, - commons_api_url=env.get("COMMONS_API_URL", "").strip() or DEFAULT_COMMONS_API_URL, - bind_host=env.get("BIND_HOST", "").strip() or DEFAULT_BIND_HOST, - bind_port=bind_port, - log_level=log_level, - trusted_inviters=parse_trusted_inviters(env), - silence_seconds=_positive_int(env, "SILENCE_SECONDS", DEFAULT_SILENCE_SECONDS), - ) diff --git a/signal_bot/config_env.py b/signal_bot/config_env.py new file mode 100644 index 0000000..e8b2323 --- /dev/null +++ b/signal_bot/config_env.py @@ -0,0 +1,138 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""Reading a :class:`~signal_bot.config.Config` out of the environment. + +Split out of ``config.py`` so both stay under the 250-line cap. "What the +settings are" and "how they are parsed and validated" are two things, and only +the first is imported by the rest of the bot. + +The dependency runs one way -- this module imports ``config``, never the +reverse -- so ``load_config`` is imported from here rather than re-exported +from ``config``. A re-export would need an import at the bottom of ``config`` +and would break depending on which module was imported first. +""" + +from __future__ import annotations + +import logging +import os +from typing import TYPE_CHECKING + +from dotenv import load_dotenv + +from signal_bot.config import ( + DEFAULT_API_BASE_URL, + DEFAULT_BIND_HOST, + DEFAULT_BIND_PORT, + DEFAULT_COOLDOWN_SECONDS, + DEFAULT_DATABASE_PATH, + DEFAULT_LOG_LEVEL, + DEFAULT_REPORT_HOUR, + DEFAULT_REPORT_MINUTE, + DEFAULT_SILENCE_SECONDS, + MAX_HOUR, + MAX_MINUTE, + MAX_PORT, + Config, +) +from signal_bot.endpoints import ( + DEFAULT_CAT_API_URL, + DEFAULT_COMMONS_API_URL, + DEFAULT_DOG_API_URL, + DEFAULT_GECKO_API_URL, +) +from signal_bot.groups import ConfigError, parse_group_ids +from signal_bot.trust import parse_trusted_inviters + +if TYPE_CHECKING: + from collections.abc import Mapping + +__all__ = ["load_config"] + + +def _require(env: Mapping[str, str], key: str) -> str: + """Return a non-empty environment value or raise ConfigError.""" + value = env.get(key, "").strip() + if not value: + msg = f"{key} is required and must not be empty." + raise ConfigError(msg) + return value + + +def _positive_int(env: Mapping[str, str], key: str, default: int) -> int: + """Return an int environment value, falling back to default when unset.""" + raw = env.get(key, "").strip() + if not raw: + return default + try: + value = int(raw) + except ValueError: + msg = f"{key} must be an integer, got {raw!r}" + raise ConfigError(msg) from None + if value < 0: + msg = f"{key} must not be negative, got {value}" + raise ConfigError(msg) + return value + + +def _report_time(env: Mapping[str, str]) -> tuple[int, int]: + """Return the validated (hour, minute) the daily report fires at.""" + hour = _positive_int(env, "REPORT_HOUR", DEFAULT_REPORT_HOUR) + minute = _positive_int(env, "REPORT_MINUTE", DEFAULT_REPORT_MINUTE) + if hour > MAX_HOUR or minute > MAX_MINUTE: + msg = f"REPORT_HOUR/REPORT_MINUTE out of range: {hour:02d}:{minute:02d}" + raise ConfigError(msg) + return hour, minute + + +def _log_level(env: Mapping[str, str]) -> str: + """Return the validated logging level name.""" + log_level = env.get("LOG_LEVEL", "").strip().upper() or DEFAULT_LOG_LEVEL + if log_level not in logging.getLevelNamesMapping(): + msg = f"LOG_LEVEL must be a logging level name, got {log_level!r}" + raise ConfigError(msg) + return log_level + + +def _bind_port(env: Mapping[str, str]) -> int: + """Return the validated port the readiness probe binds to.""" + bind_port = _positive_int(env, "BIND_PORT", DEFAULT_BIND_PORT) + if not 1 <= bind_port <= MAX_PORT: + msg = f"BIND_PORT must be between 1 and {MAX_PORT}, got {bind_port}" + raise ConfigError(msg) + return bind_port + + +def load_config(env: dict[str, str] | None = None, *, use_dotenv: bool = True) -> Config: + """Build a Config from the environment, failing loudly on anything missing. + + Args: + env: Mapping to read instead of os.environ. Used by the tests. + use_dotenv: Load a local .env file first. Disabled in tests. + + Returns: + A validated, immutable Config. + """ + if env is None: + if use_dotenv: + load_dotenv() + env = dict(os.environ) + + hour, minute = _report_time(env) + return Config( + phone_number=_require(env, "PHONE_NUMBER"), + group_ids=parse_group_ids(env), + api_base_url=env.get("API_BASE_URL", "").strip() or DEFAULT_API_BASE_URL, + database_path=env.get("DATABASE_PATH", "").strip() or DEFAULT_DATABASE_PATH, + report_hour=hour, + report_minute=minute, + cooldown_seconds=_positive_int(env, "COOLDOWN_SECONDS", DEFAULT_COOLDOWN_SECONDS), + cat_api_url=env.get("CAT_API_URL", "").strip() or DEFAULT_CAT_API_URL, + dog_api_url=env.get("DOG_API_URL", "").strip() or DEFAULT_DOG_API_URL, + gecko_api_url=env.get("GECKO_API_URL", "").strip() or DEFAULT_GECKO_API_URL, + commons_api_url=env.get("COMMONS_API_URL", "").strip() or DEFAULT_COMMONS_API_URL, + bind_host=env.get("BIND_HOST", "").strip() or DEFAULT_BIND_HOST, + bind_port=_bind_port(env), + log_level=_log_level(env), + trusted_inviters=parse_trusted_inviters(env), + silence_seconds=_positive_int(env, "SILENCE_SECONDS", DEFAULT_SILENCE_SECONDS), + ) diff --git a/signal_bot/counting.py b/signal_bot/counting.py index 7fcbb1d..d7698b2 100644 --- a/signal_bot/counting.py +++ b/signal_bot/counting.py @@ -37,20 +37,43 @@ def watched_group(message: IncomingMessage, directory: GroupDirectory) -> str | def should_count(message: IncomingMessage) -> bool: - """True when a message should add to its sender's tally. + """True when a message should be recorded against its sender. - Reactions and stickers are not messages for tally purposes, and a frame - with no text body carries nothing to count. + A reaction is not a message -- it is an annotation on someone else's -- + so it stays out. Everything a person actually sent counts, including a + sticker and including a photo posted with no caption. Until 0.1.0 the + last clause was ``bool(message.text)``, which silently valued a + photo-only message at zero. + + The bot's own messages never count. ``is_own`` comes from the envelope + branch rather than from comparing identities, which is what makes it + hold on the frames that carry only one spelling of the account. """ - if message.is_reaction or message.has_sticker: + if message.is_reaction or message.is_own: return False - return bool(message.text) + return bool(message.text) or bool(message.attachment_types) or message.has_sticker -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) +def media_kind(message: IncomingMessage) -> str: + """Name the kind of media a message carries, or "" for plain text. + + Several different kinds in one message collapse to "mixed": the group + reads a count of photos, not a taxonomy, and a message carrying a photo + and a video is neither. + """ + if message.has_sticker and not message.attachment_types: + return "sticker" + kinds = {_kind_of(content_type) for content_type in message.attachment_types} + if not kinds: + return "" + if len(kinds) > 1: + return "mixed" + return kinds.pop() + + +def _kind_of(content_type: str) -> str: + """Map one MIME type onto the coarse kind the group cares about.""" + major, _, _ = content_type.partition("/") + if major in {"image", "video", "audio"}: + return major + return "file" diff --git a/signal_bot/discovery.py b/signal_bot/discovery.py index 7d130e4..4914696 100644 --- a/signal_bot/discovery.py +++ b/signal_bot/discovery.py @@ -24,6 +24,7 @@ import httpx from signal_bot.groups import build_alias_map, canonical_group_id from signal_bot.membership import JoinAttempts, join_pending, verify_allowlist +from signal_bot.roster import RosterCache if TYPE_CHECKING: from signal_bot.config import Config @@ -74,6 +75,7 @@ class GroupDirectory: # make a stranger's message a second earlier swallow the first # command in a group that was just added. self._refreshed_for: set[str] = set() + self._members = RosterCache(config.phone_number) @property def groups(self) -> tuple[str, ...]: @@ -131,6 +133,11 @@ class GroupDirectory: entries = await self._fetch() if entries is None: return False + # Before _adopt, deliberately. _adopt returns early once the watched + # set stops changing -- which is the steady state -- so a roster + # stashed inside it would freeze at whatever the first sweep saw and + # never notice anybody joining or leaving again. + self._members.remember(entries, self._permitted) self._adopt(entries) return True @@ -189,6 +196,10 @@ class GroupDirectory: return None return [entry for entry in body if isinstance(entry, dict)] + def member_identities(self, group_id: str) -> frozenset[str]: + """The listing's spelling of every full member of one group.""" + return self._members.identities(group_id) + def _permitted(self, entry: dict[str, Any]) -> str | None: """Return the canonical id of a listing row the bot may watch.""" raw = entry.get("id") or entry.get("internal_id") diff --git a/signal_bot/dispatcher.py b/signal_bot/dispatcher.py index df81a79..4b6e9de 100644 --- a/signal_bot/dispatcher.py +++ b/signal_bot/dispatcher.py @@ -12,6 +12,7 @@ from typing import TYPE_CHECKING from signal_bot.counting import should_count, watched_group 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 @@ -65,10 +66,19 @@ class Dispatcher: ) return - if should_count(message): - self.store.increment(group, message.source_uuid, message.source_name) - + # 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 diff --git a/signal_bot/models.py b/signal_bot/models.py index 8d1a10c..a27679d 100644 --- a/signal_bot/models.py +++ b/signal_bot/models.py @@ -34,6 +34,17 @@ class IncomingMessage: # The sent timestamp, which is the id Signal quotes a message by. # Without it a reply can only be addressed to the room. timestamp: int = 0 + # contentType of each attachment, in the order Signal listed them. + # Empty for a text-only message. + attachment_types: tuple[str, ...] = () + # True when this message quotes another one. + is_reply: bool = False + # True when the bot's own account sent this. Taken from *which* envelope + # branch carried the payload, not from comparing identities: the account + # is spelled as a uuid on some frames and a +E.164 number on others, so + # any comparison misses one of them. syncMessage.sentMessage means "this + # account sent it", exactly, on every frame shape. + is_own: bool = False @property def is_direct_message(self) -> bool: @@ -58,19 +69,33 @@ def _timestamp(payload: dict[str, Any], envelope: dict[str, Any]) -> int: return 0 -def _payload(envelope: dict[str, Any]) -> dict[str, Any] | None: - """Return the dataMessage or sentMessage body, whichever is present.""" +def _payload(envelope: dict[str, Any]) -> tuple[dict[str, Any], bool] | None: + """Return the message body and whether the bot's own account sent it. + + signal-cli reports other people under ``dataMessage`` and this account's + own outgoing messages under ``syncMessage.sentMessage``. Which branch + matched is the only reliable "did we send this" signal, so it is returned + rather than re-derived downstream. + """ data = envelope.get("dataMessage") if isinstance(data, dict): - return data + return data, False sync = envelope.get("syncMessage") if isinstance(sync, dict): sent = sync.get("sentMessage") if isinstance(sent, dict): - return sent + return sent, True return None +def _attachments(payload: dict[str, Any]) -> tuple[str, ...]: + """Return each attachment's contentType, in the order Signal listed them.""" + items = payload.get("attachments") + if not isinstance(items, list): + return () + return tuple(str(item.get("contentType", "")) for item in items if isinstance(item, dict)) + + def _group_id(payload: dict[str, Any]) -> str | None: """Return the group id, or None for a direct message.""" info = payload.get("groupInfo") @@ -120,9 +145,10 @@ def parse_envelope(raw: str | bytes) -> IncomingMessage | None: if not isinstance(envelope, dict): return None - payload = _payload(envelope) - if payload is None: + resolved = _payload(envelope) + if resolved is None: return None + payload, is_own = resolved return IncomingMessage( source_uuid=str(envelope.get("sourceUuid", "")), @@ -137,4 +163,7 @@ def parse_envelope(raw: str | bytes) -> IncomingMessage | None: source_number=str(envelope.get("sourceNumber", "")), is_group_update=_group_type(payload) == "UPDATE", timestamp=_timestamp(payload, envelope), + attachment_types=_attachments(payload), + is_reply=isinstance(payload.get("quote"), dict), + is_own=is_own, ) diff --git a/signal_bot/roster.py b/signal_bot/roster.py new file mode 100644 index 0000000..0be8d05 --- /dev/null +++ b/signal_bot/roster.py @@ -0,0 +1,61 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""Who is in a group, according to the last listing that actually arrived. + +The nightly report shows a 0 next to people who said nothing, and the event +log cannot supply them: somebody who has never written has no events. Only the +group listing knows, and it is fetched, not stored -- so this is a cache of a +live answer, deliberately held in memory. A roster persisted to the database +would outlive the truth, and the failure mode of a stale roster is naming +somebody who left. + +Kept out of ``discovery`` because that module was two lines under the 250-line +cap, and because "who may the bot watch" and "who is in the room" are separate +questions that happen to be answered by the same HTTP response. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +from signal_bot.trust import identities + +Permitted = Callable[[dict[str, Any]], "str | None"] + + +class RosterCache: + """Each watched group's membership, from the last good listing.""" + + def __init__(self, own_number: str) -> None: + """Wire the cache to the bot's own number, which it excludes. + + Args: + own_number: The account's +E.164 number. The bot is a member of + every group it watches, and counting itself as a silent + participant would put the bot in its own report. + """ + self._own = own_number + self._members: dict[str, frozenset[str]] = {} + + def remember(self, entries: list[dict[str, Any]], permitted: Permitted) -> None: + """Stash the membership of every permitted group in a listing. + + Pending invitees are excluded: they cannot have written anything, so + listing them as silent members would be wrong rather than merely + noisy. + """ + for entry in entries: + group_id = permitted(entry) + if group_id is None: + continue + members = identities(entry, "members") - identities(entry, "pending_invites") + self._members[group_id] = frozenset(members - {self._own}) + + def identities(self, group_id: str) -> frozenset[str]: + """One group's members, or empty for a group no listing has covered. + + A mix of ACI uuids and +E.164 numbers -- the listing uses whichever + spelling it has for a given person, which is why the event store + matches on both. + """ + return self._members.get(group_id, frozenset()) diff --git a/signal_bot/schema.py b/signal_bot/schema.py index 3e96a96..7e2ee67 100644 --- a/signal_bot/schema.py +++ b/signal_bot/schema.py @@ -22,13 +22,6 @@ SCHEMA_VERSION: Final = 2 # message throttling the production group -- so the scope is in the primary # key rather than in the callers' discipline. SCHEMA: Final = """ -CREATE TABLE IF NOT EXISTS message_counts ( - group_id TEXT NOT NULL, - uuid TEXT NOT NULL, - display_name TEXT NOT NULL, - count INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (group_id, uuid) -); CREATE TABLE IF NOT EXISTS command_uses ( group_id TEXT NOT NULL, user_uuid TEXT NOT NULL, @@ -57,12 +50,50 @@ CREATE TABLE IF NOT EXISTS group_meta ( value TEXT NOT NULL, PRIMARY KEY (group_id, key) ); +CREATE TABLE IF NOT EXISTS message_events ( + group_id TEXT NOT NULL, + uuid TEXT NOT NULL, + display_name TEXT NOT NULL, + sent_at_ms INTEGER NOT NULL, + day TEXT NOT NULL, + local_hour INTEGER NOT NULL, + media_kind TEXT NOT NULL DEFAULT '', + media_count INTEGER NOT NULL DEFAULT 0, + char_len INTEGER NOT NULL DEFAULT 0, + is_command INTEGER NOT NULL DEFAULT 0, + is_reply INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (group_id, uuid, sent_at_ms) +); +CREATE INDEX IF NOT EXISTS idx_events_group_day + ON message_events (group_id, day); +CREATE INDEX IF NOT EXISTS idx_events_group_uuid_day + ON message_events (group_id, uuid, day); +CREATE TABLE IF NOT EXISTS group_people ( + group_id TEXT NOT NULL, + uuid TEXT NOT NULL, + display_name TEXT NOT NULL, + number TEXT NOT NULL DEFAULT '', + first_seen TEXT NOT NULL, + last_seen TEXT NOT NULL, + PRIMARY KEY (group_id, uuid) +); """ -TABLES: Final = ( - "message_counts", +# Cleared when the bot leaves a group, and rebuilt on a schema-version bump. +# Losing any of these costs a cooldown or a vote in progress. +EPHEMERAL_TABLES: Final = ( "command_uses", "daily_uses", "votes", "group_meta", ) +# Never dropped, by either path. These hold the whole statistical history, +# which cannot be recomputed from anything -- so a transient group listing +# that omits a member (the exact failure discovery._permitted guards against) +# must not be able to delete years of it, and neither must a future schema +# bump that only meant to reshape a cooldown table. +PERMANENT_TABLES: Final = ( + "message_events", + "group_people", +) +TABLES: Final = EPHEMERAL_TABLES + PERMANENT_TABLES diff --git a/signal_bot/startup.py b/signal_bot/startup.py index ed86988..bea50bf 100644 --- a/signal_bot/startup.py +++ b/signal_bot/startup.py @@ -13,62 +13,36 @@ first, /healthz answers 503 with the reason, and this module keeps trying in the background until the container answers. A bot that is still connecting is then *visibly* unhealthy rather than absent. -The distinction that keeps the systemd start limit meaningful: only errors -that another attempt might fix are retried. A container in the wrong mode, or -a GROUP_IDS entry naming a group the bot is genuinely not in, are answers -- -retrying them forever would replace a visible failure with an invisible one. +This module owns what a running bot *is*. The retry loop that gets there, +and the rule about which errors are worth retrying, live in +``signal_bot.startup_retry``. """ from __future__ import annotations import asyncio import contextlib -import logging -import signal -from collections.abc import Awaitable, Callable from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Final - -import httpx +from typing import TYPE_CHECKING from signal_bot.bot import Bot, build_bot, now_local from signal_bot.discovery import GroupDirectory from signal_bot.liveness import Liveness -from signal_bot.membership import MembershipError from signal_bot.scheduler import DailyScheduler -from signal_bot.transport_rest import RestTransport, TransportError +from signal_bot.transport_rest import RestTransport from signal_bot.trust import InviteTrust if TYPE_CHECKING: + import httpx + from signal_bot.config import Config from signal_bot.store import Store -logger = logging.getLogger(__name__) - -FIRST_RETRY_SECONDS: Final = 5.0 -# Docker's own restart of signal-api plus signal-cli's account load took -# minutes on the reboot this exists for, so the ceiling is what the bot -# spends most of that wait at. A minute is short enough that recovery is -# still prompt and long enough that a long outage is not a busy loop. -MAX_RETRY_SECONDS: Final = 60.0 -BACKOFF_FACTOR: Final = 2.0 - class NotReadyError(RuntimeError): """Raised when signal-cli answers but cannot yet say what it knows.""" -# Another attempt might fix these: the container is still booting, its HTTP -# server is up but answering 502, or it is returning a holding page that is -# not JSON. ValueError covers the last one -- json.JSONDecodeError is one. -RETRYABLE: Final = (httpx.HTTPError, ValueError, NotReadyError) -# No further attempt will fix these. They are verdicts, not outages. -FATAL: Final = (TransportError, MembershipError) - -Sleeper = Callable[[float], Awaitable[None]] -Stopper = Callable[[], None] - - @dataclass class Runtime: """The running bot and the background tasks it owns.""" @@ -140,109 +114,3 @@ async def create_runtime(config: Config, client: httpx.AsyncClient, store: Store # limit. The gate starts healthy and earns its verdict at runtime. liveness = Liveness(config, client) return Runtime(bot=build_bot(config, client, store, transport, directory, liveness)) - - -# Quoted: Config and Store are imported for type checking only, and this -# alias is evaluated at import time. -Factory = Callable[["Config", httpx.AsyncClient, "Store"], Awaitable[Runtime]] - - -def terminate() -> None: - """Ask uvicorn to shut down, from inside a background task. - - A background task cannot exit the process: SystemExit raised in one only - kills that task, is logged as "Task exception was never retrieved", and - uvicorn keeps serving a bot that will never connect. SIGTERM is the - signal uvicorn already handles, and the unit's start limit then turns the - resulting restart loop into a `failed` unit somebody can see. - """ - signal.raise_signal(signal.SIGTERM) - - -class Starter: - """Connects to signal-cli in the background, retrying until it works.""" - - def __init__( - self, - config: Config, - client: httpx.AsyncClient, - store: Store, - *, - sleeper: Sleeper | None = None, - stopper: Stopper | None = None, - factory: Factory = create_runtime, - ) -> None: - """Wire the retry loop to its config and its injectable I/O. - - Args: - config: Resolved runtime configuration. - client: HTTP client shared by every component. - store: The already-open tally database. - sleeper: Async sleep between attempts. Injected by the tests. - stopper: How to end the process on a fatal error. Injected by - the tests, which must not signal the pytest process. - factory: Builds the runtime. Injected by the tests. - """ - self._config = config - self._client = client - self._store = store - self._sleep = sleeper if sleeper is not None else asyncio.sleep - self._stop = stopper if stopper is not None else terminate - self._factory = factory - self.runtime: Runtime | None = None - self._attempts = 0 - self._detail = "not attempted yet" - self._fatal = False - - @property - def reason(self) -> str: - """Why the bot is not connected yet, in one line for /healthz.""" - return ( - f"connecting to {self._config.api_base_url}, attempt {self._attempts}: {self._detail}" - ) - - async def attempt(self) -> bool: - """Try once to build and start the runtime. - - Returns: - True once the bot is connected and its loops are running. - """ - self._attempts += 1 - try: - runtime = await self._factory(self._config, self._client, self._store) - except FATAL as exc: - self._detail = f"{type(exc).__name__}: {exc}" - self._fatal = True - logger.exception("cannot start, and no retry will help") - self._stop() - return False - except RETRYABLE as exc: - self._detail = f"{type(exc).__name__}: {exc}" - logger.warning("signal-cli not reachable yet (attempt %d): %s", self._attempts, exc) - return False - runtime.start() - self.runtime = runtime - logger.info( - "listening as %s after %d attempt(s), watching %d group(s): %r", - self._config.phone_number, - self._attempts, - len(runtime.bot.directory.groups), - list(runtime.bot.directory.groups), - ) - return True - - async def run(self, iterations: int | None = None) -> None: - """Attempt to connect until it works, or until it provably cannot. - - Args: - iterations: Stop after this many failed attempts. None means - keep trying forever, which is what production wants. - """ - completed = 0 - delay = FIRST_RETRY_SECONDS - while iterations is None or completed < iterations: - if await self.attempt() or self._fatal: - return - await self._sleep(delay) - delay = min(delay * BACKOFF_FACTOR, MAX_RETRY_SECONDS) - completed += 1 diff --git a/signal_bot/startup_retry.py b/signal_bot/startup_retry.py new file mode 100644 index 0000000..d498971 --- /dev/null +++ b/signal_bot/startup_retry.py @@ -0,0 +1,161 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""Retrying the connection to signal-cli until it works, in the background. + +Split out of ``startup.py`` so both stay under the 250-line cap. ``startup`` +owns *what a running bot is* (:class:`~signal_bot.startup.Runtime` and how it +is built); this module owns *getting there* -- the retry loop, its backoff, and +the one distinction that keeps the systemd start limit meaningful: only errors +that another attempt might fix are retried. A container in the wrong mode, or a +GROUP_IDS entry naming a group the bot is genuinely not in, are answers. +Retrying them forever would replace a visible failure with an invisible one. +""" + +from __future__ import annotations + +import asyncio +import logging +import signal +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING, Final + +import httpx + +from signal_bot.membership import MembershipError +from signal_bot.startup import NotReadyError, Runtime, create_runtime +from signal_bot.transport_rest import TransportError + +if TYPE_CHECKING: + from signal_bot.config import Config + from signal_bot.store import Store + +logger = logging.getLogger(__name__) + +FIRST_RETRY_SECONDS: Final = 5.0 +# Docker's own restart of signal-api plus signal-cli's account load took +# minutes on the reboot this exists for, so the ceiling is what the bot +# spends most of that wait at. A minute is short enough that recovery is +# still prompt and long enough that a long outage is not a busy loop. +MAX_RETRY_SECONDS: Final = 60.0 +BACKOFF_FACTOR: Final = 2.0 + +# Another attempt might fix these: the container is still booting, its HTTP +# server is up but answering 502, or it is returning a holding page that is +# not JSON. ValueError covers the last one -- json.JSONDecodeError is one. +# +# Note what else ValueError covers: registry.DuplicateTriggerError. A command +# alias claimed twice is a programming error that no retry can fix, but it is +# raised while build_bot runs inside create_runtime, so it lands here and the +# bot retries forever reporting "signal-cli not reachable yet" -- the wrong +# diagnosis entirely. tests/test_stats_commands.py builds the whole registry +# so that collision is caught at test time instead. +RETRYABLE: Final = (httpx.HTTPError, ValueError, NotReadyError) +# No further attempt will fix these. They are verdicts, not outages. +FATAL: Final = (TransportError, MembershipError) + +Sleeper = Callable[[float], Awaitable[None]] +Stopper = Callable[[], None] + +# Quoted: Config and Store are imported for type checking only, and this +# alias is evaluated at import time. +Factory = Callable[["Config", httpx.AsyncClient, "Store"], Awaitable[Runtime]] + + +def terminate() -> None: + """Ask uvicorn to shut down, from inside a background task. + + A background task cannot exit the process: SystemExit raised in one only + kills that task, is logged as "Task exception was never retrieved", and + uvicorn keeps serving a bot that will never connect. SIGTERM is the + signal uvicorn already handles, and the unit's start limit then turns the + resulting restart loop into a `failed` unit somebody can see. + """ + signal.raise_signal(signal.SIGTERM) + + +class Starter: + """Connects to signal-cli in the background, retrying until it works.""" + + def __init__( + self, + config: Config, + client: httpx.AsyncClient, + store: Store, + *, + sleeper: Sleeper | None = None, + stopper: Stopper | None = None, + factory: Factory = create_runtime, + ) -> None: + """Wire the retry loop to its config and its injectable I/O. + + Args: + config: Resolved runtime configuration. + client: HTTP client shared by every component. + store: The already-open event database. + sleeper: Async sleep between attempts. Injected by the tests. + stopper: How to end the process on a fatal error. Injected by + the tests, which must not signal the pytest process. + factory: Builds the runtime. Injected by the tests. + """ + self._config = config + self._client = client + self._store = store + self._sleep = sleeper if sleeper is not None else asyncio.sleep + self._stop = stopper if stopper is not None else terminate + self._factory = factory + self.runtime: Runtime | None = None + self._attempts = 0 + self._detail = "not attempted yet" + self._fatal = False + + @property + def reason(self) -> str: + """Why the bot is not connected yet, in one line for /healthz.""" + return ( + f"connecting to {self._config.api_base_url}, attempt {self._attempts}: {self._detail}" + ) + + async def attempt(self) -> bool: + """Try once to build and start the runtime. + + Returns: + True once the bot is connected and its loops are running. + """ + self._attempts += 1 + try: + runtime = await self._factory(self._config, self._client, self._store) + except FATAL as exc: + self._detail = f"{type(exc).__name__}: {exc}" + self._fatal = True + logger.exception("cannot start, and no retry will help") + self._stop() + return False + except RETRYABLE as exc: + self._detail = f"{type(exc).__name__}: {exc}" + logger.warning("signal-cli not reachable yet (attempt %d): %s", self._attempts, exc) + return False + runtime.start() + self.runtime = runtime + logger.info( + "listening as %s after %d attempt(s), watching %d group(s): %r", + self._config.phone_number, + self._attempts, + len(runtime.bot.directory.groups), + list(runtime.bot.directory.groups), + ) + return True + + async def run(self, iterations: int | None = None) -> None: + """Attempt to connect until it works, or until it provably cannot. + + Args: + iterations: Stop after this many failed attempts. None means + keep trying forever, which is what production wants. + """ + completed = 0 + delay = FIRST_RETRY_SECONDS + while iterations is None or completed < iterations: + if await self.attempt() or self._fatal: + return + await self._sleep(delay) + delay = min(delay * BACKOFF_FACTOR, MAX_RETRY_SECONDS) + completed += 1 diff --git a/signal_bot/stats/__init__.py b/signal_bot/stats/__init__.py new file mode 100644 index 0000000..3d1774f --- /dev/null +++ b/signal_bot/stats/__init__.py @@ -0,0 +1,8 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""Message statistics: the event log, the queries over it, and the renderers. + +Deliberately empty of re-exports. The renderers import the queries, the +queries import the event store, and a convenience re-export here would drag +the whole subpackage into every module that wanted one calendar helper. +Import from the module that owns the thing. +""" diff --git a/signal_bot/stats/events.py b/signal_bot/stats/events.py new file mode 100644 index 0000000..898b72d --- /dev/null +++ b/signal_bot/stats/events.py @@ -0,0 +1,158 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""The append-only message log, and the people who wrote it. + +One row per counted message, holding metadata only. There is deliberately no +column for the message body: the group's statistics need to know that someone +wrote 140 characters at 23:41, never what they wrote. ``test_events`` pins the +column set exactly so a later edit cannot quietly add one. + +Two tables, because they answer two different questions. ``message_events`` +answers "who said how much, when". ``group_people`` answers "who is in this +group", which the event log cannot: somebody who has never spoken has no +events, and the group listing spells its members inconsistently -- an ACI uuid +for most people but a +E.164 number for anyone the account has as a contact. +Carrying both spellings is what lets a roster entry find its events. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import sqlite3 + from collections.abc import Iterable + + +@dataclass(frozen=True, slots=True) +class MessageEvent: + """One counted message, reduced to the metadata the statistics need.""" + + group_id: str + uuid: str + display_name: str + sent_at_ms: int + day: str + local_hour: int + media_kind: str = "" + media_count: int = 0 + char_len: int = 0 + is_command: bool = False + is_reply: bool = False + + +@dataclass(frozen=True, slots=True) +class Person: + """Somebody who has written at least one message in a group.""" + + uuid: str + display_name: str + number: str + + +class EventStore: + """Reads and writes the message history. Shares the Store's connection.""" + + def __init__(self, conn: sqlite3.Connection) -> None: + """Wrap an open connection whose schema has already been applied.""" + self._conn = conn + + @property + def connection(self) -> sqlite3.Connection: + """The shared connection, for the query modules in this package.""" + return self._conn + + def record(self, event: MessageEvent) -> None: + """Append one message, ignoring a frame that has already been seen. + + ``ON CONFLICT DO NOTHING`` against the (group, sender, timestamp) + primary key is what makes a websocket redelivery harmless. signal-cli + replays undelivered frames when the bot reconnects, so without this + every restart would silently inflate the counts it just published. + """ + self._conn.execute( + """ + INSERT INTO message_events ( + group_id, uuid, display_name, sent_at_ms, day, local_hour, + media_kind, media_count, char_len, is_command, is_reply + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(group_id, uuid, sent_at_ms) DO NOTHING + """, + ( + event.group_id, + event.uuid, + event.display_name, + event.sent_at_ms, + event.day, + event.local_hour, + event.media_kind, + event.media_count, + event.char_len, + int(event.is_command), + int(event.is_reply), + ), + ) + self._conn.commit() + + def note_person( + self, group_id: str, uuid: str, display_name: str, number: str, day: str + ) -> None: + """Record that somebody wrote in a group today, under the name they used. + + ``display_name`` is last-write-wins here on purpose: this table + answers "what do we call them now". The name they had at the time is + preserved per row in ``message_events``, so a rename cannot rewrite + past attribution. + + ``number`` only updates when a non-empty one arrives. Envelopes carry + it inconsistently, and an empty string overwriting a known number + would break the roster join for exactly the people it exists for. + """ + self._conn.execute( + """ + INSERT INTO group_people ( + group_id, uuid, display_name, number, first_seen, last_seen + ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(group_id, uuid) DO UPDATE SET + display_name = excluded.display_name, + number = CASE + WHEN excluded.number <> '' THEN excluded.number + ELSE group_people.number + END, + last_seen = excluded.last_seen + """, + (group_id, uuid, display_name, number, day, day), + ) + self._conn.commit() + + def people(self, group_id: str) -> list[Person]: + """Everyone who has ever written in a group, by display name.""" + rows = self._conn.execute( + "SELECT uuid, display_name, number FROM group_people " + "WHERE group_id = ? ORDER BY display_name ASC", + (group_id,), + ).fetchall() + return [Person(r["uuid"], r["display_name"], r["number"]) for r in rows] + + def roster(self, group_id: str, identities: Iterable[str]) -> list[Person]: + """The named people among a set of listing identities. + + The group listing spells a member as either an ACI uuid or a +E.164 + number, so both columns are candidates. Anyone it cannot match has + never written here and has no name we could print -- the caller + counts them rather than naming them, because printing a raw uuid or + somebody's phone number into a group chat is a leak. + """ + wanted = {i for i in identities if i} + if not wanted: + return [] + return [p for p in self.people(group_id) if p.uuid in wanted or p.number in wanted] + + def first_day(self, group_id: str) -> str | None: + """The earliest day with an event, or None when there is no history.""" + row = self._conn.execute( + "SELECT MIN(day) AS first FROM message_events WHERE group_id = ?", + (group_id,), + ).fetchone() + first: str | None = row["first"] + return first diff --git a/signal_bot/stats/periods.py b/signal_bot/stats/periods.py new file mode 100644 index 0000000..77cc093 --- /dev/null +++ b/signal_bot/stats/periods.py @@ -0,0 +1,197 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""What a "day" is, and the periods built out of days. + +Every statistic in this package is grouped by a ``YYYY-MM-DD`` string in +Europe/Warsaw, never by a range of epoch milliseconds. SQLite has no idea what +Europe/Warsaw is, so the day is computed here at insert time and stored beside +the event; grouping on a stored string is the only arrangement where a +daylight-saving change cannot leak into a period boundary. + +Warsaw days are not all 24 hours -- 2026-03-29 is 23 and 2026-10-25 is 25, +both verified against the installed tzdata. Two consequences the rest of the +package depends on: + +* "Yesterday" is ``date - timedelta(days=1)`` on the *date*. Subtracting 86400 + seconds from a timestamp lands at 23:00 the previous evening on a short day. +* Any elapsed-time arithmetic must convert to UTC first. Subtracting two aware + datetimes that share a tzinfo gives the *wall-clock* difference, so the naive + spelling reports every day as exactly 24 hours and hides the bug entirely. + +Not named ``calendar``: ruff's A005 forbids shadowing a standard-library +module name, and this package would shadow ``calendar`` for its own imports. +""" + +from __future__ import annotations + +import datetime as dt +from dataclasses import dataclass +from typing import Final +from zoneinfo import ZoneInfo + +WARSAW: Final = ZoneInfo("Europe/Warsaw") + +MS_PER_SECOND: Final = 1000 + +# Nominative, for "Poniedziałek, 1 września". Index 0 is Monday, matching +# date.weekday() so the two never need translating between each other. +WEEKDAY_NAMES: Final = ( + "Poniedziałek", + "Wtorek", + "Środa", + "Czwartek", + "Piątek", + "Sobota", + "Niedziela", +) +# Locative-ish plural, for "wtorki mają średnio 74 wiadomości". +WEEKDAY_PLURAL: Final = ( + "poniedziałki", + "wtorki", + "środy", + "czwartki", + "piątki", + "soboty", + "niedziele", +) +# Genitive, because a Polish date reads "1 września", never "1 wrzesień". +MONTH_GENITIVE: Final = ( + "stycznia", + "lutego", + "marca", + "kwietnia", + "maja", + "czerwca", + "lipca", + "sierpnia", + "września", + "października", + "listopada", + "grudnia", +) +MONTH_NOMINATIVE: Final = ( + "styczeń", + "luty", + "marzec", + "kwiecień", + "maj", + "czerwiec", + "lipiec", + "sierpień", + "wrzesień", + "październik", + "listopad", + "grudzień", +) + + +def now() -> dt.datetime: + """Current Warsaw time. The group's day, not the host's.""" + return dt.datetime.now(WARSAW) + + +def moment(sent_at_ms: int) -> dt.datetime: + """A Signal millisecond timestamp as Warsaw wall-clock time.""" + return dt.datetime.fromtimestamp(sent_at_ms / MS_PER_SECOND, tz=WARSAW) + + +def day_of(sent_at_ms: int) -> str: + """The Warsaw calendar day a timestamp belongs to, as YYYY-MM-DD.""" + return moment(sent_at_ms).date().isoformat() + + +def hour_of(sent_at_ms: int) -> int: + """The Warsaw wall-clock hour, 0-23. + + On the 25-hour day two real hours share the value 2, and on the 23-hour + day nothing has it. Callers must not assume 24 buckets summing to a day. + """ + return moment(sent_at_ms).hour + + +def today(at: dt.datetime | None = None) -> str: + """The current Warsaw day as YYYY-MM-DD.""" + return (at or now()).astimezone(WARSAW).date().isoformat() + + +def shift(day: str, days: int) -> str: + """A day offset by whole calendar days, never by seconds.""" + return (dt.date.fromisoformat(day) + dt.timedelta(days=days)).isoformat() + + +def previous_day(at: dt.datetime | None = None) -> str: + """The last full Warsaw day -- what the nightly report is about.""" + return shift(today(at), -1) + + +def weekday_of(day: str) -> int: + """0 for Monday through 6 for Sunday.""" + return dt.date.fromisoformat(day).weekday() + + +def pl_date(day: str) -> str: + """A day as the group reads it: "Wtorek, 1 września".""" + date = dt.date.fromisoformat(day) + return f"{WEEKDAY_NAMES[date.weekday()]}, {date.day} {MONTH_GENITIVE[date.month - 1]}" + + +def pl_day_month(day: str) -> str: + """Just the date: "1 września". + + Used after a preposition, where the weekday would have to decline too -- + "od wtorek" is wrong and "od wtorku" needs a whole second name table for + one line of output. + """ + date = dt.date.fromisoformat(day) + return f"{date.day} {MONTH_GENITIVE[date.month - 1]}" + + +@dataclass(frozen=True, slots=True) +class Period: + """An inclusive span of Warsaw days, with a name the group would use.""" + + kind: str + start: str + end: str + label: str + + @property + def days(self) -> int: + """How many calendar days the span covers, both ends included.""" + return (dt.date.fromisoformat(self.end) - dt.date.fromisoformat(self.start)).days + 1 + + +def week_of(day: str) -> Period: + """The ISO week (Monday-Sunday) containing a day.""" + date = dt.date.fromisoformat(day) + start = date - dt.timedelta(days=date.weekday()) + end = start + dt.timedelta(days=6) + return Period("week", start.isoformat(), end.isoformat(), f"tydzień {start.isocalendar().week}") + + +def month_of(day: str) -> Period: + """The calendar month containing a day.""" + date = dt.date.fromisoformat(day) + start = date.replace(day=1) + end = (start + dt.timedelta(days=32)).replace(day=1) - dt.timedelta(days=1) + label = f"{MONTH_NOMINATIVE[date.month - 1]} {date.year}" + return Period("month", start.isoformat(), end.isoformat(), label) + + +def year_of(day: str) -> Period: + """The calendar year containing a day.""" + year = dt.date.fromisoformat(day).year + return Period("year", f"{year}-01-01", f"{year}-12-31", str(year)) + + +def previous(period: Period) -> Period: + """The period of the same kind immediately before this one. + + Used for every trend arrow, so it has to be exact rather than "minus + seven days": the month before a 31-day month is not 31 days long. + """ + before = shift(period.start, -1) + if period.kind == "week": + return week_of(before) + if period.kind == "month": + return month_of(before) + return year_of(before) diff --git a/signal_bot/stats/queries_day.py b/signal_bot/stats/queries_day.py new file mode 100644 index 0000000..4b9ffc6 --- /dev/null +++ b/signal_bot/stats/queries_day.py @@ -0,0 +1,113 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""What happened on one Warsaw day. + +Every query here groups on the stored ``day`` string rather than on a range of +timestamps. That is what makes the 23-hour and 25-hour days come out right -- +see ``signal_bot.stats.periods`` for why the alternative silently does not. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from signal_bot.stats.events import EventStore + + +@dataclass(frozen=True, slots=True) +class DayRow: + """One person's day.""" + + uuid: str + display_name: str + messages: int + media: int + commands: int + chars: int + + @property + def is_silent(self) -> bool: + """True when this person did not write at all.""" + return self.messages == 0 + + +@dataclass(frozen=True, slots=True) +class DayTotals: + """The group's day, as one line of summary.""" + + messages: int + media: int + commands: int + chars: int + speakers: int + + @property + def average_length(self) -> int: + """Mean characters per message, rounded, or 0 on an empty day.""" + if not self.messages: + return 0 + return round(self.chars / self.messages) + + +def day_rows(events: EventStore, group_id: str, day: str) -> list[DayRow]: + """Everyone who wrote on a day, most talkative first. + + Ties break on display name so the order is stable between two renders of + the same day -- a leaderboard that reshuffles itself reads as a bug. + """ + rows = events.connection.execute( + """ + SELECT uuid, + display_name, + COUNT(*) AS messages, + SUM(media_count) AS media, + SUM(is_command) AS commands, + SUM(char_len) AS chars + FROM message_events + WHERE group_id = ? AND day = ? + GROUP BY uuid + ORDER BY messages DESC, display_name ASC + """, + (group_id, day), + ).fetchall() + return [ + DayRow( + uuid=r["uuid"], + display_name=r["display_name"], + messages=r["messages"], + media=r["media"], + commands=r["commands"], + chars=r["chars"], + ) + for r in rows + ] + + +def day_totals(rows: list[DayRow]) -> DayTotals: + """Sum a day's rows. Derived from the rows so the two can never disagree.""" + return DayTotals( + messages=sum(r.messages for r in rows), + media=sum(r.media for r in rows), + commands=sum(r.commands for r in rows), + chars=sum(r.chars for r in rows), + speakers=sum(1 for r in rows if r.messages), + ) + + +def with_silent(rows: list[DayRow], roster: list[tuple[str, str]]) -> list[DayRow]: + """Append a zero row for every named group member who did not write. + + ``roster`` is (uuid, display_name) for the people whose names are known. + Anyone in the group whose name we have never seen is deliberately absent: + they exist in the listing only as a raw uuid or a phone number, and + printing either into a group chat would leak it. + """ + spoke = {r.uuid for r in rows} + silent = [ + DayRow(uuid=uuid, display_name=name, messages=0, media=0, commands=0, chars=0) + for uuid, name in roster + if uuid not in spoke + ] + silent.sort(key=lambda r: r.display_name) + return rows + silent diff --git a/signal_bot/stats/recorder.py b/signal_bot/stats/recorder.py new file mode 100644 index 0000000..3bb7a56 --- /dev/null +++ b/signal_bot/stats/recorder.py @@ -0,0 +1,67 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""Turning one parsed message into one row of history.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from signal_bot.counting import media_kind +from signal_bot.stats.events import MessageEvent +from signal_bot.stats.periods import MS_PER_SECOND, day_of, hour_of + +if TYPE_CHECKING: + import datetime as dt + + from signal_bot.models import IncomingMessage + from signal_bot.stats.events import EventStore + + +def to_event( + group_id: str, + message: IncomingMessage, + now: dt.datetime, + *, + is_command: bool, +) -> MessageEvent: + """Reduce a message to the metadata the statistics keep. + + The timestamp falls back to the clock because ``models._timestamp`` + returns 0 when neither the payload nor the envelope carried one. Zero is + not merely inaccurate: it is part of the primary key, so every + zero-timestamp frame from one sender would collide and the whole run + would be recorded as a single message. + """ + sent_at_ms = message.timestamp or int(now.timestamp() * MS_PER_SECOND) + return MessageEvent( + group_id=group_id, + uuid=message.source_uuid, + display_name=message.source_name or message.source_uuid, + sent_at_ms=sent_at_ms, + day=day_of(sent_at_ms), + local_hour=hour_of(sent_at_ms), + media_kind=media_kind(message), + media_count=len(message.attachment_types) or int(message.has_sticker), + char_len=len(message.text or ""), + is_command=is_command, + is_reply=message.is_reply, + ) + + +def record_message( + events: EventStore, + group_id: str, + message: IncomingMessage, + now: dt.datetime, + *, + is_command: bool, +) -> None: + """Append a message to the history and refresh who its sender is.""" + event = to_event(group_id, message, now, is_command=is_command) + events.record(event) + events.note_person( + group_id, + event.uuid, + event.display_name, + message.source_number, + event.day, + ) diff --git a/signal_bot/stats/render_day.py b/signal_bot/stats/render_day.py new file mode 100644 index 0000000..243951e --- /dev/null +++ b/signal_bot/stats/render_day.py @@ -0,0 +1,102 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""Rendering one day as the block the group reads. + +Plain text, because that is all Signal renders. No markdown, and no column +padding: Signal uses a proportional font, so ``ljust`` lines up in a terminal +and on nobody's phone. A separator that does not depend on glyph widths is the +only spelling that looks the same in both places. + +The block is capped at MAX_LINES so the nightly post stays something people +read rather than scroll past. When it has to give something up it gives up +speakers, never the highlight -- a leaderboard with the interesting part cut +off is worse than a shorter leaderboard. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from signal_bot.stats import text +from signal_bot.stats.periods import pl_date, pl_day_month + +if TYPE_CHECKING: + from signal_bot.stats.queries_day import DayRow, DayTotals + +MAX_LINES: Final = 12 +CROWN: Final = "\U0001f451" +CAMERA: Final = "\U0001f4f7" +CHART: Final = "\U0001f4ca" + + +def _media(count: int) -> str: + """The camera marker, outside any padded column so it cannot misalign.""" + return f" ({count} {CAMERA})" if count else "" + + +def person_line(row: DayRow, place: int) -> str: + """One person's line: a crown for first place, otherwise a rank.""" + lead = CROWN if place == 1 else f"{place}." + return f"{lead} {row.display_name} — {row.messages}{_media(row.media)}" + + +def silence_line(rows: list[DayRow], unnamed: int) -> str: + """Everyone who said nothing, in one line, or "" when everybody spoke. + + ``unnamed`` are group members who have never written here at all. They are + counted rather than named on purpose: the listing knows them only as a raw + uuid or a phone number, and printing either into a group chat is a leak. + """ + names = [row.display_name for row in rows if row.is_silent] + if unnamed: + names.append(f"i {text.people(unnamed)} bez ani jednej wiadomości tutaj") + return "Cisza: " + ", ".join(names) if names else "" + + +def summary_line(totals: DayTotals) -> str: + """Commands and average length -- the two facts that qualify the count.""" + if not totals.messages: + return "" + parts = [f"średnio {text.characters(totals.average_length)}"] + if totals.commands: + parts.insert(0, f"z tego {text.commands(totals.commands)}") + return parts[0].capitalize() + (f", {parts[1]}." if len(parts) > 1 else ".") + + +def render_day( + day: str, + rows: list[DayRow], + totals: DayTotals, + *, + since: str | None = None, + unnamed: int = 0, + highlights: list[str] | None = None, + heading: str | None = None, +) -> str: + """Compose the day block, trimmed to MAX_LINES.""" + header = ( + heading + or f"{CHART} {pl_date(day)} — {text.messages(totals.messages)}{_media(totals.media)}" + ) + tail = [line for line in (silence_line(rows, unnamed), summary_line(totals)) if line] + picks = highlights or [] + if since: + tail.append(f"(liczymy od {pl_day_month(since)})") + + # Everything except the speakers is mandatory, and at least one highlight + # slot is reserved before any speaker is allowed a line. + reserved = len(tail) + min(len(picks), 1) + spoke = [row for row in rows if not row.is_silent] + # At least one, so the truncation line below always has a line to occupy. + # The tail cannot realistically reach eleven lines, but a floor here is + # cheaper than a defensive branch nothing can ever exercise. + room = max(1, MAX_LINES - 1 - reserved) + people = [person_line(row, i + 1) for i, row in enumerate(spoke[:room])] + if room < len(spoke): + hidden = spoke[room - 1 :] + total = sum(r.messages for r in hidden) + people[-1] = f"…i {text.people(len(hidden))} więcej (razem {text.messages(total)})" + + lines = [header, *people] + lines.extend(picks[: max(0, MAX_LINES - len(lines) - len(tail))]) + lines.extend(tail) + return "\n".join(lines) diff --git a/signal_bot/stats/text.py b/signal_bot/stats/text.py new file mode 100644 index 0000000..d650587 --- /dev/null +++ b/signal_bot/stats/text.py @@ -0,0 +1,63 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""Polish number agreement, and the small text pieces the renderers share. + +Polish nouns take three forms after a numeral, not two. "1 znak", "2 znaki", +"5 znaków" -- and the rule is on the last *two* digits, so 12, 13 and 14 take +the many-form while 22, 23 and 24 do not. Getting this wrong is the kind of +thing the group notices immediately and mentions every time. +""" + +from __future__ import annotations + +from typing import Final + +TEEN_START: Final = 12 +TEEN_END: Final = 14 +FEW_START: Final = 2 +FEW_END: Final = 4 + + +def plural(count: int, one: str, few: str, many: str) -> str: + """Pick the Polish form that agrees with a count. + + Args: + count: The number the noun follows. + one: The form for exactly 1 ("znak"). + few: The form for 2-4 and most numbers ending 2-4 ("znaki"). + many: Every other case, including the 12-14 teens ("znaków"). + + Returns: + The bare noun, without the number. + """ + if count == 1: + return one + if TEEN_START <= count % 100 <= TEEN_END: + return many + if FEW_START <= count % 10 <= FEW_END: + return few + return many + + +def messages(count: int) -> str: + """The count and its noun, agreeing: e.g. 3 wiadomości.""" + return f"{count} {plural(count, 'wiadomość', 'wiadomości', 'wiadomości')}" + + +def characters(count: int) -> str: + """The count and its noun, agreeing: e.g. 44 znaki.""" + return f"{count} {plural(count, 'znak', 'znaki', 'znaków')}" + + +def commands(count: int) -> str: + """The count and its noun, agreeing: e.g. 4 komendy.""" + return f"{count} {plural(count, 'komenda', 'komendy', 'komend')}" + + +def people(count: int) -> str: + """The count and its noun, agreeing: e.g. 2 osoby.""" + return f"{count} {plural(count, 'osoba', 'osoby', 'osób')}" + + +def days(count: int) -> str: + """The count and its noun, agreeing: e.g. 3 dni.""" + return f"{count} {plural(count, 'dzień', 'dni', 'dni')}" diff --git a/signal_bot/store.py b/signal_bot/store.py index 16af1aa..1b01e98 100644 --- a/signal_bot/store.py +++ b/signal_bot/store.py @@ -1,33 +1,28 @@ # Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. -"""SQLite persistence for tallies, cooldowns, daily limits and votes. +"""SQLite persistence for cooldowns, daily limits, votes and group state. Everything the bot remembers lives here so a restart before the daily report does not lose the day. The original bot kept all of it in module globals. + +The message history itself is reached through ``store.events`` -- see +``signal_bot.stats.events``. It is kept apart because it is the one thing here +that is append-only and never discarded. """ from __future__ import annotations import contextlib import sqlite3 -from dataclasses import dataclass from typing import TYPE_CHECKING, Self -from signal_bot.schema import SCHEMA, SCHEMA_VERSION, TABLES +from signal_bot.schema import EPHEMERAL_TABLES, SCHEMA, SCHEMA_VERSION +from signal_bot.stats.events import EventStore if TYPE_CHECKING: from collections.abc import Iterator from types import TracebackType -@dataclass(frozen=True, slots=True) -class Tally: - """One person's message count for the current reporting period.""" - - uuid: str - display_name: str - count: int - - class Store: """Thin, synchronous SQLite wrapper. All state the bot keeps lives here.""" @@ -36,6 +31,7 @@ class Store: self._conn = sqlite3.connect(path) self._conn.row_factory = sqlite3.Row self._apply_schema() + self.events = EventStore(self._conn) def _apply_schema(self) -> None: """Create the tables, rebuilding them when the file predates them. @@ -53,7 +49,10 @@ class Store: """ version = int(self._conn.execute("PRAGMA user_version").fetchone()[0]) if version < SCHEMA_VERSION: - for table in TABLES: + # EPHEMERAL_TABLES, not TABLES. Rebuilding a cooldown table is + # free; rebuilding message_events would throw away the entire + # statistical history, which nothing can recompute. + for table in EPHEMERAL_TABLES: self._conn.execute(f"DROP TABLE IF EXISTS {table}") self._conn.executescript(SCHEMA) self._conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") @@ -77,17 +76,22 @@ class Store: self._conn.close() def forget_group(self, group_id: str) -> None: - """Drop every row belonging to one group. + """Drop one group's *ephemeral* rows: cooldowns, limits, votes, meta. - Called when the bot is removed from a group, so that being added - back starts a fresh tally and a clean cooldown rather than - resurrecting whatever was true whenever it was last in there. + Called when the bot is removed from a group, so that being added back + starts with a clean cooldown rather than resurrecting whatever was + true whenever it was last in there. + + What it deliberately no longer touches is the message history. A group + listing that transiently omits members -- the exact failure the + comment in discovery._permitted warns about -- would otherwise delete + years of statistics on the strength of one bad HTTP response. History + is permanent; only the live scaffolding is disposable. """ # Spelled out rather than looped over _TABLES: an f-string here is a # SQL-injection shape even when the input is a module constant, and # the repo does not take lint suppressions. test_forget_group_clears # _every_table is what keeps this list in step with the schema. - self._conn.execute("DELETE FROM message_counts WHERE group_id = ?", (group_id,)) self._conn.execute("DELETE FROM command_uses WHERE group_id = ?", (group_id,)) self._conn.execute("DELETE FROM daily_uses WHERE group_id = ?", (group_id,)) self._conn.execute("DELETE FROM votes WHERE group_id = ?", (group_id,)) @@ -113,36 +117,6 @@ class Store: ) self._conn.commit() - # -- message tallies --------------------------------------------------- - - def increment(self, group_id: str, uuid: str, display_name: str) -> None: - """Add one to a person's tally in one group, creating the row when new.""" - self._conn.execute( - """ - INSERT INTO message_counts (group_id, uuid, display_name, count) - VALUES (?, ?, ?, 1) - ON CONFLICT(group_id, uuid) DO UPDATE SET - count = count + 1, - display_name = excluded.display_name - """, - (group_id, uuid, display_name), - ) - self._conn.commit() - - def tallies(self, group_id: str) -> list[Tally]: - """Return one group's tallies, highest count first.""" - rows = self._conn.execute( - "SELECT uuid, display_name, count FROM message_counts " - "WHERE group_id = ? ORDER BY count DESC, display_name ASC", - (group_id,), - ).fetchall() - return [Tally(r["uuid"], r["display_name"], r["count"]) for r in rows] - - def reset_tallies(self, group_id: str) -> None: - """Clear one group's tallies, as its daily report does after publishing.""" - self._conn.execute("DELETE FROM message_counts WHERE group_id = ?", (group_id,)) - self._conn.commit() - # -- per-group, per-user, per-command rate limiting -------------------- def record_use(self, group_id: str, user_uuid: str, command: str, now: float, day: str) -> None: diff --git a/signal_bot/trust.py b/signal_bot/trust.py index 951f878..d83ed37 100644 --- a/signal_bot/trust.py +++ b/signal_bot/trust.py @@ -74,8 +74,13 @@ def parse_trusted_inviters(env: dict[str, str]) -> tuple[str, ...]: return tuple(trusted) -def _identities(entry: dict[str, Any], key: str) -> set[str]: - """Return one listing field as a set of strings, tolerating anything.""" +def identities(entry: dict[str, Any], key: str) -> set[str]: + """Return one listing field as a set of strings, tolerating anything. + + Public because the roster cache reads the same two fields off the same + listing rows, and two spellings of "who is in this group" is how the + nightly report would come to disagree with the trust gate. + """ values = entry.get(key) if not isinstance(values, list): return set() @@ -133,7 +138,7 @@ class InviteTrust: """ if not self._trusted or group_id in self._vouched: return True - members = _identities(entry, "members") - _identities(entry, "pending_invites") + members = identities(entry, "members") - identities(entry, "pending_invites") if self._trusted & members: return True if group_id not in self._refused: diff --git a/tests/conftest.py b/tests/conftest.py index 6085cb7..a49fd93 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -115,26 +115,38 @@ def clock() -> dt.datetime: return dt.datetime(2026, 8, 29, 12, 0, tzinfo=dt.UTC) +# The exotic payload shapes, one per key, so the builder below stays inside +# the argument limit instead of growing a boolean per frame variant. +EXTRAS: dict[str, dict[str, Any]] = { + "sticker": {"sticker": {"packId": "abc", "stickerId": 6}}, + "reaction": {"reaction": {"emoji": "👍", "targetAuthor": "x"}}, + "quote": {"quote": {"id": 1719177917000, "author": "x", "text": "..."}}, +} + + def envelope( *, text: str | None = "hello", group_id: str | None = WATCHED_GROUP, - uuid: str = "uuid-alice", - name: str = "Alice", - sticker: bool = False, - reaction: bool = False, + sender: tuple[str, str] = ("uuid-alice", "Alice"), sync: bool = False, + attachments: tuple[str, ...] = (), + extra: str = "", + timestamp: int = 1719177917717, ) -> str: """Build one raw websocket frame in either envelope shape.""" - payload: dict[str, Any] = {"timestamp": 1719177917717} + uuid, name = sender + payload: dict[str, Any] = {"timestamp": timestamp} if text is not None: payload["message"] = text if group_id is not None: payload["groupInfo"] = {"groupId": group_id, "type": "DELIVER"} - if sticker: - payload["sticker"] = {"packId": "abc", "stickerId": 6} - if reaction: - payload["reaction"] = {"emoji": "👍", "targetAuthor": "x"} + if extra: + payload.update(EXTRAS[extra]) + if attachments: + payload["attachments"] = [ + {"contentType": c, "id": str(i)} for i, c in enumerate(attachments) + ] body: dict[str, Any] = { "source": name, @@ -142,7 +154,7 @@ def envelope( "sourceUuid": uuid, "sourceName": name, "sourceDevice": 69, - "timestamp": 1719177917717, + "timestamp": timestamp, } if sync: body["syncMessage"] = {"sentMessage": payload} diff --git a/tests/test_bot.py b/tests/test_bot.py index 0d124b8..6f0fd1c 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -3,6 +3,7 @@ from __future__ import annotations +import datetime as dt from dataclasses import dataclass from typing import TYPE_CHECKING @@ -11,6 +12,8 @@ import httpx from signal_bot.bot import Bot, build_bot, now_local from signal_bot.discovery import GroupDirectory from signal_bot.liveness import Liveness +from signal_bot.stats.events import MessageEvent +from signal_bot.stats.periods import WARSAW from signal_bot.transport_fake import FakeTransport from signal_bot.trust import InviteTrust from tests.conftest import ( @@ -27,9 +30,36 @@ if TYPE_CHECKING: from signal_bot.store import Store +# The report covers the previous full day, so the tests freeze "now" and +# write their events into the day before it. +NOW = dt.datetime(2026, 9, 2, 21, 37, tzinfo=WARSAW) +YESTERDAY = "2026-09-01" + + def _bot(config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory) -> Bot: client = mock_client(lambda r: httpx.Response(200)) - return build_bot(config, client, store, transport, directory, Liveness(config, client)) + return build_bot( + config, client, store, transport, directory, Liveness(config, client), clock=lambda: NOW + ) + + +def wrote( + store: Store, group_id: str, uuid: str, name: str, times: int, *, day: str = YESTERDAY +) -> None: + """Put ``times`` messages from one person into one group on one day.""" + for i in range(times): + store.events.record( + MessageEvent( + group_id=group_id, + uuid=uuid, + display_name=name, + sent_at_ms=i + 1, + day=day, + local_hour=12, + char_len=5, + ) + ) + store.events.note_person(group_id, uuid, name, "", day) def test_now_local_is_timezone_aware() -> None: @@ -48,24 +78,43 @@ async def test_publish_report_posts_each_group_its_own_tally( config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory ) -> None: """Every group gets its own counts, in its own chat, and only its own.""" - store.increment(WATCHED_GROUP, "u1", "Alice") - store.increment(SECOND_GROUP, "u2", "Bob") - store.increment(SECOND_GROUP, "u2", "Bob") + wrote(store, WATCHED_GROUP, "u1", "Alice", 1) + wrote(store, SECOND_GROUP, "u2", "Bob", 2) await _bot(config, store, transport, directory).publish_report() - assert [(m.recipient, m.text) for m in transport.sent] == [ - (WATCHED_GROUP, "Alice 1"), - (SECOND_GROUP, "Bob 2"), - ] - assert store.tallies(WATCHED_GROUP) == [] - assert store.tallies(SECOND_GROUP) == [] + assert [m.recipient for m in transport.sent] == [WATCHED_GROUP, SECOND_GROUP] + assert "Alice" in transport.texts[0] + assert "Bob" in transport.texts[1] + assert "Alice" not in transport.texts[1] + + +async def test_the_report_covers_yesterday_not_today( + config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory +) -> None: + """Today's traffic belongs to tomorrow's report, not tonight's.""" + wrote(store, WATCHED_GROUP, "u1", "Alice", 1, day=YESTERDAY) + wrote(store, WATCHED_GROUP, "u2", "Bob", 9, day="2026-09-02") + await _bot(config, store, transport, directory).publish_report() + assert "Alice" in transport.texts[0] + assert "Bob" not in transport.texts[0] + + +async def test_nothing_is_deleted_after_publishing( + config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory +) -> None: + """The log is append-only; a second run renders the same day again.""" + wrote(store, WATCHED_GROUP, "u1", "Alice", 3) + bot = _bot(config, store, transport, directory) + await bot.publish_report() + await bot.publish_report() + assert transport.texts[0] == transport.texts[1] async def test_publish_report_falls_back_to_uuid_when_unnamed( config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory ) -> None: - store.increment(WATCHED_GROUP, "u1", "") + wrote(store, WATCHED_GROUP, "u1", "u1", 1) await _bot(config, store, transport, directory).publish_report() - assert transport.texts[0] == "u1 1" + assert "u1" in transport.texts[0] async def test_listen_dispatches_every_frame( @@ -73,7 +122,10 @@ async def test_listen_dispatches_every_frame( ) -> None: transport.queue([envelope(text="hello", group_id=WATCHED_GROUP)]) await _bot(config, store, transport, directory).listen() - assert store.tallies(WATCHED_GROUP)[0].count == 1 + row = store.events.connection.execute( + "SELECT COUNT(*) AS n FROM message_events WHERE group_id = ?", (WATCHED_GROUP,) + ).fetchone() + assert row["n"] == 1 @dataclass @@ -93,26 +145,24 @@ class RefusingTransport(FakeTransport): async def test_one_group_failing_its_report_does_not_cost_the_others( config: Config, store: Store, directory: GroupDirectory ) -> None: - """A failed send must not clear that group's day, nor skip the next group.""" - store.increment(WATCHED_GROUP, "u1", "Alice") - store.increment(SECOND_GROUP, "u2", "Bob") + """A failed send must not skip the next group.""" + wrote(store, WATCHED_GROUP, "u1", "Alice", 1) + wrote(store, SECOND_GROUP, "u2", "Bob", 1) transport = RefusingTransport(refuses=WATCHED_GROUP) await _bot(config, store, transport, directory).publish_report() - assert [(m.recipient, m.text) for m in transport.sent] == [(SECOND_GROUP, "Bob 1")] - # The group that failed keeps its counts; the one that succeeded is reset. - assert store.tallies(WATCHED_GROUP)[0].count == 1 - assert store.tallies(SECOND_GROUP) == [] + assert [m.recipient for m in transport.sent] == [SECOND_GROUP] + assert "Bob" in transport.texts[0] async def test_a_quiet_group_is_logged_rather_than_posted_into( config: Config, store: Store, transport: FakeTransport, directory: GroupDirectory ) -> None: """The bot sits in every group it was added to; a nightly ping is noise.""" - store.increment(SECOND_GROUP, "u2", "Bob") + wrote(store, SECOND_GROUP, "u2", "Bob", 1) await _bot(config, store, transport, directory).publish_report() - assert [(m.recipient, m.text) for m in transport.sent] == [(SECOND_GROUP, "Bob 1")] + assert [m.recipient for m in transport.sent] == [SECOND_GROUP] async def test_the_report_follows_the_live_group_set( @@ -122,7 +172,7 @@ async def test_the_report_follows_the_live_group_set( client = listing_client([group_entry(WATCHED_GROUP), group_entry(SECOND_GROUP)]) directory = GroupDirectory(config, client, store, InviteTrust(())) await directory.refresh() - store.increment(WATCHED_GROUP, "u1", "Alice") - store.increment(SECOND_GROUP, "u2", "Bob") + wrote(store, WATCHED_GROUP, "u1", "Alice", 1) + wrote(store, SECOND_GROUP, "u2", "Bob", 1) await _bot(config, store, transport, directory).publish_report() assert [m.recipient for m in transport.sent] == [WATCHED_GROUP, SECOND_GROUP] diff --git a/tests/test_config.py b/tests/test_config.py index 18d39dc..26303d6 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -10,8 +10,8 @@ from signal_bot.config import ( ConfigError, canonical_group_id, group_id_aliases, - load_config, ) +from signal_bot.config_env import load_config # The real spellings of one group, as the API reports them. PREFIXED = "group.WTI5dVptbG5MV2R5YjNWd0xXbGtMVE15TFdKNWRHVnpJU0VoSVNFaElRPT0=" diff --git a/tests/test_counting.py b/tests/test_counting.py index a7ddd70..4c27c48 100644 --- a/tests/test_counting.py +++ b/tests/test_counting.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING import pytest -from signal_bot.counting import format_tally, should_count, watched_group +from signal_bot.counting import media_kind, should_count, watched_group from signal_bot.models import parse_envelope from tests.conftest import OTHER_GROUP, SECOND_GROUP, WATCHED_GROUP, envelope @@ -42,20 +42,46 @@ def test_plain_text_counts() -> None: assert should_count(_message(text="hello")) -@pytest.mark.parametrize("kwargs", [{"sticker": True}, {"reaction": True}]) -def test_stickers_and_reactions_do_not_count(kwargs: dict[str, bool]) -> None: - assert not should_count(_message(**kwargs)) +def test_reactions_do_not_count() -> None: + """A reaction annotates somebody else's message; it is not one.""" + assert not should_count(_message(extra="reaction")) + + +def test_a_sticker_is_a_message() -> None: + assert should_count(_message(text=None, extra="sticker")) @pytest.mark.parametrize("text", [None, ""]) -def test_empty_text_does_not_count(text: str | None) -> None: +def test_empty_text_with_no_media_does_not_count(text: str | None) -> None: assert not should_count(_message(text=text)) -def test_format_tally_pads_names() -> None: - rendered = format_tally([("Alice", 5), ("Bo", 2)]) - assert rendered == "Alice 5\nBo 2" +@pytest.mark.parametrize("text", [None, ""]) +def test_a_caption_less_photo_counts(text: str | None) -> None: + """Until 0.1.0 this was worth zero, which is what the release fixes.""" + assert should_count(_message(text=text, attachments=("image/jpeg",))) -def test_format_tally_when_nobody_spoke() -> None: - assert format_tally([]) == "Brak wiadomosci dzisiaj." +def test_the_bots_own_messages_never_count() -> None: + """Taken from the sync branch, so it holds however the account is spelled.""" + assert not should_count(_message(text="hello", sync=True)) + + +@pytest.mark.parametrize( + ("attachments", "expected"), + [ + ((), ""), + (("image/jpeg",), "image"), + (("image/jpeg", "image/png"), "image"), + (("video/mp4",), "video"), + (("audio/aac",), "audio"), + (("application/pdf",), "file"), + (("image/jpeg", "video/mp4"), "mixed"), + ], +) +def test_media_kind(attachments: tuple[str, ...], expected: str) -> None: + assert media_kind(_message(attachments=attachments)) == expected + + +def test_a_sticker_is_its_own_kind() -> None: + assert media_kind(_message(text=None, extra="sticker")) == "sticker" diff --git a/tests/test_decorators.py b/tests/test_decorators.py index 90cc9a8..69b7d6a 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -30,7 +30,7 @@ def _context( now: float = 100.0, group: str = WATCHED_GROUP, ) -> CommandContext: - message = parse_envelope(envelope(text="!ping", uuid=uuid, group_id=group)) + message = parse_envelope(envelope(text="!ping", sender=(uuid, "Alice"), group_id=group)) assert message is not None return CommandContext( message=message, diff --git a/tests/test_discovery.py b/tests/test_discovery.py index a19e14e..5e52b8a 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -9,6 +9,7 @@ import httpx import pytest from signal_bot.discovery import GroupDirectory +from signal_bot.stats.events import MessageEvent from signal_bot.trust import InviteTrust from tests.conftest import ( OTHER_GROUP, @@ -164,10 +165,15 @@ async def test_a_row_without_a_usable_id_is_skipped( assert found.groups == () -async def test_a_removed_group_is_dropped_and_its_counts_purged( +async def test_a_removed_group_is_dropped_but_keeps_its_history( config: Config, store: Store ) -> None: - """A re-add should start a fresh tally, not resurrect an old one.""" + """Leaving clears the live scaffolding and keeps the statistics. + + Before 0.1.0 this purged the group's counts. It cannot any more: the same + code path fires when a listing transiently omits members, and losing years + of history to one bad HTTP response is not a trade worth making. + """ responses = iter( [ httpx.Response(200, json=[group_entry(WATCHED_GROUP), group_entry(SECOND_GROUP)]), @@ -176,15 +182,29 @@ async def test_a_removed_group_is_dropped_and_its_counts_purged( ) found = _directory(config, store, lambda r: next(responses)) await found.refresh() - store.increment(WATCHED_GROUP, "u1", "Alice") - store.increment(SECOND_GROUP, "u2", "Bob") + store.record_use(WATCHED_GROUP, "u1", "cat", 100.0, "2026-09-01") + store.events.record( + MessageEvent( + group_id=WATCHED_GROUP, + uuid="u1", + display_name="Alice", + sent_at_ms=1, + day="2026-09-01", + local_hour=12, + ) + ) + store.record_use(SECOND_GROUP, "u2", "cat", 200.0, "2026-09-01") await found.refresh() assert found.groups == (SECOND_GROUP,) assert found.resolve(WATCHED_INTERNAL) is None - assert store.tallies(WATCHED_GROUP) == [] - assert store.tallies(SECOND_GROUP)[0].count == 1 + assert store.last_used(WATCHED_GROUP, "u1", "cat") is None + kept = store.events.connection.execute( + "SELECT COUNT(*) AS n FROM message_events WHERE group_id = ?", (WATCHED_GROUP,) + ).fetchone() + assert kept["n"] == 1 + assert store.last_used(SECOND_GROUP, "u2", "cat") == 200.0 async def test_refresh_for_looks_again_once_per_unknown_group(config: Config, store: Store) -> None: diff --git a/tests/test_dispatcher.py b/tests/test_dispatcher.py index 205b344..a357be7 100644 --- a/tests/test_dispatcher.py +++ b/tests/test_dispatcher.py @@ -21,6 +21,14 @@ if TYPE_CHECKING: NOW = dt.datetime(2026, 8, 29, 12, 0, tzinfo=dt.UTC) +def counted(store: Store, group_id: str = WATCHED_GROUP) -> int: + """How many events one group holds.""" + row = store.events.connection.execute( + "SELECT COUNT(*) AS n FROM message_events WHERE group_id = ?", (group_id,) + ).fetchone() + return int(row["n"]) + + @pytest.fixture def runs() -> list[str]: return [] @@ -56,7 +64,7 @@ def dispatcher( async def test_counts_a_plain_group_message(dispatcher: Dispatcher, store: Store) -> None: await dispatcher.handle(envelope(text="hello")) - assert store.tallies(WATCHED_GROUP)[0].count == 1 + assert counted(store) == 1 async def test_runs_a_command_and_records_the_use( @@ -74,7 +82,7 @@ async def test_direct_messages_are_never_handled( """A linked device sees every DM on the account; none may reach a handler.""" await dispatcher.handle(envelope(text="!cat", group_id=None)) assert runs == [] - assert store.tallies(WATCHED_GROUP) == [] + assert counted(store) == 0 assert transport.sent == [] @@ -83,22 +91,51 @@ async def test_other_groups_are_never_handled( ) -> None: await dispatcher.handle(envelope(text="!cat", group_id=OTHER_GROUP)) assert runs == [] - assert store.tallies(WATCHED_GROUP) == [] + assert counted(store) == 0 async def test_unparseable_frames_are_dropped(dispatcher: Dispatcher, store: Store) -> None: await dispatcher.handle("not json") - assert store.tallies(WATCHED_GROUP) == [] + assert counted(store) == 0 -async def test_stickers_are_not_counted(dispatcher: Dispatcher, store: Store) -> None: - await dispatcher.handle(envelope(text="hi", sticker=True, sync=True)) - assert store.tallies(WATCHED_GROUP) == [] +async def test_the_bots_own_messages_are_not_counted(dispatcher: Dispatcher, store: Store) -> None: + """The sync branch is the account's own outgoing traffic.""" + await dispatcher.handle(envelope(text="hi", sync=True)) + assert counted(store) == 0 + + +async def test_stickers_are_counted(dispatcher: Dispatcher, store: Store) -> None: + await dispatcher.handle(envelope(text=None, extra="sticker")) + assert counted(store) == 1 + + +async def test_a_caption_less_photo_is_counted(dispatcher: Dispatcher, store: Store) -> None: + """Worth zero before 0.1.0. The whole point of the media change.""" + await dispatcher.handle(envelope(text=None, attachments=("image/jpeg",))) + assert counted(store) == 1 + + +async def test_a_replayed_frame_is_counted_once(dispatcher: Dispatcher, store: Store) -> None: + """A reconnect replays frames; the primary key is what absorbs them.""" + frame = envelope(text="hello") + await dispatcher.handle(frame) + await dispatcher.handle(frame) + assert counted(store) == 1 + + +async def test_a_command_is_recorded_as_one(dispatcher: Dispatcher, store: Store) -> None: + """is_command is resolved before the write, so it lands on the row.""" + await dispatcher.handle(envelope(text="!cat")) + row = store.events.connection.execute( + "SELECT is_command FROM message_events WHERE group_id = ?", (WATCHED_GROUP,) + ).fetchone() + assert row["is_command"] == 1 async def test_reactions_are_not_counted(dispatcher: Dispatcher, store: Store) -> None: - await dispatcher.handle(envelope(text="hi", reaction=True)) - assert store.tallies(WATCHED_GROUP) == [] + await dispatcher.handle(envelope(text="hi", extra="reaction")) + assert counted(store) == 0 async def test_non_command_text_runs_nothing(dispatcher: Dispatcher, runs: list[str]) -> None: @@ -130,7 +167,7 @@ async def test_a_failing_command_does_not_kill_the_loop( async def test_command_message_is_also_counted(dispatcher: Dispatcher, store: Store) -> None: await dispatcher.handle(envelope(text="!cat")) - assert store.tallies(WATCHED_GROUP)[0].count == 1 + assert counted(store) == 1 async def test_a_reply_goes_back_to_the_group_that_asked( @@ -156,8 +193,13 @@ async def test_a_reply_goes_back_to_the_group_that_asked( async def test_counts_are_kept_per_group(dispatcher: Dispatcher, store: Store) -> None: - await dispatcher.handle(envelope(text="hello", group_id=WATCHED_GROUP)) - await dispatcher.handle(envelope(text="hello", group_id=SECOND_GROUP)) - await dispatcher.handle(envelope(text="hello", group_id=SECOND_GROUP)) - assert store.tallies(WATCHED_GROUP)[0].count == 1 - assert store.tallies(SECOND_GROUP)[0].count == 2 + """Distinct timestamps: two frames one millisecond apart are two messages. + + Reusing one timestamp here would be absorbed by the dedupe key, which is + the behaviour test_a_replayed_frame_is_counted_once pins deliberately. + """ + await dispatcher.handle(envelope(text="hello", group_id=WATCHED_GROUP, timestamp=1)) + await dispatcher.handle(envelope(text="hello", group_id=SECOND_GROUP, timestamp=1)) + await dispatcher.handle(envelope(text="hello", group_id=SECOND_GROUP, timestamp=2)) + assert counted(store) == 1 + assert counted(store, SECOND_GROUP) == 2 diff --git a/tests/test_main.py b/tests/test_main.py index 148b957..6846d74 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -29,7 +29,7 @@ def _isolated_env(monkeypatch: pytest.MonkeyPatch) -> None: happily *adds* ones that are not -- so a real .env carrying BIND_PORT would silently decide the outcome of the default-port test. """ - monkeypatch.setattr("signal_bot.config.load_dotenv", lambda *a, **k: None) + monkeypatch.setattr("signal_bot.config_env.load_dotenv", lambda *a, **k: None) for key, value in BASE_ENV.items(): monkeypatch.setenv(key, value) for key in ("BIND_HOST", "BIND_PORT", "GROUP_ID", "GROUP_ID_SEND"): diff --git a/tests/test_models.py b/tests/test_models.py index 438ae4e..2cef3fc 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -32,14 +32,14 @@ def test_parses_own_account_sync_message() -> None: def test_detects_sticker_in_both_shapes(shape: dict[str, bool]) -> None: """The original bot only ever looked under dataMessage, so its own stickers were never filtered out of the tally.""" - message = parse_envelope(envelope(sticker=True, **shape)) + message = parse_envelope(envelope(extra="sticker", **shape)) assert message is not None assert message.has_sticker @pytest.mark.parametrize("shape", [{"sync": False}, {"sync": True}]) def test_detects_reaction_in_both_shapes(shape: dict[str, bool]) -> None: - message = parse_envelope(envelope(reaction=True, **shape)) + message = parse_envelope(envelope(extra="reaction", **shape)) assert message is not None assert message.is_reaction diff --git a/tests/test_startup.py b/tests/test_startup.py index 6f9ba7a..351ee61 100644 --- a/tests/test_startup.py +++ b/tests/test_startup.py @@ -16,19 +16,13 @@ from typing import TYPE_CHECKING import httpx import pytest -from signal_bot import startup as startup_module +from signal_bot import startup_retry as retry_module from signal_bot.bot import build_bot from signal_bot.discovery import GroupDirectory from signal_bot.liveness import Liveness from signal_bot.membership import MembershipError -from signal_bot.startup import ( - MAX_RETRY_SECONDS, - NotReadyError, - Runtime, - Starter, - create_runtime, - terminate, -) +from signal_bot.startup import NotReadyError, Runtime, create_runtime +from signal_bot.startup_retry import MAX_RETRY_SECONDS, Starter, terminate from signal_bot.transport_fake import FakeTransport from signal_bot.transport_rest import TransportError from signal_bot.trust import InviteTrust @@ -235,6 +229,6 @@ async def test_a_pinned_group_the_bot_is_not_in_is_fatal(config: Config, store: def test_terminate_asks_uvicorn_to_shut_down(monkeypatch: pytest.MonkeyPatch) -> None: """A background task cannot exit the process; SIGTERM is how it asks.""" sent: list[int] = [] - monkeypatch.setattr(startup_module.signal, "raise_signal", sent.append) + monkeypatch.setattr(retry_module.signal, "raise_signal", sent.append) terminate() assert sent == [signal.SIGTERM] diff --git a/tests/test_stats_events.py b/tests/test_stats_events.py new file mode 100644 index 0000000..bbfb923 --- /dev/null +++ b/tests/test_stats_events.py @@ -0,0 +1,102 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""The append-only message log: dedupe, naming, and the roster join.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from signal_bot.store import Store, open_store +from tests.test_store import GROUP, counted, event + +if TYPE_CHECKING: + from pathlib import Path + + +def test_the_event_table_holds_no_message_body(store: Store) -> None: + """The privacy guarantee, pinned so a later edit cannot quietly break it. + + The statistics need to know somebody wrote 140 characters at 23:41, never + what they wrote. Any new column here is a deliberate decision, not a + convenient place to stash a body. + """ + columns = { + row["name"] + for row in store.events.connection.execute("PRAGMA table_info(message_events)").fetchall() + } + assert columns == { + "group_id", + "uuid", + "display_name", + "sent_at_ms", + "day", + "local_hour", + "media_kind", + "media_count", + "char_len", + "is_command", + "is_reply", + } + + +def test_events_accumulate(store: Store) -> None: + store.events.record(event(at=1)) + store.events.record(event(at=2)) + store.events.record(event(uuid="u2", name="Bob", at=3)) + assert counted(store, GROUP) == 3 + + +def test_a_replayed_frame_is_recorded_once(store: Store) -> None: + """signal-cli replays undelivered frames when the bot reconnects. + + Without ON CONFLICT DO NOTHING every restart would inflate the counts the + bot had just published, and nothing in the output would look wrong. + """ + store.events.record(event(at=7)) + store.events.record(event(at=7)) + assert counted(store, GROUP) == 1 + + +def test_a_rename_does_not_rewrite_past_attribution(store: Store) -> None: + """group_people follows the new name; the events keep the old one.""" + store.events.record(event(name="Alice", at=1)) + store.events.note_person(GROUP, "u1", "Alice", "", "2026-08-29") + store.events.record(event(name="Alicja", at=2)) + store.events.note_person(GROUP, "u1", "Alicja", "", "2026-08-30") + assert store.events.people(GROUP)[0].display_name == "Alicja" + rows = store.events.connection.execute( + "SELECT display_name FROM message_events WHERE group_id = ? ORDER BY sent_at_ms", + (GROUP,), + ).fetchall() + assert [r["display_name"] for r in rows] == ["Alice", "Alicja"] + + +def test_a_known_number_is_not_erased_by_an_empty_one(store: Store) -> None: + """The roster join exists for exactly the people spelled by number.""" + store.events.note_person(GROUP, "u1", "Alice", "+15550001111", "2026-08-29") + store.events.note_person(GROUP, "u1", "Alice", "", "2026-08-30") + assert store.events.people(GROUP)[0].number == "+15550001111" + + +def test_roster_matches_either_spelling(store: Store) -> None: + """The listing gives a uuid for some members and a number for others.""" + store.events.note_person(GROUP, "u1", "Alice", "+15550001111", "2026-08-29") + store.events.note_person(GROUP, "u2", "Bob", "", "2026-08-29") + assert [p.display_name for p in store.events.roster(GROUP, ["+15550001111"])] == ["Alice"] + assert [p.display_name for p in store.events.roster(GROUP, ["u2"])] == ["Bob"] + assert store.events.roster(GROUP, []) == [] + assert store.events.roster(GROUP, ["nobody"]) == [] + + +def test_first_day_is_none_without_history(store: Store) -> None: + assert store.events.first_day(GROUP) is None + store.events.record(event(day="2026-08-29")) + store.events.record(event(at=2, day="2026-08-30")) + assert store.events.first_day(GROUP) == "2026-08-29" + + +def test_events_survive_a_restart(tmp_path: Path) -> None: + db = str(tmp_path / "restart.sqlite3") + with open_store(db) as first: + first.events.record(event()) + with open_store(db) as second: + assert counted(second, GROUP) == 1 diff --git a/tests/test_stats_periods.py b/tests/test_stats_periods.py new file mode 100644 index 0000000..078e095 --- /dev/null +++ b/tests/test_stats_periods.py @@ -0,0 +1,122 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""Warsaw days, and the periods built out of them.""" + +from __future__ import annotations + +import datetime as dt + +import pytest + +from signal_bot.stats import periods + +# 2026-09-01 21:37 Warsaw, as Signal would timestamp it. +TUESDAY_MS = int(dt.datetime(2026, 9, 1, 21, 37, tzinfo=periods.WARSAW).timestamp() * 1000) + + +def test_now_is_warsaw() -> None: + assert periods.now().tzinfo is periods.WARSAW + + +def test_day_and_hour_are_warsaw_wall_clock() -> None: + assert periods.day_of(TUESDAY_MS) == "2026-09-01" + assert periods.hour_of(TUESDAY_MS) == 21 + + +def test_a_late_evening_message_stays_on_its_own_day() -> None: + """23:50 Warsaw is still today, even though it is already tomorrow in UTC+3.""" + late = int(dt.datetime(2026, 9, 1, 23, 50, tzinfo=periods.WARSAW).timestamp() * 1000) + assert periods.day_of(late) == "2026-09-01" + + +@pytest.mark.parametrize( + ("day", "hours"), + [("2025-10-26", 25), ("2026-03-29", 23), ("2026-10-25", 25), ("2026-09-01", 24)], +) +def test_warsaw_days_are_not_all_24_hours(day: str, hours: int) -> None: + """The reason every period groups on the stored day string. + + Computed through UTC deliberately: subtracting two aware datetimes that + share a tzinfo gives the wall-clock difference, which reports all four of + these as 24 and hides the whole problem. + """ + date = dt.date.fromisoformat(day) + start = dt.datetime.combine(date, dt.time(), periods.WARSAW).astimezone(dt.UTC) + end = dt.datetime.combine(date + dt.timedelta(days=1), dt.time(), periods.WARSAW) + assert (end.astimezone(dt.UTC) - start).total_seconds() / 3600 == hours + + +def test_shift_crosses_a_short_day_by_the_date() -> None: + """Minus 86400 seconds would land at 23:00 the evening before.""" + assert periods.shift("2026-03-30", -1) == "2026-03-29" + assert periods.shift("2026-03-29", -1) == "2026-03-28" + + +def test_today_and_previous_day_take_an_injected_clock() -> None: + at = dt.datetime(2026, 9, 2, 0, 30, tzinfo=periods.WARSAW) + assert periods.today(at) == "2026-09-02" + assert periods.previous_day(at) == "2026-09-01" + + +def test_a_utc_clock_is_converted_rather_than_truncated() -> None: + """22:30 UTC is already the next day in Warsaw.""" + at = dt.datetime(2026, 9, 1, 22, 30, tzinfo=dt.UTC) + assert periods.today(at) == "2026-09-02" + + +def test_weekday_of() -> None: + assert periods.weekday_of("2026-09-01") == 1 + assert periods.WEEKDAY_NAMES[1] == "Wtorek" + assert periods.WEEKDAY_PLURAL[1] == "wtorki" + + +def test_pl_date_uses_the_genitive_month() -> None: + assert periods.pl_date("2026-09-01") == "Wtorek, 1 września" + assert periods.pl_date("2026-01-31") == "Sobota, 31 stycznia" + + +def test_week_is_monday_to_sunday() -> None: + week = periods.week_of("2026-09-01") + assert (week.start, week.end, week.days) == ("2026-08-31", "2026-09-06", 7) + assert week.label == "tydzień 36" + + +def test_month_bounds_and_length() -> None: + for day, end, length in [ + ("2026-09-15", "2026-09-30", 30), + ("2026-02-03", "2026-02-28", 28), + ("2028-02-03", "2028-02-29", 29), + ("2026-12-31", "2026-12-31", 31), + ]: + month = periods.month_of(day) + assert (month.end, month.days) == (end, length) + assert periods.month_of("2026-09-15").label == "wrzesień 2026" + + +def test_year_bounds() -> None: + year = periods.year_of("2026-09-01") + assert (year.start, year.end, year.label, year.days) == ( + "2026-01-01", + "2026-12-31", + "2026", + 365, + ) + + +@pytest.mark.parametrize( + ("day", "kind", "expected_start"), + [ + ("2026-09-01", "week", "2026-08-24"), + ("2026-09-15", "month", "2026-08-01"), + ("2026-09-15", "year", "2025-01-01"), + ], +) +def test_previous_period_is_exact(day: str, kind: str, expected_start: str) -> None: + """Not "minus seven days": the month before a 31-day month is not 31 days.""" + current = {"week": periods.week_of, "month": periods.month_of, "year": periods.year_of}[kind]( + day + ) + assert periods.previous(current).start == expected_start + + +def test_previous_month_of_january_is_december() -> None: + assert periods.previous(periods.month_of("2026-01-10")).label == "grudzień 2025" diff --git a/tests/test_stats_render_day.py b/tests/test_stats_render_day.py new file mode 100644 index 0000000..24d344c --- /dev/null +++ b/tests/test_stats_render_day.py @@ -0,0 +1,117 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""The nightly block: exact text, and the line budget.""" + +from __future__ import annotations + +from signal_bot.stats.queries_day import DayRow, day_totals, with_silent +from signal_bot.stats.render_day import MAX_LINES, render_day, summary_line + +# The real group, so the rendered example is a case that can actually occur. +GROUP = [ + ("Ala", 76, 12), + ("Jan Nowak", 42, 3), + ("kuhy", 38, 0), + ("Basia", 33, 1), + ("Franciszek Kowalczyk", 16, 0), + ("Cezary Wiśniewski", 15, 0), +] + + +def rows(spec: list[tuple[str, int, int]], *, commands: int = 0) -> list[DayRow]: + """Build day rows from (name, messages, media) triples.""" + return [ + DayRow( + uuid=f"u{i}", + display_name=name, + messages=messages, + media=media, + commands=commands if i == 0 else 0, + chars=messages * 47, + ) + for i, (name, messages, media) in enumerate(spec) + ] + + +def test_the_nightly_block_for_a_real_day() -> None: + """Pinned exactly: this is the only assertion that proves the rendering.""" + day_rows_ = with_silent(rows(GROUP), [("u9", "Marek")]) + rendered = render_day( + "2026-09-01", + day_rows_, + day_totals(day_rows_), + since="2026-08-30", + highlights=["🏆 Ala 76 — rekord życiowy (poprzedni 71)"], + ) + assert rendered == ( + "📊 Wtorek, 1 września — 220 wiadomości (16 📷)\n" + "👑 Ala — 76 (12 📷)\n" + "2. Jan Nowak — 42 (3 📷)\n" + "3. kuhy — 38\n" + "4. Basia — 33 (1 📷)\n" + "5. Franciszek Kowalczyk — 16\n" + "6. Cezary Wiśniewski — 15\n" + "🏆 Ala 76 — rekord życiowy (poprzedni 71)\n" + "Cisza: Marek\n" + "Średnio 47 znaków.\n" + "(liczymy od 30 sierpnia)" + ) + assert len(rendered.split("\n")) <= MAX_LINES + + +def test_a_day_with_no_media_shows_no_camera() -> None: + day = rows([("kuhy", 3, 0)]) + rendered = render_day("2026-09-01", day, day_totals(day)) + assert "📷" not in rendered + assert rendered.startswith("📊 Wtorek, 1 września — 3 wiadomości\n👑 kuhy — 3") + + +def test_commands_are_broken_out() -> None: + day = rows([("kuhy", 10, 0)], commands=4) + assert summary_line(day_totals(day)) == "Z tego 4 komendy, średnio 47 znaków." + + +def test_summary_is_empty_on_a_day_nobody_spoke() -> None: + assert summary_line(day_totals([])) == "" + assert day_totals([]).average_length == 0 + + +def test_unnamed_members_are_counted_never_named() -> None: + """Their only spellings are a uuid or a phone number; both would leak.""" + day = with_silent(rows([("kuhy", 3, 0)]), [("u9", "Marek")]) + rendered = render_day("2026-09-01", day, day_totals(day), unnamed=2) + assert "Cisza: Marek, i 2 osoby bez ani jednej wiadomości tutaj" in rendered + + +def test_a_big_group_is_truncated_and_still_fits() -> None: + """The rule the current group size cannot reach, fixed before it can.""" + big = rows([(f"Osoba {i:02d}", 30 - i, 0) for i in range(20)]) + rendered = render_day( + "2026-09-01", + big, + day_totals(big), + since="2026-08-30", + highlights=["🏆 rekord", "👑 korona", "📅 wtorki"], + ) + lines = rendered.split("\n") + assert len(lines) <= MAX_LINES + assert any(line.startswith("…i ") for line in lines) + # The reserved highlight slot survives the truncation. + assert "🏆 rekord" in rendered + + +def test_the_hidden_tail_reports_its_own_total() -> None: + day = rows([("A", 10, 0), ("B", 5, 0), ("C", 3, 0), ("D", 2, 0)]) + rendered = render_day( + "2026-09-01", + day, + day_totals(day), + since="2026-08-30", + highlights=["h1", "h2", "h3"], + ) + assert len(rendered.split("\n")) <= MAX_LINES + + +def test_a_custom_heading_replaces_the_date() -> None: + day = rows([("kuhy", 1, 0)]) + rendered = render_day("2026-09-01", day, day_totals(day), heading="Dziś, na razie") + assert rendered.startswith("Dziś, na razie\n") diff --git a/tests/test_stats_text.py b/tests/test_stats_text.py new file mode 100644 index 0000000..c6c23b8 --- /dev/null +++ b/tests/test_stats_text.py @@ -0,0 +1,60 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""Polish number agreement, which the group notices the moment it is wrong.""" + +from __future__ import annotations + +import pytest + +from signal_bot.stats import text + + +@pytest.mark.parametrize( + ("count", "expected"), + [ + (0, "0 znaków"), + (1, "1 znak"), + (2, "2 znaki"), + (4, "4 znaki"), + (5, "5 znaków"), + (11, "11 znaków"), + # The teens take the many-form even though they end in 2, 3 and 4 -- + # the rule reads the last two digits, not the last one. + (12, "12 znaków"), + (13, "13 znaków"), + (14, "14 znaków"), + (21, "21 znaków"), + (22, "22 znaki"), + (24, "24 znaki"), + (25, "25 znaków"), + (44, "44 znaki"), + (112, "112 znaków"), + (122, "122 znaki"), + ], +) +def test_characters_agree(count: int, expected: str) -> None: + assert text.characters(count) == expected + + +@pytest.mark.parametrize( + ("count", "expected"), + [(1, "1 wiadomość"), (2, "2 wiadomości"), (5, "5 wiadomości"), (220, "220 wiadomości")], +) +def test_messages_agree(count: int, expected: str) -> None: + assert text.messages(count) == expected + + +@pytest.mark.parametrize( + ("count", "expected"), [(1, "1 komenda"), (4, "4 komendy"), (5, "5 komend")] +) +def test_commands_agree(count: int, expected: str) -> None: + assert text.commands(count) == expected + + +@pytest.mark.parametrize(("count", "expected"), [(1, "1 osoba"), (3, "3 osoby"), (7, "7 osób")]) +def test_people_agree(count: int, expected: str) -> None: + assert text.people(count) == expected + + +@pytest.mark.parametrize(("count", "expected"), [(1, "1 dzień"), (2, "2 dni"), (9, "9 dni")]) +def test_days_agree(count: int, expected: str) -> None: + assert text.days(count) == expected diff --git a/tests/test_store.py b/tests/test_store.py index 45cfdd5..02740f4 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -7,6 +7,7 @@ import sqlite3 from typing import TYPE_CHECKING from signal_bot.schema import SCHEMA_VERSION +from signal_bot.stats.events import MessageEvent from signal_bot.store import Store, open_store if TYPE_CHECKING: @@ -16,38 +17,30 @@ GROUP = "group-a==" OTHER = "group-b==" -def test_increment_creates_and_accumulates(store: Store) -> None: - store.increment(GROUP, "u1", "Alice") - store.increment(GROUP, "u1", "Alice") - store.increment(GROUP, "u2", "Bob") - assert [(t.display_name, t.count) for t in store.tallies(GROUP)] == [("Alice", 2), ("Bob", 1)] +def event( + uuid: str = "u1", + name: str = "Alice", + at: int = 1, + day: str = "2026-08-29", + group_id: str = GROUP, +) -> MessageEvent: + """One minimal event. ``at`` also disambiguates the primary key.""" + return MessageEvent( + group_id=group_id, + uuid=uuid, + display_name=name, + sent_at_ms=at, + day=day, + local_hour=12, + ) -def test_display_name_is_refreshed(store: Store) -> None: - store.increment(GROUP, "u1", "Alice") - store.increment(GROUP, "u1", "Alicja") - assert store.tallies(GROUP)[0].display_name == "Alicja" - - -def test_tallies_sorted_by_count_then_name(store: Store) -> None: - store.increment(GROUP, "u1", "Zoe") - store.increment(GROUP, "u2", "Adam") - assert [t.display_name for t in store.tallies(GROUP)] == ["Adam", "Zoe"] - - -def test_reset_clears_everything(store: Store) -> None: - store.increment(GROUP, "u1", "Alice") - store.reset_tallies(GROUP) - assert store.tallies(GROUP) == [] - - -def test_tallies_survive_a_restart(tmp_path: Path) -> None: - """The old bot kept the day's tally in a module global.""" - db = str(tmp_path / "bot.sqlite3") - with open_store(db) as first: - first.increment(GROUP, "u1", "Alice") - with open_store(db) as second: - assert second.tallies(GROUP)[0].count == 1 +def counted(store: Store, group_id: str) -> int: + """How many events one group holds.""" + row = store.events.connection.execute( + "SELECT COUNT(*) AS n FROM message_events WHERE group_id = ?", (group_id,) + ).fetchone() + return int(row["n"]) def test_last_used_is_none_before_first_use(store: Store) -> None: @@ -94,25 +87,40 @@ def test_clear_votes(store: Store) -> None: def test_store_is_a_context_manager(tmp_path: Path) -> None: with Store(str(tmp_path / "x.sqlite3")) as opened: - opened.increment(GROUP, "u1", "Alice") - assert opened.tallies.__self__ is opened + opened.events.record(event()) + assert opened.meta.__self__ is opened def test_state_is_scoped_per_group(store: Store) -> None: """The whole point of the group key: two groups never share a counter.""" - store.increment(GROUP, "u1", "Alice") - store.increment(OTHER, "u1", "Alice") - store.increment(OTHER, "u1", "Alice") - assert store.tallies(GROUP)[0].count == 1 - assert store.tallies(OTHER)[0].count == 2 + store.events.record(event(at=1)) + store.events.record(event(at=1, group_id=OTHER)) + store.events.record(event(at=2, group_id=OTHER)) + assert counted(store, GROUP) == 1 + assert counted(store, OTHER) == 2 -def test_reset_clears_only_its_own_group(store: Store) -> None: - store.increment(GROUP, "u1", "Alice") - store.increment(OTHER, "u1", "Alice") - store.reset_tallies(GROUP) - assert store.tallies(GROUP) == [] - assert store.tallies(OTHER)[0].count == 1 +def test_forget_group_keeps_the_history(store: Store) -> None: + """The reversal 0.1.0 turns on, and why it matters. + + A group listing that transiently omits members makes discovery drop the + group, which calls this. Before 0.1.0 that cost a day's counter; against + a permanent history it would delete every statistic the group has. + """ + store.events.record(event()) + store.events.note_person(GROUP, "u1", "Alice", "", "2026-08-29") + store.record_use(GROUP, "u1", "cat", 100.0, "2026-08-29") + store.add_vote(GROUP, "cmd", "u1", 100.0) + store.set_meta(GROUP, "announced_version", "0.0.7") + + store.forget_group(GROUP) + + assert store.last_used(GROUP, "u1", "cat") is None + assert store.uses_today(GROUP, "u1", "cat", "2026-08-29") == 0 + assert store.count_votes(GROUP, "cmd", 0.0) == 0 + assert store.meta(GROUP, "announced_version") is None + assert counted(store, GROUP) == 1 + assert [p.display_name for p in store.events.people(GROUP)] == ["Alice"] def test_rate_limits_are_scoped_per_group(store: Store) -> None: @@ -138,27 +146,46 @@ def test_a_pre_group_database_is_rebuilt(tmp_path: Path) -> None: """ db = str(tmp_path / "old.sqlite3") legacy = sqlite3.connect(db) - legacy.execute( - "CREATE TABLE message_counts (uuid TEXT PRIMARY KEY, " - "display_name TEXT NOT NULL, count INTEGER NOT NULL DEFAULT 0)" - ) - legacy.execute("INSERT INTO message_counts VALUES ('u1', 'Alice', 7)") + legacy.execute("CREATE TABLE command_uses (uuid TEXT PRIMARY KEY, used_at REAL NOT NULL)") + legacy.execute("INSERT INTO command_uses VALUES ('u1', 7.0)") legacy.commit() legacy.close() with open_store(db) as store: - store.increment(GROUP, "u1", "Alice") - assert [(t.display_name, t.count) for t in store.tallies(GROUP)] == [("Alice", 1)] + store.record_use(GROUP, "u1", "cat", 100.0, "2026-08-29") + assert store.last_used(GROUP, "u1", "cat") == 100.0 check = sqlite3.connect(db) assert int(check.execute("PRAGMA user_version").fetchone()[0]) == SCHEMA_VERSION check.close() +def test_a_schema_rebuild_never_drops_the_history(tmp_path: Path) -> None: + """The second destructive path the EPHEMERAL/PERMANENT split governs. + + forget_group is the obvious one. This is the other: a future session + bumping SCHEMA_VERSION for some cooldown table must not take years of + message history with it. + """ + db = str(tmp_path / "bump.sqlite3") + with open_store(db) as first: + first.events.record(event()) + first.record_use(GROUP, "u1", "cat", 100.0, "2026-08-29") + + aged = sqlite3.connect(db) + aged.execute("PRAGMA user_version = 0") + aged.commit() + aged.close() + + with open_store(db) as second: + assert counted(second, GROUP) == 1 + assert second.last_used(GROUP, "u1", "cat") is None + + def test_a_current_database_is_left_alone(tmp_path: Path) -> None: - """Reopening must not wipe the day's tally on every restart.""" + """Reopening must not wipe the history on every restart.""" db = str(tmp_path / "current.sqlite3") with open_store(db) as first: - first.increment(GROUP, "u1", "Alice") + first.events.record(event()) with open_store(db) as second: - assert second.tallies(GROUP)[0].count == 1 + assert counted(second, GROUP) == 1 diff --git a/tests/test_trust.py b/tests/test_trust.py index 1ba9938..07a4625 100644 --- a/tests/test_trust.py +++ b/tests/test_trust.py @@ -181,7 +181,7 @@ def test_ordinary_frames_vouch_for_nothing(config: Config, store: Store) -> None async def test_an_adopted_group_is_never_un_watched_by_the_gate( config: Config, store: Store ) -> None: - """Adoption is itself the vouch; a later listing must not purge a tally. + """Adoption is itself the vouch; a later listing must not purge its state. The listing spells a member as a phone number for a known contact and as a bare uuid for everybody else, and the same group has been seen both @@ -201,8 +201,8 @@ async def test_an_adopted_group_is_never_un_watched_by_the_gate( found = GroupDirectory(config, client, store, InviteTrust((TRUSTED_NUMBER,))) await found.refresh() assert found.groups == (WATCHED_GROUP,) - store.increment(WATCHED_GROUP, "uuid-alice", "Alice") + store.record_use(WATCHED_GROUP, "uuid-alice", "cat", 100.0, "2026-09-01") await found.refresh() assert found.groups == (WATCHED_GROUP,) - assert [(t.display_name, t.count) for t in store.tallies(WATCHED_GROUP)] == [("Alice", 1)] + assert store.last_used(WATCHED_GROUP, "uuid-alice", "cat") == 100.0