mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-12 08:51:04 +01:00
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 +48516501330 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
198 lines
6.0 KiB
Python
198 lines
6.0 KiB
Python
# 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)
|