mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:17:02 +01:00
Adds !papież / !papiez / !pope: a random pope as a photo, captioned with his Latin name, reign and pl.wikipedia link. popes.json already carried a Commons portrait filename for all 267 and nothing had ever consumed it. The Latin name comes from the Latin column of Poczet papieży, not from the la.wikipedia sitelink. The sitelink resolved for all 267 and was still wrong: la.wikipedia numbers several popes differently, so #154 came out as "Stefan IX — Stephanus X" in a single caption, which reads as an off-by-one. The table's own column says "Stephanus Nonus" and cannot disagree with the name beside it. LATIN_OVERRIDES ships empty; it is a tripwire, not a backlog. Portraits are cached on disk under CacheDirectory=signal-bot, named from the ordinal so no upstream string ever reaches a path, and bounded at 267 entries so nothing needs evicting. Verified that CacheDirectory wins against ProtectHome=read-only before relying on it. Also adds POST /_selftest and scripts/selftest.sh. Signal never delivers an account its own sends, so no command has ever been exercised end-to-end without asking a human to type it. This injects a real frame into the live dispatcher; it is loopback-only and refuses any group not in SELFTEST_GROUPS, which is empty by default. Verified against the real endpoints, not just the fake: 267/267 portraits resolve on Commons, regeneration is byte-identical on every previously shipped field, and !papież was driven 7 times through the live dispatcher in testbot, returning distinct popes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
110 lines
4.0 KiB
Python
110 lines
4.0 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Let the bot exercise its own commands without a second Signal account.
|
|
|
|
Signal never delivers an account its own sends, so the bot cannot type
|
|
`!papież` at itself, and every command shipped so far was verified either by
|
|
asking a human to type it or by reconstructing the object graph in a scratch
|
|
script. Both are weaker than they look: the scratch script is not the wiring
|
|
that runs in production, and a human typing it proves nothing about the path
|
|
between the websocket and the handler.
|
|
|
|
This injects a real envelope frame into the *live* dispatcher, so everything
|
|
downstream of the websocket read is the code that actually serves the group:
|
|
parsing, the scoping gate, the tally, trigger resolution, the cooldown, the
|
|
handler and the real transport. Only the socket read itself is skipped.
|
|
|
|
Fail-closed: SELFTEST_GROUPS names the groups this may post into, and is
|
|
empty by default. Without it the endpoint refuses, so a loopback caller
|
|
cannot make the bot post into the real group.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import time
|
|
from typing import TYPE_CHECKING, Any, Final
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Mapping
|
|
|
|
import httpx
|
|
|
|
from signal_bot.bot import Bot
|
|
from signal_bot.config import Config
|
|
|
|
GROUPS_ENV: Final = "SELFTEST_GROUPS"
|
|
|
|
|
|
class SelfTestError(RuntimeError):
|
|
"""Raised when a self-test cannot be run as asked."""
|
|
|
|
|
|
def allowed_groups(env: Mapping[str, str]) -> frozenset[str]:
|
|
"""Return the group names the self-test may post into.
|
|
|
|
Empty unless SELFTEST_GROUPS says otherwise. A bot that would replay
|
|
arbitrary text into the production group on a loopback POST is a worse
|
|
problem than the one this module solves.
|
|
"""
|
|
raw = env.get(GROUPS_ENV, "")
|
|
return frozenset(name.strip() for name in raw.split(",") if name.strip())
|
|
|
|
|
|
async def resolve_group(client: httpx.AsyncClient, config: Config, name: str) -> tuple[str, str]:
|
|
"""Return (internal group id, an author to attribute the message to).
|
|
|
|
The author must be somebody Signal can resolve, because the reply quotes
|
|
them: a made-up uuid comes back as "user ... is not registered" and the
|
|
send fails. Any real member other than the bot will do.
|
|
"""
|
|
response = await client.get(config.groups_url)
|
|
response.raise_for_status()
|
|
for entry in response.json():
|
|
if entry.get("name") != name:
|
|
continue
|
|
group_id = str(entry.get("internal_id") or entry.get("id") or "")
|
|
members = [m for m in entry.get("members", []) if m != config.phone_number]
|
|
if not group_id or not members:
|
|
msg = f"group {name!r} has no usable id or no other member"
|
|
raise SelfTestError(msg)
|
|
return group_id, str(members[0])
|
|
msg = f"no group named {name!r}"
|
|
raise SelfTestError(msg)
|
|
|
|
|
|
def build_frame(group_id: str, author: str, text: str) -> str:
|
|
"""Build the websocket frame signal-cli would emit for this message."""
|
|
stamp = int(time.time() * 1000)
|
|
payload: dict[str, Any] = {
|
|
"timestamp": stamp,
|
|
"message": text,
|
|
"groupInfo": {"groupId": group_id, "type": "DELIVER"},
|
|
}
|
|
envelope: dict[str, Any] = {
|
|
"source": author,
|
|
"sourceNumber": author,
|
|
"sourceUuid": author,
|
|
"sourceName": "self-test",
|
|
"sourceDevice": 1,
|
|
"timestamp": stamp,
|
|
"dataMessage": payload,
|
|
}
|
|
return json.dumps({"envelope": envelope, "account": author})
|
|
|
|
|
|
async def run(
|
|
bot: Bot,
|
|
client: httpx.AsyncClient,
|
|
group: str,
|
|
text: str,
|
|
env: Mapping[str, str] | None = None,
|
|
) -> str:
|
|
"""Feed one synthetic message through the live dispatcher."""
|
|
if group not in allowed_groups(os.environ if env is None else env):
|
|
msg = f"{group!r} is not in {GROUPS_ENV}; refusing to post there"
|
|
raise SelfTestError(msg)
|
|
group_id, author = await resolve_group(client, bot.config, group)
|
|
await bot.dispatcher.handle(build_frame(group_id, author, text))
|
|
return group_id
|