signal-bot/signal_bot/membership.py
Krzysztof kuhy Rudnicki 72482629d9 fix: a signal-api that is not up yet no longer kills the bot at boot
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 15:23:42 +02:00

171 lines
6.2 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Invited is not joined.
Signal only adds an account to a group outright when whoever adds it already
holds its profile key; otherwise the add becomes a pending invitation. A
non-member looks perfectly healthy from outside -- the group is listed, the
websocket is up, /healthz answers -- and not one message ever arrives. This
module is the part that tells the two states apart and tries to fix the
second one.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any, Final
from urllib.parse import quote
import httpx
from signal_bot.groups import group_id_aliases
if TYPE_CHECKING:
from collections.abc import Callable
from signal_bot.config import Config
logger = logging.getLogger(__name__)
MEMBER_KEY: Final = "member"
class MembershipError(RuntimeError):
"""Raised when the account is not a full member of a required group."""
def entry_matches(entry: dict[str, Any], group_id: str) -> bool:
"""True when a /v1/groups row is the group with this id, in any spelling.
Args:
entry: One row of the group listing.
group_id: The canonical id being looked for.
Returns:
True when the row describes that group.
"""
return bool({entry.get("id"), entry.get("internal_id")} & group_id_aliases(group_id))
def find_entry(entries: list[dict[str, Any]], group_id: str) -> dict[str, Any] | None:
"""Return the listing row for one group, or None when it is absent."""
return next((entry for entry in entries if entry_matches(entry, group_id)), None)
class JoinAttempts:
"""Remembers which invitations could not be accepted.
signal-cli cannot accept an invite addressed to a phone number at all
("Cannot find service ID for self"). Without this, the refresh loop would
retry that same impossible join every 30 seconds forever, logging a
warning each time and burying anything worth reading.
"""
def __init__(self) -> None:
"""Start with nothing known to have failed."""
self._failed: dict[str, str] = {}
def has_failed(self, group_id: str) -> bool:
"""True when joining this group has already been tried and refused."""
return group_id in self._failed
def reason(self, group_id: str) -> str:
"""Return why the last join attempt for this group failed."""
return self._failed.get(group_id, "not attempted")
def record_failure(self, group_id: str, detail: str) -> None:
"""Remember that this group could not be joined, and why."""
self._failed[group_id] = detail
def forget(self, group_id: str) -> None:
"""Drop the memory of a failure, once the group is joined."""
self._failed.pop(group_id, None)
async def attempt_join(client: httpx.AsyncClient, config: Config, group_id: str) -> str:
"""Try to accept a pending invitation, reporting what the API said.
This never raises. The join endpoint is a best-effort self-heal, and the
authority on whether it worked is the caller's membership re-check, not
this response -- returning the outcome as text only keeps it available
for the error message when the re-check still says non-member.
Args:
client: HTTP client for the REST API.
config: Resolved runtime configuration.
group_id: The canonical group id to join.
Returns:
A short description of the attempt.
"""
# Percent-encoded: the internal spelling contains '/' and '+', which do
# not survive being a path segment.
quoted = quote(group_id, safe="")
url = f"{config.api_base_url}/v1/groups/{config.phone_number}/{quoted}/join"
try:
response = await client.post(url)
except httpx.HTTPError as exc:
return f"join request failed: {exc}"
return f"join returned HTTP {response.status_code}: {response.text.strip()[:200]}"
async def join_pending(
client: httpx.AsyncClient,
config: Config,
entries: list[dict[str, Any]],
joins: JoinAttempts,
permitted: Callable[[dict[str, Any]], str | None],
) -> bool:
"""Try to accept every invitation worth retrying.
Args:
client: HTTP client for the REST API.
config: Resolved runtime configuration.
entries: The current group listing.
joins: Where a refused join is remembered, so it is tried once.
permitted: Decides whether a listing row is one the bot may watch,
returning its canonical id.
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 = permitted(entry)
if group_id is None or entry.get("member") or 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(client, config, group_id)
joins.record_failure(group_id, detail)
attempted = True
return attempted
def verify_allowlist(config: Config, groups: tuple[str, ...], joins: JoinAttempts) -> None:
"""Refuse to start when GROUP_IDS names a group the bot is not in.
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.
Args:
config: Resolved runtime configuration.
groups: The groups the bot actually discovered.
joins: Why a join failed, for the error message.
Raises:
MembershipError: A pinned group is missing or unjoinable.
"""
if not config.is_pinned:
logger.info("watching every group this account is a member of")
return
missing = [group_id for group_id in config.group_ids if group_id not in groups]
if missing:
details = ", ".join(f"{gid} ({joins.reason(gid)})" for gid in missing)
msg = (
f"GROUP_IDS pins the bot to {len(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)