mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 14:07:51 +01:00
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
171 lines
6.2 KiB
Python
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)
|