mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:17:02 +01:00
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:
parent
5d76adf4db
commit
bb72f0ae93
12
fake_signal/scenarios/per_group_isolation.yaml
Normal file
12
fake_signal/scenarios/per_group_isolation.yaml
Normal 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
128
scripts/prime_profile.sh
Executable 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
155
signal_bot/groups.py
Normal 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
137
signal_bot/membership.py
Normal 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
155
tests/test_membership.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user