diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e0906a..fb76331 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,12 @@ jobs: exit 1 fi + # The name half of this check needs the operator's private data and so + # can only run locally; the shape half -- phone numbers, uuids, Signal + # group ids -- works anywhere and is what CI enforces on every push. + - name: no personal data + run: ./scripts/check_personal_data.sh --all + - name: ruff format run: .venv/bin/ruff format --check . diff --git a/.personal-data-allowlist b/.personal-data-allowlist new file mode 100644 index 0000000..3d570dc --- /dev/null +++ b/.personal-data-allowlist @@ -0,0 +1,87 @@ +# Values this public repository is allowed to contain. +# +# Everything a real person could be identified by is refused by +# scripts/check_personal_data.sh, which learns the names from OUTSIDE this +# repository (the photo pack, the message database, .env) so that the list of +# real names is never in it. What is left is shapes -- phone numbers, uuids, +# Signal group ids -- and those need an explicit list of the invented ones the +# documents and fixtures are built from. That list is this file. +# +# One value per line, lower-cased. Add a value here only after checking it +# belongs to nobody. + +# Phone numbers. The 555 range is reserved for fiction; the two below are +# structurally valid and deliberately not anybody's. ++15550000000 ++48123456789 ++420111222333 ++48000000000 ++48111222333 + +# UUIDs used as fixtures. +b1c2d3e4-0000-4a00-8a00-000000000001 +b1c2d3e4-0000-4a00-8a00-000000000002 + +# Group ids invented for the offline end-to-end run and the unit tests. Each +# one base64-decodes to a sentence saying it is a fixture. +group.wlrkbexyzghkr05vzldrdc1ncm91cc1pzc0zmi1ienl0zxmhisfd +group.zdjgmfkyagxaqzfuy205mwndmxbaqzb6twkxiwvyumxjeuvositfaelrpt0= +group.wtjllxdhdgnozwqtz3jvdxatawqtmzitynl0zxmhisfd + +# The author's own handle, which is the repository owner and appears in the +# project URL, the User-Agent and every copyright line. +kuhy +# The sender name POST /_selftest injects. Not a person. +self-test + +# Given names that are also popes. Permitted ONLY in the files that carry the +# papal list, so the same name in a test fixture or a document is still +# refused -- which is exactly how it leaked the first time. +signal_bot/popes.json::marek +signal_bot/popes.json::wiktor +tests/test_versioning.py::wiktor +DOCS-changelog.md::wiktor + +# Invented Signal group ids. Every one of these base64-decodes twice to a +# sentence saying what it is, which is how you can tell it belongs to nobody: +# group.WlRKbExYZGhkR05vWldRdFozSnZkWEF0YVdRdE16SXRZbmwwWlhNaElTRT0= +# -> ZTJlLXdhdGNoZWQtZ3JvdXAtaWQtMzItYnl0ZXMhISE= -> e2e-watched-group-id-32-bytes!!! +group.WTI5dVptbG5MV2R5YjNWd0xXbGtMVE15TFdKNWRHVnpJU0VoSVNFaElRPT0= +group.WlRKbExYZGhkR05vWldRdFozSnZkWEF0YVdRdE16SXRZbmwwWlhNaElTRT0= +group.WlRKbExYTmxZMjl1WkMxbmNtOTFjQzFwWkMwek1pMWllWFJsY3lFaElTRT0= +group.WlRKbExYUm9hWEprTFdkeWIzVndMV2xrTFRNeUxXSjVkR1Z6SVNFaElRPT0= +group.WlRKbExXWnZkWEowYUMxbmNtOTFjQzFwWkMwek1pMWllWFJsY3lFaElTRT0= +group.WTI1c1pDMXZibVV0WjNKdmRYQXRhV1F0TXpJdFlubDBaWE1oSVNFaElTRT0= +group.WTI5c1pDMXZibVV0WjNKdmRYQXRhV1F0TXpJdFlubDBaWE1oSVNFaElTRT0= +group.WTI5c1pDMTBkMjh0WjNKdmRYQXRhV1F0TXpJdFlubDBaWE1oSVNFaElTRT0= +group.WTI5c1pDMTBhSEpsWlMxbmNtOTFjQzFwWkMwek1pMWllWFJsY3lFaElTRT0= +group.WTI5c1pDMW1iM1Z5TFdkeWIzVndMV2xrTFRNeUxXSjVkR1Z6SVNFaElTRT0= +group.YjNSb1pYSXRaM0p2ZFhBdGFXUXRNekl0WW5sMFpYTWhJU0VoSVNFPQ== +group.YzJWamIyNWtMV2R5YjNWd0xXbGtMVE15TFdKNWRHVnpJU0VoSVNFaA== +group.ZDJGMFkyaGxaQzFuY205MWNDMXBaQzB6TWkxaWVYUmxjeUVoSVNFaElRPT0= +group.WlRKbExYZGhkR05vWldRdA== + +# Invented phone numbers used as fixtures. Reserved / non-allocated ranges. ++15550000001 ++15550000002 ++15550001111 ++15550001234 ++15555550123 ++15559999999 ++48987654321 +signal_bot/versioning.py::wiktor + +# Given names that are also popes, permitted only in the files that carry or +# assert the papal list. Anywhere else -- a test fixture, a document, a help +# text -- they are still refused, which is the case that leaked. +signal_bot/popes.json::jan +signal_bot/popes.json::marek +signal_bot/popes.json::wiktor +signal_bot/versioning.py::jan +tool/gen_popes.py::jan +tool/gen_popes.py::marek +tool/gen_popes.py::wiktor +tests/test_versioning.py::jan +tests/test_changelog.py::jan +tests/test_popes_command.py::jan +DOCS-changelog.md::jan diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ae547ee..3dc6a67 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -49,6 +49,27 @@ repos: # database is gitignored, but three binary SQLite files reached the # history anyway before this gate existed, so .gitignore is evidently # not the whole defence. Exceptions go in .binary-allowlist. + # This repository is public and leaked once: real names, two phone + # numbers and a member's Signal uuid reached GitHub and only came off + # it by deleting the repository. The checker learns what to refuse from + # OUTSIDE the repo -- the photo pack, the message database, .env -- so + # the list of real names is never in the thing being checked. + - id: personal-data + name: no personal data + entry: scripts/check_personal_data.sh + language: system + types: [text] + + # The same check on the commit message, because that is where two of + # the leaks actually lived. + - id: personal-data-message + name: no personal data in the commit message + entry: scripts/check_personal_data.sh --message + language: system + stages: [commit-msg] + always_run: true + pass_filenames: true + - id: no-binaries name: no binary or image files entry: scripts/check_no_binaries.sh diff --git a/DOCS-privacy.md b/DOCS-privacy.md new file mode 100644 index 0000000..d611640 --- /dev/null +++ b/DOCS-privacy.md @@ -0,0 +1,83 @@ +# What is private, and what stops it leaking + +This repository is public. The bot it describes reads a private group chat, +posts from a private photo album, and keeps a database of who said how much. +None of that may be in here, and on 2026-08-30 some of it was. + +## What leaked, and how + +Real first names, one full name, two phone numbers and a group member's +Signal uuid were in the tree *and* in the public history. They arrived one +way every time: **somebody reached for a real name because it was the example +in front of them** — a test fixture built from a live database dump, an +envelope pasted verbatim out of a running container, a help text written +while looking at the actual group. + +Fixing it needed the tree scrubbed, all 56 commits rewritten with +`git filter-repo`, and then the repository **deleted and recreated**, because +GitHub keeps orphaned commits fetchable by their SHA after a force-push and +only the repository going away takes them with it. The closed pull requests +and the star went too. + +## What is private, and where it lives + +| | Where | Never in git because | +|---|---|---| +| the photos and who is in them | `~/.local/share/signal-bot/pack/` | it is outside the repo, and `.gitignore` also names `people.json`, `tags.json`, `media/`, `pack/` and every media extension | +| the message database | `signal_bot.sqlite3` | `*.sqlite3` and its `-wal`/`-shm` siblings are ignored | +| the account's number, groups, inviters | `.env` | `.env` is ignored; `.env.sample` carries the shape and no values | +| everyone's names | the database and the pack manifest | nothing reads them into the repo; the bot logs `pack:p1`, never a name | + +All of it is `700`/`600` on disk. The repository never needs any of it: the +tests, the documents and the offline end-to-end run use invented people — +alfa, beta, gamma, ala, basia, celina — and invented ids that base64-decode +twice into a sentence saying what they are. + +## The gate + +`scripts/check_personal_data.sh` refuses a commit that carries personal data. +It runs on staged files, on the commit message, and over the whole tree in +CI. Two halves, because they fail differently: + +- **Names cannot be listed here** — a file of real names kept in the repo to + check against *is* the leak. So the checker learns them at run time from + outside: the photo pack's manifest, the message database, and `.env`. It + never prints the word it matched, because this output reaches CI logs. +- **Shapes can be listed** — a phone number, a uuid and a Signal group id + look like themselves anywhere, so those are matched by pattern and compared + against `.personal-data-allowlist`, which holds only invented values. + +On a machine with none of that private data (CI, a fresh clone) the name half +has nothing to compare against and **says so** rather than passing quietly. + +It found a real leak the first time it was run: the live test group's id, in +two spellings, in `tests/test_config.py` — which four rounds of grepping by +hand had missed, because nobody thinks to grep for base64. + +## When it fires + +``` +tests/test_thing.py:12: a name from your private data +``` + +Use an invented name. `ala`, `basia`, `celina`, `darek` are already in use; +so are `+15550000000` and ids that decode to `e2e-watched-group-id-32-bytes!!!`. + +``` +DOCS-thing.md:40: phone number '+48123456789' is not in .personal-data-allowlist +``` + +Either it belongs to somebody — replace it — or it is invented, in which case +add it to `.personal-data-allowlist` **after checking that it is**. A line +there may be scoped to one file (`signal_bot/popes.json::hadrian`), which is how +a given name that is also a pope stays refused in every test fixture that has +no business carrying it. + +## What the gate cannot do + +- It does not scan history. Rewriting that is a deliberate, separate act. +- It knows only what is on *this* machine. A name nobody has ever typed into + the group is a name it cannot recognise. +- It does not stop the bot's own logs holding display names — + `journalctl --user -u signal-bot` does, and `--vacuum-time=` is how that is + emptied. diff --git a/DOCS-signal-envelopes.md b/DOCS-signal-envelopes.md index 41c1a41..3c9b402 100644 --- a/DOCS-signal-envelopes.md +++ b/DOCS-signal-envelopes.md @@ -128,7 +128,7 @@ who made the change. Captured live on 2026-08-29: "dataMessage": { "timestamp": 1788006851503, "message": null, - "groupInfo": { "groupId": "Y29uZmlnLWdy/…", "groupName": "testbot", "type": "UPDATE" } + "groupInfo": { "groupId": "Y29uZmlnLWdy…", "groupName": "testbot", "type": "UPDATE" } } }, "account": "+420111222333" diff --git a/README.md b/README.md index 28e06f3..c242b87 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # signal-bot A Signal group bot: `!cat` / `!dog` / `!gekon` image commands, `!papież`, and -group message statistics. +group message statistics. The group's own data stays out: `DOCS-privacy.md`. Runs on its **own registered Signal number** (or, if you prefer, as a linked device on an existing account) via diff --git a/scripts/check_personal_data.py b/scripts/check_personal_data.py new file mode 100755 index 0000000..b03c1cb --- /dev/null +++ b/scripts/check_personal_data.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""Refuse to commit anything that identifies a real person. + +This repository is public and has already leaked once: real first names, a +full name, two phone numbers and a group member's Signal uuid reached GitHub +and stayed there until the repository had to be deleted and recreated. Every +one of them arrived the same way -- somebody (a human once, this program's +author the second time) reached for a real name because it was the example +in front of them. + +So it is a gate rather than a rule. Two halves, because they fail differently: + +* **Names**, which cannot be listed here. The forbidden words are read at run + time from outside the repository -- the photo pack's manifest, the message + database, the local .env -- so the check knows them and the repository + never does. On a machine with none of those (CI, a fresh clone) this half + simply has nothing to compare against and says so. +* **Shapes**, which can. A phone number, a uuid and a Signal group id look + like themselves wherever they appear, so those are matched by pattern and + compared against the placeholders this repository is allowed to contain. + +Usage: + scripts/check_personal_data.sh --all # every tracked file + scripts/check_personal_data.sh a.py b.md # named files + +The shell wrapper is the entry point; it knows how to list what git tracks, +which keeps this half free of a subprocess call it would need a suppression +for. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path +from typing import Final + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from personal_data_sources import REPO, forbidden + +ALLOWLIST: Final = REPO / ".personal-data-allowlist" + +# Shapes that identify somebody wherever they turn up. +PATTERNS: Final[dict[str, re.Pattern[str]]] = { + "phone number": re.compile(r"\+\d{9,15}\b"), + "uuid": re.compile(r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b"), + "signal group id": re.compile(r"\bgroup\.[A-Za-z0-9+/]{16,}={0,2}"), +} + +SKIP_DIRS: Final = frozenset({".git", ".venv", "__pycache__", ".mypy_cache", ".ruff_cache"}) +SKIP_SUFFIXES: Final = frozenset({".lock", ".bundle"}) + + +def allowed(path: Path | None = None) -> set[str]: + """Literals this repository may contain, lower-cased. + + A plain line permits a value anywhere. A ``some/path::value`` line + permits it only in that file, which is how a name that is also a pope in + popes.json stays refused in every test fixture and document that has no + business carrying it. + """ + if not ALLOWLIST.exists(): + return set() + permitted: set[str] = set() + where = str(path) if path is not None else "" + # The allowlist has to be able to name the values it permits, including + # the file-scoped ones, so inside it every listed value counts as + # permitted. It is the list of exceptions; it cannot be its own exception. + inside = path is not None and path.resolve() == ALLOWLIST + for raw in ALLOWLIST.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + scope, separator, value = line.partition("::") + if not separator: + permitted.add(line.lower()) + elif inside or where.endswith(scope.strip()): + permitted.add(value.strip().lower()) + return permitted + + +def scan(text: str, words: set[str], path: Path | None = None) -> list[tuple[int, str]]: + """Return (line number, what matched) for every hit in one document.""" + permitted = allowed(path) + words = {word for word in words if word not in permitted} + hits: list[tuple[int, str]] = [] + for number, line in enumerate(text.splitlines(), start=1): + lowered = line.lower() + for word in words: + if re.search(rf"(? str | None: + """The file's text, or None when it is not text at all.""" + if path.suffix in SKIP_SUFFIXES or any(part in SKIP_DIRS for part in path.parts): + return None + try: + return path.read_text(encoding="utf-8") + except OSError, UnicodeDecodeError: + return None + + +def main() -> int: + """Check whatever was asked for, and explain any refusal.""" + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument("--files", nargs="*", type=Path, default=[]) + parser.add_argument("--message", type=Path, default=None, help="a commit message file") + arguments = parser.parse_args() + + words, sources = forbidden(allowed()) + targets = list(arguments.files) + if arguments.message is not None: + targets.append(arguments.message) + + failures = 0 + for path in targets: + text = readable(path) + if text is None: + continue + for number, reason in scan(text, words, path): + sys.stdout.write(f"{path}:{number}: {reason}\n") + failures += 1 + + if failures: + sys.stdout.write( + f"\nRefused: {failures} line(s) carry personal data.\n" + "This repository is public. Use an invented name, a placeholder number, or\n" + "add the value to .personal-data-allowlist if it is genuinely not anybody's.\n" + ) + return 1 + if not words: + # Said out loud rather than passing quietly: on CI and on a fresh + # clone the name half of this check has nothing to compare against, + # and a gate that silently checks nothing is worse than no gate. + sys.stdout.write("no local private data to compare against; shapes only\n") + else: + sys.stdout.write(f"clean ({len(words)} name(s) from: {', '.join(sources)})\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check_personal_data.sh b/scripts/check_personal_data.sh new file mode 100755 index 0000000..d3953f3 --- /dev/null +++ b/scripts/check_personal_data.sh @@ -0,0 +1,73 @@ +#!/bin/bash + +# ============================================================================ +# Refuse to commit anything that identifies a real person. +# +# The logic is check_personal_data.py; this half exists to list what git +# tracks, which keeps the Python free of a subprocess call it would otherwise +# need a lint suppression for. +# +# scripts/check_personal_data.sh --all every tracked file +# scripts/check_personal_data.sh a.py b.md named files +# scripts/check_personal_data.sh --message .git/COMMIT_EDITMSG +# ============================================================================ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +REPO_ROOT="$(dirname "$SCRIPT_DIR")" +readonly REPO_ROOT +readonly CHECKER="${SCRIPT_DIR}/check_personal_data.py" + +# The repo's own interpreter when there is one, so the check runs the same +# way under pre-commit, in CI and by hand. +PYTHON="${PYTHON:-${REPO_ROOT}/.venv/bin/python}" +if [[ ! -x "$PYTHON" ]]; then + PYTHON="$(command -v python3)" +fi +readonly PYTHON + +usage() { + echo "Usage: $(basename "$0") [--all | --message FILE | FILE...]" + exit 0 +} + +main() { + if [[ $# -eq 0 ]]; then + usage + fi + + case "$1" in + -h | --help) + usage + ;; + --message) + if [[ $# -lt 2 ]]; then + echo "Error: --message needs a file" >&2 + exit 1 + fi + "$PYTHON" "$CHECKER" --message "$2" + ;; + --all) + local files=() + mapfile -t files < <(git -C "$REPO_ROOT" ls-files) + if [[ ${#files[@]} -eq 0 ]]; then + echo "Error: git tracks no files here" >&2 + exit 1 + fi + # Prefixed with the repo root so this works from any directory. + local paths=() + local file + for file in "${files[@]}"; do + paths+=("${REPO_ROOT}/${file}") + done + "$PYTHON" "$CHECKER" --files "${paths[@]}" + ;; + *) + "$PYTHON" "$CHECKER" --files "$@" + ;; + esac +} + +main "$@" diff --git a/scripts/personal_data_sources.py b/scripts/personal_data_sources.py new file mode 100755 index 0000000..4fa36da --- /dev/null +++ b/scripts/personal_data_sources.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence. +"""Where the forbidden words come from -- all of it outside this repository. + +Split out of check_personal_data.py at the 250-line cap, and the seam is the +honest one: this half knows the private data, that half knows nothing but how +to search. A list of real names kept *in* the repository to check against +would be the leak the check exists to prevent, so nothing here is ever +written down -- it is read at run time from the photo pack, the message +database and the local .env, and on a machine with none of those it simply +comes back empty. +""" + +from __future__ import annotations + +import base64 +import json +import os +import sqlite3 +from pathlib import Path +from typing import Final + +REPO: Final = Path(__file__).resolve().parent.parent + +# Three, because real first names are three letters long and one of this +# group's is. Two would match inside half the words in the repository; three +# needs a handful of allowlist entries for the given names that are also +# popes, which is a cost worth paying to catch a real one. +MIN_NAME: Final = 3 + + +def _pack_names(directory: Path) -> set[str]: + """Every trigger word in a photo pack manifest -- i.e. every name.""" + manifest = directory / "people.json" + if not manifest.is_file(): + return set() + try: + document = json.loads(manifest.read_text(encoding="utf-8")) + except OSError, json.JSONDecodeError: + return set() + entries = [*document.get("people", []), *document.get("combos", [])] + return { + str(trigger).lstrip("!").strip() + for entry in entries + for trigger in entry.get("triggers", []) + } + + +def _database_names(path: Path) -> set[str]: + """Display names of everybody the bot has ever seen speak.""" + if not path.is_file(): + return set() + try: + connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True) + except sqlite3.Error: + return set() + # Literal statements, not a table name interpolated into one: a + # formatted query is a SQL-injection shape even when every input is a + # constant, and this repository takes no lint suppressions. + queries = ( + "SELECT DISTINCT display_name FROM message_counts", + "SELECT DISTINCT display_name FROM group_people", + "SELECT DISTINCT display_name FROM message_events", + ) + found: set[str] = set() + with connection: + for query in queries: + try: + rows = connection.execute(query) + except sqlite3.Error: + continue + found.update(str(row[0]) for row in rows if row[0]) + connection.close() + return found + + +def _database_groups(path: Path) -> set[str]: + """Every real group id the bot has seen, in both spellings. + + Both, because they are the same group written two ways: /v1/groups + reports "group." and an inbound envelope carries the id that + base64 decodes to. A check that knew only the first would have missed the + one that was sitting in tests/test_config.py. + """ + if not path.is_file(): + return set() + try: + connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True) + except sqlite3.Error: + return set() + queries = ( + "SELECT DISTINCT group_id FROM group_meta", + "SELECT DISTINCT group_id FROM message_events", + ) + found: set[str] = set() + with connection: + for query in queries: + try: + rows = connection.execute(query) + except sqlite3.Error: + continue + found.update(str(row[0]) for row in rows if row[0]) + connection.close() + inner: set[str] = set() + for group in found: + payload = group.removeprefix("group.") + try: + inner.add(base64.b64decode(payload, validate=True).decode("utf-8")) + except ValueError, UnicodeDecodeError: + continue + return found | inner + + +def _env_values(path: Path) -> set[str]: + """Phone numbers, group ids and inviters from the deployment's .env.""" + if not path.is_file(): + return set() + interesting = {"PHONE_NUMBER", "GROUP_IDS", "GROUP_ID", "TRUSTED_INVITERS"} + found: set[str] = set() + for line in path.read_text(encoding="utf-8").splitlines(): + key, _, value = line.partition("=") + if key.strip() in interesting: + found.update(part.strip() for part in value.split(",") if part.strip()) + return found + + +def forbidden(permitted: set[str]) -> tuple[set[str], list[str]]: + """Every literal that must not be committed, and where they came from. + + The sources are all outside the repository on purpose. A list of real + names kept *in* the repository to check against would be the leak it is + meant to prevent. + """ + pack = os.environ.get("PHOTO_PACK_DIR", "").strip() + directory = ( + Path(pack) + if pack + else Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) + / "signal-bot" + / "pack" + ) + sources: list[str] = [] + words: set[str] = set() + for label, found in ( + (f"pack manifest ({directory})", _pack_names(directory)), + ("message database", _database_names(REPO / "signal_bot.sqlite3")), + ("group ids", _database_groups(REPO / "signal_bot.sqlite3")), + (".env", _env_values(REPO / ".env")), + ): + if found: + sources.append(label) + words |= found + return { + word.lower() for word in words if len(word) >= MIN_NAME and word.lower() not in permitted + }, sources diff --git a/signal_bot/groups.py b/signal_bot/groups.py index 2bf6618..3f4bd23 100644 --- a/signal_bot/groups.py +++ b/signal_bot/groups.py @@ -33,7 +33,7 @@ def group_id_aliases(group_id: str) -> frozenset[str]: 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 + 2026-08-29: ``group.WTI5dVptbG5...`` decodes to ``Y29uZmlnLWdy...``, the id the same endpoint reports as ``internal_id``. Args: diff --git a/signal_bot/pack.py b/signal_bot/pack.py index 15910ef..03893e2 100644 --- a/signal_bot/pack.py +++ b/signal_bot/pack.py @@ -125,7 +125,7 @@ def default_dir() -> tuple[Path, bool]: def options_for(pack: Pack, token: str) -> tuple[frozenset[str], ...] | None: """What one typed name could mean, or None when it means nothing. - Accepts the bare spelling as well as the "!" one, because "!celina marek" + Accepts the bare spelling as well as the "!" one, because "!celina basia" is how people actually write it, and refusing the second half over a missing punctuation mark would be a rule nobody can see. """ diff --git a/tests/test_stats_render_day.py b/tests/test_stats_render_day.py index b7b0b86..ff88e28 100644 --- a/tests/test_stats_render_day.py +++ b/tests/test_stats_render_day.py @@ -9,7 +9,7 @@ from signal_bot.stats.render_day import MAX_LINES, DayView, render_day, summary_ # The real group, so the rendered example is a case that can actually occur. GROUP = [ ("Ala", 76, 12), - ("Jan Nowak", 42, 3), + ("Tomasz Nowak", 42, 3), ("kuhy", 38, 0), ("Basia", 33, 1), ("Franciszek Kowalczyk", 16, 0), @@ -44,7 +44,7 @@ def test_the_nightly_block_for_a_real_day() -> None: assert rendered == ( "📊 Wtorek, 1 września — 220 wiadomości (16 📷)\n" "👑 Ala — 76 (12 📷)\n" - "2. Jan Nowak — 42 (3 📷)\n" + "2. Tomasz Nowak — 42 (3 📷)\n" "3. kuhy — 38\n" "4. Basia — 33 (1 📷)\n" "5. Franciszek Kowalczyk — 16\n"