signal-bot/signal_bot/discovery.py
2026-08-29 15:30:08 +02:00

239 lines
9.3 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Which groups the bot is in, decided at runtime rather than in .env.
The bot is its own registered Signal account, so the groups it can see are
exactly the groups somebody put it in. Making the operator also write those
ids into .env and restart added a second step to "add the bot" that could
only ever be forgotten. This module keeps the watched set in sync with the
account itself.
The one rule that matters: a *failed* refresh never narrows the set. If the
listing cannot be fetched, or comes back malformed, the previous set stands.
Rebuilding from a partial answer would make the bot conclude it had been
removed from everything -- deaf, and with every group's counts purged.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Any, Final
import httpx
from signal_bot.groups import build_alias_map, canonical_group_id
from signal_bot.membership import JoinAttempts, MembershipError, attempt_join
if TYPE_CHECKING:
from signal_bot.config import Config
from signal_bot.store import Store
logger = logging.getLogger(__name__)
REFRESH_SECONDS: Final = 30.0
Sleeper = Callable[[float], Awaitable[None]]
class GroupDirectory:
"""The live set of groups the bot answers in."""
def __init__(
self,
config: Config,
client: httpx.AsyncClient,
store: Store,
*,
sleeper: Sleeper | None = None,
) -> None:
"""Wire the directory to its config, HTTP client and store.
Args:
config: Resolved runtime configuration.
client: HTTP client for the REST API.
store: Where per-group state lives, so a group the bot is
removed from can have its rows dropped.
sleeper: Async sleep used by run(). 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._aliases: dict[str, str] = {}
self._groups: tuple[str, ...] = ()
self._joins = JoinAttempts()
# Group ids seen in an envelope that were not in the listing yet. One
# refresh per *id*, not per unit of time: a global cooldown would
# 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()
@property
def groups(self) -> tuple[str, ...]:
"""Every group currently watched, canonical and in listing order."""
return self._groups
def resolve(self, group_id: str | None) -> str | None:
"""Return the canonical id of a watched group, or None.
This is the single place an envelope's group id becomes a group the
bot acts on: the scoping gate, the reply address and the per-group
state key all come from this one answer.
Args:
group_id: The id as it arrived, or None for a direct message.
Returns:
The canonical id, or None for a direct message or a group the
bot does not watch.
"""
if group_id is None:
return None
return self._aliases.get(group_id)
async def refresh(self) -> None:
"""Rebuild the watched set from the account's own group listing.
Joins anything the bot was merely invited to, then re-reads the
listing so membership is decided by the API rather than by the join
endpoint's own optimistic answer.
"""
entries = await self._fetch()
if entries is None:
return
if await self._join_pending(entries):
entries = await self._fetch()
if entries is None:
return
self._adopt(entries)
async def refresh_for(self, group_id: str) -> str | None:
"""Refresh once because a message arrived from an unknown group.
Args:
group_id: The unrecognised id from the envelope.
Returns:
The canonical id if the refresh adopted that group, else None.
"""
if group_id in self._refreshed_for:
return None
self._refreshed_for.add(group_id)
logger.info("message from unknown group %r; refreshing the listing", group_id)
await self.refresh()
return self.resolve(group_id)
async def verify_allowlist(self) -> None:
"""Refuse to start when GROUP_IDS names a group the bot cannot use.
Only applies when the operator pinned the bot to specific groups. An
unpinned bot watches whatever it has been added to, including
nothing at all, and that is not an error -- it is a bot waiting to
be added.
Raises:
MembershipError: A pinned group is missing or unjoinable.
"""
if not self._config.is_pinned:
logger.info("watching every group this account is a member of")
return
missing = [group_id for group_id in self._config.group_ids if group_id not in self._groups]
if missing:
details = ", ".join(f"{gid} ({self._joins.reason(gid)})" for gid in missing)
msg = (
f"GROUP_IDS pins the bot to {len(self._config.group_ids)} group(s) but it is "
f"not a joined member of: {details}. Have an admin add the bot again now that "
"it has a profile, or drop the id from GROUP_IDS."
)
raise MembershipError(msg)
async def run(self, iterations: int | None = None) -> None:
"""Refresh forever, so a group added later needs no restart.
Args:
iterations: Stop after this many refreshes. None means forever.
"""
completed = 0
while iterations is None or completed < iterations:
await self._sleep(REFRESH_SECONDS)
await self.refresh()
completed += 1
async def _fetch(self) -> list[dict[str, Any]] | None:
"""Return the group listing, or None when it could not be read.
None is the "keep what we have" signal. Every failure mode of this
call -- timeout, 500, a body that is not a list -- must leave the
watched set alone rather than emptying it.
"""
try:
response = await self._client.get(self._config.groups_url)
response.raise_for_status()
body = response.json()
except (httpx.HTTPError, ValueError) as exc:
logger.warning(
"group listing unavailable, keeping the %d group(s) already known: %s",
len(self._groups),
exc,
)
return None
if not isinstance(body, list):
logger.warning("group listing was %s, not a list; keeping what we have", type(body))
return None
return [entry for entry in body if isinstance(entry, dict)]
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")
if not isinstance(raw, str) or not raw:
return None
group_id = canonical_group_id(raw)
return group_id if self._config.allows(group_id) else None
async def _join_pending(self, entries: list[dict[str, Any]]) -> bool:
"""Try to accept every invitation worth retrying.
Args:
entries: The current group listing.
Returns:
True when at least one join was attempted, so the caller knows
the listing needs re-reading.
"""
attempted = False
for entry in entries:
group_id = self._permitted(entry)
if group_id is None or entry.get("member") or self._joins.has_failed(group_id):
continue
logger.warning("invited to %r but not a member; trying to join", entry.get("name"))
detail = await attempt_join(self._client, self._config, group_id)
self._joins.record_failure(group_id, detail)
attempted = True
return attempted
def _adopt(self, entries: list[dict[str, Any]]) -> None:
"""Replace the watched set from a listing known to be good."""
groups = tuple(
group_id
for entry in entries
if entry.get("member") and (group_id := self._permitted(entry)) is not None
)
if groups == self._groups:
return
added = [group_id for group_id in groups if group_id not in self._groups]
removed = [group_id for group_id in self._groups if group_id not in groups]
# Built whole and assigned in one statement: the dispatcher reads
# this map between awaits, and must never see it half-built.
self._aliases = build_alias_map(groups)
self._groups = groups
for group_id in added:
self._joins.forget(group_id)
self._refreshed_for.discard(group_id)
logger.info("now watching %s", group_id)
for group_id in removed:
# Dropped, not kept: a re-add should start a fresh tally rather
# than resurrect counts from whenever the bot was last in there.
self._store.forget_group(group_id)
logger.info("no longer a member of %s; its counts were dropped", group_id)