mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:17:02 +01:00
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:
parent
558dd297a4
commit
dc1995b558
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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__
|
||||
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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
138
signal_bot/config_env.py
Normal 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),
|
||||
)
|
||||
@ -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"
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
61
signal_bot/roster.py
Normal 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())
|
||||
@ -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
|
||||
|
||||
@ -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
161
signal_bot/startup_retry.py
Normal 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
|
||||
8
signal_bot/stats/__init__.py
Normal file
8
signal_bot/stats/__init__.py
Normal 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
158
signal_bot/stats/events.py
Normal 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
197
signal_bot/stats/periods.py
Normal 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)
|
||||
113
signal_bot/stats/queries_day.py
Normal file
113
signal_bot/stats/queries_day.py
Normal 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
|
||||
67
signal_bot/stats/recorder.py
Normal file
67
signal_bot/stats/recorder.py
Normal 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,
|
||||
)
|
||||
102
signal_bot/stats/render_day.py
Normal file
102
signal_bot/stats/render_day.py
Normal 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
63
signal_bot/stats/text.py
Normal 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')}"
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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="
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"):
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
102
tests/test_stats_events.py
Normal 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
122
tests/test_stats_periods.py
Normal 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"
|
||||
117
tests/test_stats_render_day.py
Normal file
117
tests/test_stats_render_day.py
Normal 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
60
tests/test_stats_text.py
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user