mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:17:02 +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 +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
117 lines
4.7 KiB
Python
117 lines
4.7 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Coming up, including when signal-cli is not there yet.
|
|
|
|
On 2026-08-30 the machine rebooted, the bot started before the signal-api
|
|
container was ready, and its very first HTTP call raised. uvicorn reported
|
|
"Application startup failed", the unit burned its five restarts in 25
|
|
seconds, and it sat in `failed` for eight hours while the container had been
|
|
healthy for almost all of them. Nobody noticed, because a unit nobody is
|
|
watching is indistinguishable from a quiet group.
|
|
|
|
So the connection to Signal is no longer part of starting up. uvicorn serves
|
|
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.
|
|
|
|
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
|
|
from dataclasses import dataclass, field
|
|
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.scheduler import DailyScheduler
|
|
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
|
|
|
|
|
|
class NotReadyError(RuntimeError):
|
|
"""Raised when signal-cli answers but cannot yet say what it knows."""
|
|
|
|
|
|
@dataclass
|
|
class Runtime:
|
|
"""The running bot and the background tasks it owns."""
|
|
|
|
bot: Bot
|
|
tasks: list[asyncio.Task[None]] = field(default_factory=list)
|
|
|
|
def start(self) -> None:
|
|
"""Spawn the listener and the daily scheduler as background tasks."""
|
|
scheduler = DailyScheduler(
|
|
self.bot.config.report_hour,
|
|
self.bot.config.report_minute,
|
|
self.bot.publish_report,
|
|
now_local,
|
|
asyncio.sleep,
|
|
)
|
|
self.tasks = [
|
|
asyncio.create_task(self.bot.listen()),
|
|
asyncio.create_task(scheduler.run()),
|
|
# Third loop: without it, a group added later is only noticed
|
|
# when somebody happens to speak in it.
|
|
asyncio.create_task(self.bot.directory.run()),
|
|
# Fourth loop: without it, a dead connection to Signal is
|
|
# indistinguishable from a quiet evening, for hours.
|
|
asyncio.create_task(self.bot.liveness.run()),
|
|
# Fifth, and one-shot: says hello with a changelog when the
|
|
# running version is newer than what a group was last told.
|
|
asyncio.create_task(self.bot.announce_release()),
|
|
]
|
|
|
|
async def stop(self) -> None:
|
|
"""Cancel the background tasks. The caller owns the client and store."""
|
|
for task in self.tasks:
|
|
task.cancel()
|
|
for task in self.tasks:
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await task
|
|
|
|
|
|
async def create_runtime(config: Config, client: httpx.AsyncClient, store: Store) -> Runtime:
|
|
"""Build a Runtime, verifying the container exposes a WebSocket first.
|
|
|
|
Args:
|
|
config: Resolved runtime configuration.
|
|
client: HTTP client shared by every component.
|
|
store: The already-open tally database. Owned by the caller, because
|
|
this function is retried and must not leak a connection per try.
|
|
|
|
Returns:
|
|
A Runtime whose loops have not been started yet.
|
|
|
|
Raises:
|
|
MembershipError: GROUP_IDS names a group the bot is not a member of,
|
|
established against a listing that was actually read.
|
|
"""
|
|
transport = RestTransport(config, client)
|
|
await transport.verify_mode()
|
|
directory = GroupDirectory(config, client, store, InviteTrust(config.trusted_inviters))
|
|
# Only a listing that really arrived can convict the allowlist. A refresh
|
|
# that failed leaves the watched set empty, and judging GROUP_IDS on that
|
|
# would turn "the container is not up yet" into "you are not in your own
|
|
# group" -- fatal, and wrong.
|
|
if not await directory.refresh():
|
|
msg = f"group listing at {config.groups_url} could not be read"
|
|
raise NotReadyError(msg)
|
|
await directory.verify_allowlist()
|
|
# No probe here: /v1/devices has been seen to hang past 20s, and a
|
|
# startup that can hang is a startup that can burn the unit's restart
|
|
# 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))
|