diff --git a/fake_signal/scenarios/per_group_isolation.yaml b/fake_signal/scenarios/per_group_isolation.yaml new file mode 100644 index 0000000..ac2658a --- /dev/null +++ b/fake_signal/scenarios/per_group_isolation.yaml @@ -0,0 +1,12 @@ +# The whole point of watching more than one group: a command is answered in +# the group that asked and nowhere else, and one group's rate limiting does +# not reach into another. Gina asks in both groups inside one cooldown +# window; a shared cooldown would turn the second image into "BEEP BOOP", +# and a fixed reply target would post both into the same group. +name: "a command is answered only in the group that asked" +inject: + - { text: "!cat", group: watched, uuid: uuid-gina, name: Gina } + - { text: "!cat", group: second, uuid: uuid-gina, name: Gina } +expect: + - { kind: image, to: reply } + - { kind: image, to: second_reply } diff --git a/scripts/prime_profile.sh b/scripts/prime_profile.sh new file mode 100755 index 0000000..318185b --- /dev/null +++ b/scripts/prime_profile.sh @@ -0,0 +1,128 @@ +#!/bin/bash + +# ============================================================================ +# Hand the bot's profile key to somebody who is about to add it to a group. +# +# Signal only adds an account to a group outright when whoever adds it +# already holds that account's profile key. Otherwise the add silently +# becomes a pending invitation -- and signal-cli cannot accept an invite +# addressed to a phone number, so the bot sits in the group forever, +# looking healthy and receiving nothing. +# +# Sending one direct message from the bot to that person hands their client +# the profile key, so the add that follows lands as a real join. Run this +# BEFORE asking anyone to add the bot to an existing group. +# +# Usage: scripts/prime_profile.sh +48123456789 [+48987654321 ...] +# ============================================================================ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +REPO_ROOT="$(dirname "$SCRIPT_DIR")" +readonly REPO_ROOT +SCRIPT_NAME="$(basename "$0")" +readonly SCRIPT_NAME + +readonly PORT="${PORT:-9922}" +readonly API="${API:-http://localhost:${PORT}}" +readonly BOT_NAME="${BOT_NAME:-signal-bot}" +readonly MESSAGE="${MESSAGE:-Hi, I am ${BOT_NAME}. Adding me to a group now works: this message gave your phone my profile, so the add becomes a real join instead of an invite I cannot accept.}" + +RECIPIENTS=() + +# shellcheck source-path=SCRIPTDIR source=scripts/lib_signal.sh +. "${SCRIPT_DIR}/lib_signal.sh" + +usage() { + echo "Usage: ${SCRIPT_NAME} + [+ ...]" + echo "Sends one DM from the bot so the recipient's client learns its" + echo "profile key, which makes a later group add land as a real join." + exit 0 +} + +validate_requirements() { + command -v curl >/dev/null || { echo "Error: curl is required" >&2; exit 1; } + command -v jq >/dev/null || { echo "Error: jq is required" >&2; exit 1; } + if [[ ${#RECIPIENTS[@]} -eq 0 ]]; then + echo "Error: at least one recipient number is required" >&2 + usage + fi + local number + for number in "${RECIPIENTS[@]}"; do + if [[ ! "$number" =~ ^\+[0-9]{6,15}$ ]]; then + echo "Error: '${number}' is not an E.164 number like +48123456789" >&2 + exit 1 + fi + done + if [[ ! -f "${REPO_ROOT}/.env" ]]; then + echo "Error: no ${REPO_ROOT}/.env -- run scripts/register_bot.sh first" >&2 + exit 1 + fi +} + +# Read the bot's own number out of .env rather than asking for it: a number +# typed twice is a number that can be typed differently the second time. +bot_number() { + local number + number="$(grep -E '^PHONE_NUMBER=' "${REPO_ROOT}/.env" | head -1 | cut -d= -f2-)" + if [[ -z "$number" ]]; then + echo "Error: PHONE_NUMBER is not set in ${REPO_ROOT}/.env" >&2 + return 1 + fi + printf '%s' "$number" +} + +send_dm() { + local from="$1" to="$2" status + status="$(curl -sS -o /dev/null -w '%{http_code}' -X POST "${API}/v2/send" \ + -H 'Content-Type: application/json' \ + -d "$(jq -n --arg msg "$MESSAGE" --arg from "$from" --arg to "$to" \ + '{message: $msg, number: $from, recipients: [$to]}')")" + if [[ "$status" != "2"* ]]; then + echo "Error: could not message ${to} (HTTP ${status})." >&2 + return 1 + fi + echo "== ${to} now has the bot's profile key" +} + +main() { + validate_requirements + + local number + number="$(bot_number)" + signal_wait_for_url "${API}/v1/about" "signal-cli-rest-api" 25 + signal_verify_mode + # The profile is re-asserted rather than assumed: an account with no + # profile hands over nothing, so the DM would leave the recipient in + # exactly the state this script exists to prevent. + signal_set_profile "$number" "$BOT_NAME" + + local recipient + for recipient in "${RECIPIENTS[@]}"; do + send_dm "$number" "$recipient" + done + + echo "== done. Ask them to add ${number} to the group now." + echo " Then add the group id to GROUP_IDS in .env and restart:" + echo " systemctl --user restart signal-bot.service" +} + +while [[ $# -gt 0 ]]; do + case $1 in + -h | --help) + usage + ;; + -*) + echo "Unknown option: $1" >&2 + exit 1 + ;; + *) + RECIPIENTS+=("$1") + shift + ;; + esac +done + +main "$@" diff --git a/signal_bot/groups.py b/signal_bot/groups.py new file mode 100644 index 0000000..1ea6c51 --- /dev/null +++ b/signal_bot/groups.py @@ -0,0 +1,155 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""Group ids: the three spellings, and the one that wins. + +Split out of config.py because it is the whole reason the bot can watch more +than one group without answering in the wrong one. The REST API reports a +group as ``group.``, an envelope carries what that decodes to, and +only the first form can be sent to -- so every id is normalised here, once, +before anything downstream compares or addresses one. +""" + +from __future__ import annotations + +import base64 +import binascii +from typing import TYPE_CHECKING, Final + +if TYPE_CHECKING: + from collections.abc import Iterable + +GROUP_ID_PREFIX: Final = "group." + + +class ConfigError(RuntimeError): + """Raised when the environment does not describe a runnable bot.""" + + +def group_id_aliases(group_id: str) -> frozenset[str]: + """Every spelling of one group id that can arrive in an envelope. + + The REST API reports group ids two different ways and never says so: + ``/v1/groups`` returns ``group.`` (what the setup script writes + into .env), while an incoming envelope's ``groupInfo.groupId`` carries + the *internal* id -- which is exactly what that base64 decodes to. The + two never compare equal, so a bot configured from the API's own output + silently ignores every message in its own group. Verified live on + 2026-08-29: ``group.WTI5dVptbG...`` decodes to ``Y29uZmlnLWdy/...``, the id + the same endpoint reports as ``internal_id``. + + Args: + group_id: The configured group id, in either spelling. + + Returns: + The configured id plus its decoded internal form, when it has one. + """ + aliases = {group_id} + if group_id.startswith(GROUP_ID_PREFIX): + try: + decoded = base64.b64decode(group_id.removeprefix(GROUP_ID_PREFIX), validate=True) + except binascii.Error, ValueError: + return frozenset(aliases) + aliases.add(decoded.decode("utf-8", errors="replace")) + else: + aliases.add(canonical_group_id(group_id)) + return frozenset(aliases) + + +def canonical_group_id(group_id: str) -> str: + """Return the ``group.`` spelling of a group id. + + One spelling has to win, because the same string is used for three + different jobs: matching an incoming envelope, addressing a reply, and + keying per-group state. The prefixed form wins because it is the only + one ``/v2/send`` and the group REST paths accept -- the internal form + contains ``/`` and ``+``. The internal id is itself base64 text, so the + conversion is just the inverse of what group_id_aliases decodes. + + Args: + group_id: A group id in either spelling. + + Returns: + The id in ``group.`` form, unchanged if it already is one. + """ + if group_id.startswith(GROUP_ID_PREFIX): + return group_id + return GROUP_ID_PREFIX + base64.b64encode(group_id.encode("utf-8")).decode("ascii") + + +def build_alias_map(group_ids: Iterable[str]) -> dict[str, str]: + """Map every spelling of every configured group to its canonical id. + + Args: + group_ids: Configured group ids, already canonical. + + Returns: + A lookup from any spelling an envelope might carry to the one + canonical id that scopes state and addresses replies. + """ + mapping: dict[str, str] = {} + for group_id in group_ids: + for alias in group_id_aliases(group_id): + mapping[alias] = group_id + return mapping + + +def parse_group_ids(env: dict[str, str]) -> tuple[str, ...]: + """Return the watched groups, canonical and de-duplicated. + + ``GROUP_IDS`` is the list form; ``GROUP_ID`` remains accepted as a + one-element spelling so an existing .env keeps working. Setting both is + an error rather than a precedence rule, because a precedence rule means + silently ignoring one of two values the operator clearly meant. + + Args: + env: The environment mapping to read. + + Returns: + Canonical group ids, in the order they were listed. + + Raises: + ConfigError: Neither key is set, both are set, or a removed key is. + """ + if env.get("GROUP_ID_SEND", "").strip(): + msg = ( + "GROUP_ID_SEND has been removed. Replies and the daily report now " + "go back to the group the message came from, which a fixed send " + "target cannot express once the bot watches more than one group. " + "Delete the line from .env." + ) + raise ConfigError(msg) + + listed = env.get("GROUP_IDS", "").strip() + single = env.get("GROUP_ID", "").strip() + if listed and single: + msg = "set GROUP_IDS or GROUP_ID, not both; GROUP_ID is the one-group spelling" + raise ConfigError(msg) + + raw = listed or single + if not raw: + msg = ( + "GROUP_IDS is required and must not be empty. A linked Signal " + "device sees every chat on the account, so the bot refuses to " + "start without an explicit list of groups to scope itself to. " + "Find them with: curl -s $API_BASE_URL/v1/groups/$PHONE_NUMBER" + ) + raise ConfigError(msg) + + group_ids: list[str] = [] + seen: dict[str, str] = {} + for part in raw.split(","): + candidate = part.strip() + if not candidate: + continue + canonical = canonical_group_id(candidate) + aliases = group_id_aliases(canonical) + clash = next((seen[alias] for alias in aliases if alias in seen), None) + if clash is not None: + msg = f"group {candidate!r} is listed twice, once as {clash!r}" + raise ConfigError(msg) + seen.update(dict.fromkeys(aliases, canonical)) + group_ids.append(canonical) + + if not group_ids: + msg = f"GROUP_IDS lists no group ids, only separators: {raw!r}" + raise ConfigError(msg) + return tuple(group_ids) diff --git a/signal_bot/membership.py b/signal_bot/membership.py new file mode 100644 index 0000000..763dba9 --- /dev/null +++ b/signal_bot/membership.py @@ -0,0 +1,137 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""Is the account actually *in* the groups it is configured to watch? + +Split out of transport_rest.py because it answers a different question from +"how do I send a message": a pending invitation is indistinguishable from +membership from the outside, and the whole point of this module is that the +difference is checked before the bot is allowed to start. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any +from urllib.parse import quote + +import httpx + +from signal_bot.groups import group_id_aliases + +if TYPE_CHECKING: + from signal_bot.config import Config + +logger = logging.getLogger(__name__) + + +class MembershipError(RuntimeError): + """Raised when the account is not a full member of a watched group.""" + + +class MembershipVerifier: + """Checks each watched group, joining one it was only invited to.""" + + def __init__(self, config: Config, client: httpx.AsyncClient) -> None: + """Wire the verifier to its config and HTTP client.""" + self._config = config + self._client = client + + async def _fetch_groups(self) -> list[dict[str, Any]]: + """Return the groups the account knows about, as raw dicts.""" + response = await self._client.get(self._config.groups_url) + response.raise_for_status() + return [group for group in response.json() if isinstance(group, dict)] + + @staticmethod + def _find_group(groups: list[dict[str, Any]], group_id: str) -> dict[str, Any] | None: + """Return the entry for one configured group, in either spelling.""" + aliases = group_id_aliases(group_id) + for group in groups: + if {group.get("id"), group.get("internal_id")} & aliases: + return group + return None + + async def _attempt_join(self, 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 membership re-check by the + caller, not this response. Returning the outcome as text keeps it in + the error message when the re-check still says non-member. + + Args: + group_id: The canonical group id to join. + + Returns: + A short description of the attempt, for the error message. + """ + # The prefixed spelling, percent-encoded: the internal form contains + # '/' and '+', which do not survive being a path segment. + quoted = quote(group_id, safe="") + url = f"{self._config.api_base_url}/v1/groups/{self._config.phone_number}/{quoted}/join" + try: + response = await self._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 verify(self) -> None: + """Raise unless the account is a full member of every watched group. + + A pending invitation is indistinguishable from membership from the + outside: /v1/groups lists the group, the websocket connects, + /healthz answers, and not one message ever arrives. Signal only + adds an account to a group outright when the inviter already holds + its profile key; otherwise it creates an invite. Observed live on + 2026-08-29, where the bot looked perfectly healthy for hours while + being a non-member. Startup is the only honest place to catch it. + + Each configured group is checked on its own. One joined group + satisfying the check for another is exactly how a bot ends up + silently deaf in the group nobody was watching. + + Raises: + MembershipError: A watched group is missing, or the account is + not a full member of it and could not join. + """ + groups = await self._fetch_groups() + for group_id in self._config.group_ids: + await self._verify_one(groups, group_id) + + async def _verify_one(self, groups: list[dict[str, Any]], group_id: str) -> None: + """Check one configured group, joining it if it is only an invite. + + Args: + groups: The current /v1/groups listing. + group_id: The canonical group id to check. + + Raises: + MembershipError: The group is unknown, or still not joined after + the join attempt. + """ + state = self._find_group(groups, group_id) + if state is None: + msg = ( + f"the account is in no group matching {group_id!r}. Check GROUP_IDS " + "in .env against: curl -s $API_BASE_URL/v1/groups/$PHONE_NUMBER" + ) + raise MembershipError(msg) + if state.get("member"): + return + + name = state.get("name") + logger.warning("invited to %r but not a member; attempting to join", name) + detail = await self._attempt_join(group_id) + # The join endpoint's own response is not trusted: only the listing + # decides, so the gate cannot degrade into an optimistic warning. + rechecked = self._find_group(await self._fetch_groups(), group_id) + if rechecked is not None and rechecked.get("member"): + logger.info("joined %r on startup (%s)", name, detail) + return + + msg = ( + f"the account is invited to {name!r} ({group_id}) but has not joined " + f"it, so it will receive nothing there, and joining did not work: " + f"{detail}. Have an admin remove the pending invite and add the bot " + "again now that it has a profile, or have the bot create the group." + ) + raise MembershipError(msg) diff --git a/tests/test_membership.py b/tests/test_membership.py new file mode 100644 index 0000000..371ec2d --- /dev/null +++ b/tests/test_membership.py @@ -0,0 +1,155 @@ +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""The startup membership gate: invited is not joined, and each group counts.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import replace +from typing import TYPE_CHECKING +from urllib.parse import quote + +import httpx +import pytest + +from signal_bot.transport_rest import RestTransport, TransportError + +if TYPE_CHECKING: + from signal_bot.config import Config + +# The callable httpx.MockTransport expects -- not a bot command handler. +MockHandler = Callable[[httpx.Request], httpx.Response] + + +def _transport(config: Config, handler: MockHandler) -> RestTransport: + """A transport whose HTTP calls are answered by handler.""" + return RestTransport(config, httpx.AsyncClient(transport=httpx.MockTransport(handler))) + + +def _groups(*entries: object) -> MockHandler: + """Answer /v1/groups with an arbitrary payload.""" + return lambda r: httpx.Response(200, json=list(entries)) + + +def _member(group_id: str, *, member: bool = True, name: str = "testbot") -> dict[str, object]: + """One /v1/groups row for a configured group.""" + return {"id": group_id, "internal_id": group_id, "name": name, "member": member} + + +def _all_joined(config: Config) -> MockHandler: + """Answer /v1/groups with every configured group, fully joined.""" + return _groups(*(_member(group_id) for group_id in config.group_ids)) + + +async def test_membership_accepts_a_group_matched_by_its_internal_id( + config: Config, +) -> None: + """Envelopes carry internal_id, so that spelling must satisfy the gate.""" + handler = _groups( + {"id": "group.other", "internal_id": config.group_ids[0], "member": True}, + _member(config.group_ids[1]), + ) + assert await _transport(config, handler).verify_membership() is None + + +async def test_membership_checks_every_configured_group(config: Config) -> None: + """One joined group must not vouch for another: that is a silent deafness.""" + handler = _groups(_member(config.group_ids[0])) + with pytest.raises(TransportError, match="in no group matching"): + await _transport(config, handler).verify_membership() + + +async def test_membership_names_the_group_it_is_only_invited_to( + config: Config, +) -> None: + """The join self-heal fails too, so the gate must still refuse loudly.""" + joined, invited = config.group_ids + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST": + return httpx.Response(400, text="Cannot find service ID for self") + return httpx.Response( + 200, json=[_member(joined), _member(invited, member=False, name="prod")] + ) + + with pytest.raises(TransportError, match="invited to 'prod'") as raised: + await _transport(config, handler).verify_membership() + # The join attempt's own answer travels into the error, so the reason is + # in the journal rather than one debugging session away. + assert "Cannot find service ID" in str(raised.value) + assert invited in str(raised.value) + + +async def test_membership_joins_a_pending_invitation(config: Config) -> None: + """The self-heal: accept the invite at startup instead of demanding a re-add.""" + joined = config.group_ids[0] + # A real internal id, slashes and pluses included: unencoded, they would + # not match the join route at all, and the self-heal would never fire. + invited = "Y29uZmlnLWdyb3VwLWlkLTMy" + config = replace(config, group_ids=(joined, invited)) + posts: list[str] = [] + listings = iter( + [ + [_member(joined), _member(invited, member=False)], + [_member(joined), _member(invited)], + ] + ) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST": + posts.append(request.url.raw_path.decode()) + return httpx.Response(201, text="") + return httpx.Response(200, json=next(listings)) + + assert await _transport(config, handler).verify_membership() is None + # The id is percent-encoded: the internal spelling carries '/' and '+', + # which would otherwise not match the route at all. + expected = f"/v1/groups/{config.phone_number}/{quote(invited, safe='')}/join" + assert posts == [expected] + + +async def test_membership_survives_a_join_endpoint_that_is_not_there( + config: Config, +) -> None: + """A transport error on join must become the loud refusal, not a crash.""" + joined, invited = config.group_ids + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST": + msg = "connection refused" + raise httpx.ConnectError(msg) + return httpx.Response(200, json=[_member(joined), _member(invited, member=False)]) + + with pytest.raises(TransportError, match="join request failed"): + await _transport(config, handler).verify_membership() + + +async def test_membership_refuses_when_the_group_vanishes_after_joining( + config: Config, +) -> None: + """The re-check is the authority, so a group gone from the listing fails.""" + joined, invited = config.group_ids + listings = iter([[_member(joined), _member(invited, member=False)], [_member(joined)]]) + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "POST": + return httpx.Response(201, text="") + return httpx.Response(200, json=next(listings)) + + with pytest.raises(TransportError, match="has not joined"): + await _transport(config, handler).verify_membership() + + +async def test_membership_rejects_an_account_in_no_matching_group( + config: Config, +) -> None: + handler = _groups({"id": "group.someone-elses", "member": True}) + with pytest.raises(TransportError, match="in no group matching"): + await _transport(config, handler).verify_membership() + + +async def test_membership_ignores_entries_that_are_not_objects( + config: Config, +) -> None: + """A malformed row must not mask the real reason the bot is deaf.""" + handler = _groups("unexpected", *(_member(g) for g in config.group_ids)) + assert await _transport(config, handler).verify_membership() is None