mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 18:34:07 +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
155 lines
5.9 KiB
Python
155 lines
5.9 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Who is allowed to put this bot in a group.
|
|
|
|
The bot adopts every group its account is a member of, which is what makes
|
|
"add the bot" the whole setup. The cost is that *anybody* who knows the
|
|
number can pull it into a group and have it answer there. TRUSTED_INVITERS
|
|
puts a name on the door without taking the convenience away: a group still
|
|
needs no config edit and no restart, it just has to involve somebody the
|
|
operator listed.
|
|
|
|
Two independent pieces of evidence, because neither covers the other:
|
|
|
|
- the **group-update envelope**, which is the only frame that names who
|
|
changed the membership. It is authoritative, and it arrives before the
|
|
first message in a new group -- but only if the bot was running.
|
|
- the **listing row**, used when the bot was down when the group appeared.
|
|
``/v1/groups`` never says who invited anyone, so the proxy is "a trusted
|
|
identity is a full member of this group". Deliberately a proxy: it stops
|
|
a stranger's private group, and it does not stop a stranger's group that
|
|
a trusted person genuinely joined.
|
|
|
|
Identities are matched as the listing spells them. A row lists a phone
|
|
number for contacts the account knows and a bare UUID for everybody else,
|
|
so TRUSTED_INVITERS accepts either form and a refusal logs the row's own
|
|
members, which is what tells the operator which string to add.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from typing import Any, Final
|
|
|
|
from signal_bot.groups import ConfigError, canonical_group_id
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
TRUSTED_INVITERS_KEY: Final = "TRUSTED_INVITERS"
|
|
# +E.164, or the UUID the listing uses for a member the account has never
|
|
# exchanged messages with.
|
|
_E164: Final = re.compile(r"^\+[1-9]\d{6,14}$")
|
|
_UUID: Final = re.compile(r"^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$")
|
|
|
|
|
|
def parse_trusted_inviters(env: dict[str, str]) -> tuple[str, ...]:
|
|
"""Return the identities allowed to add this bot to a group.
|
|
|
|
Args:
|
|
env: The environment mapping to read.
|
|
|
|
Returns:
|
|
The listed identities, de-duplicated and in order. Empty means the
|
|
gate is off and any group the account is in will be watched.
|
|
|
|
Raises:
|
|
ConfigError: An entry is neither +E.164 nor a UUID.
|
|
"""
|
|
raw = env.get(TRUSTED_INVITERS_KEY, "").strip()
|
|
if not raw:
|
|
return ()
|
|
trusted: list[str] = []
|
|
for item in raw.split(","):
|
|
identity = item.strip()
|
|
if not identity:
|
|
continue
|
|
if not _E164.match(identity) and not _UUID.match(identity):
|
|
msg = (
|
|
f"{TRUSTED_INVITERS_KEY} entries must be +E.164 numbers or member "
|
|
f"UUIDs, got {identity!r}"
|
|
)
|
|
raise ConfigError(msg)
|
|
if identity not in trusted:
|
|
trusted.append(identity)
|
|
return tuple(trusted)
|
|
|
|
|
|
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()
|
|
return {value for value in values if isinstance(value, str)}
|
|
|
|
|
|
class InviteTrust:
|
|
"""Decides whether a group in the listing is one the operator asked for."""
|
|
|
|
def __init__(self, trusted: tuple[str, ...]) -> None:
|
|
"""Wire the gate to its allowlist.
|
|
|
|
Args:
|
|
trusted: Identities permitted to add the bot to a group. Empty
|
|
turns the gate off, which is said out loud rather than
|
|
assumed, because the difference is who can make this bot
|
|
talk in their group.
|
|
"""
|
|
self._trusted = frozenset(trusted)
|
|
self._vouched: set[str] = set()
|
|
self._refused: set[str] = set()
|
|
if not self._trusted:
|
|
logger.warning(
|
|
"%s is unset: every group this account is added to will be watched, "
|
|
"whoever added it",
|
|
TRUSTED_INVITERS_KEY,
|
|
)
|
|
|
|
def note_update(self, group_id: str, source_number: str, source_uuid: str) -> None:
|
|
"""Record a membership change, vouching for the group when it is trusted.
|
|
|
|
Args:
|
|
group_id: The group id as the envelope spelled it.
|
|
source_number: The +E.164 number that made the change, if known.
|
|
source_uuid: The uuid that made the change.
|
|
"""
|
|
if source_number not in self._trusted and source_uuid not in self._trusted:
|
|
return
|
|
canonical = canonical_group_id(group_id)
|
|
if canonical not in self._vouched:
|
|
self._vouched.add(canonical)
|
|
self._refused.discard(canonical)
|
|
logger.info("a trusted inviter added the bot to %s", canonical)
|
|
|
|
def allows(self, group_id: str, entry: dict[str, Any]) -> bool:
|
|
"""True when this group may be watched.
|
|
|
|
Args:
|
|
group_id: The canonical group id.
|
|
entry: The group's row from ``/v1/groups``.
|
|
|
|
Returns:
|
|
True when the gate is off, the group was vouched for by an
|
|
update envelope, or a trusted identity is a full member.
|
|
"""
|
|
if not self._trusted or group_id in self._vouched:
|
|
return True
|
|
members = identities(entry, "members") - identities(entry, "pending_invites")
|
|
if self._trusted & members:
|
|
return True
|
|
if group_id not in self._refused:
|
|
self._refused.add(group_id)
|
|
logger.warning(
|
|
"refusing group %r (%s): no trusted inviter. Its members are %s; "
|
|
"add one of them to %s if this group is yours.",
|
|
entry.get("name"),
|
|
group_id,
|
|
sorted(members),
|
|
TRUSTED_INVITERS_KEY,
|
|
)
|
|
return False
|