feat: the message event log, and the nightly report built on it

The tally was one integer per person, deleted after each post. Nothing
dated survived it, so weeks, months, records, streaks and weekday
averages were all unanswerable. This replaces it with an append-only
per-message log holding metadata only -- never a message body -- and
rebuilds the nightly report on top.

What changes for the group: a photo posted without a caption now counts
(it was worth zero), stickers count, the bot's own messages no longer
do, people who said nothing appear with a 0, and the report covers the
previous full calendar day in Europe/Warsaw instead of "since the last
post" -- which used to merge two days whenever a group went quiet.

Notable decisions, each with the trap it avoids:

- Events and people are PERMANENT_TABLES: neither forget_group nor a
  future SCHEMA_VERSION bump may drop them. Both paths used to cost a
  day's counter and would now cost the entire history, and forget_group
  fires on a listing that merely omits members.
- is_own comes 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.
- The roster is stashed in refresh(), not _adopt(), which returns early
  once the watched set stops changing -- i.e. always, in the steady
  state.
- The roster matches on uuid OR number because /v1/groups spells kuhy as
  +48123456789 while his messages carry an ACI uuid. Joining on uuid
  alone would have listed him twice every night, once with 0.
- Members whose display name we have never seen are counted, not named:
  their only spellings are a uuid or a phone number.
- (group, uuid, sent_at_ms) is the primary key, so a websocket
  redelivery after a reconnect cannot inflate the counts.
- Days come from a stored Warsaw date string, never a timestamp range:
  2026-03-29 is 23 hours long and 2026-10-25 is 25.
- Polish number agreement is a real rule with a 12-14 exception, so
  "44 znaki" and "45 znaków" are both spelled correctly.

config.py and startup.py were two lines under the 250-line cap and both
had to grow, so config_env and startup_retry come out of them first, as
pure moves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
This commit is contained in:
Krzysztof Rudnicki 2026-08-30 19:14:43 +02:00
parent 558dd297a4
commit dc1995b558
38 changed files with 1992 additions and 456 deletions

View File

@ -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

View File

@ -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__

View File

@ -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,
)

View File

@ -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),
)

138
signal_bot/config_env.py Normal file
View File

@ -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),
)

View File

@ -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"

View File

@ -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")

View File

@ -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

View File

@ -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,
)

61
signal_bot/roster.py Normal file
View File

@ -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())

View File

@ -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

View File

@ -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

161
signal_bot/startup_retry.py Normal file
View File

@ -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

View File

@ -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.
"""

158
signal_bot/stats/events.py Normal file
View File

@ -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

197
signal_bot/stats/periods.py Normal file
View File

@ -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)

View File

@ -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

View File

@ -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,
)

View File

@ -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)

63
signal_bot/stats/text.py Normal file
View File

@ -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')}"

View File

@ -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:

View File

@ -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:

View File

@ -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}

View File

@ -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]

View File

@ -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="

View File

@ -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"

View File

@ -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,

View File

@ -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:

View File

@ -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

View File

@ -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"):

View File

@ -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

View File

@ -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]

102
tests/test_stats_events.py Normal file
View File

@ -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

122
tests/test_stats_periods.py Normal file
View File

@ -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"

View File

@ -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")

60
tests/test_stats_text.py Normal file
View File

@ -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

View File

@ -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

View File

@ -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