fix: commit the five modules the last commit only referenced

The wrap-up script stages dirty *tracked* files, so every newly created
file in the multi-group change -- the group-id module, the membership
verifier, their tests, the isolation scenario and prime_profile.sh --
was listed as untracked and then left behind. The tree compiled locally
because the files exist on disk; CI checked out a repo where config.py
imported signal_bot.groups and nothing provided it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yaBT45KhHqxTKVCTA87iy
This commit is contained in:
Krzysztof Rudnicki 2026-08-29 15:05:18 +02:00
parent 5d76adf4db
commit bb72f0ae93
5 changed files with 587 additions and 0 deletions

View File

@ -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 }

128
scripts/prime_profile.sh Executable file
View File

@ -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} +<E164 number> [+<E164 number> ...]"
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 "$@"

155
signal_bot/groups.py Normal file
View File

@ -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.<base64>``, 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.<base64>`` (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.<base64>`` 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.<base64>`` 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)

137
signal_bot/membership.py Normal file
View File

@ -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)

155
tests/test_membership.py Normal file
View File

@ -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