mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:17:02 +01:00
Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da016e1b94 | |||
| 77795ee6f8 | |||
| c195e6b4c1 | |||
| e3c7bbfe23 | |||
| b5c4e3e37b | |||
| 4af32f8bd5 | |||
| 72e90acb47 | |||
| d13ae42590 | |||
| 41df308393 | |||
| 0717df646a | |||
| 3107a9e55f | |||
| 6b69deb37d | |||
| 64852ae578 | |||
| 405541be9b | |||
| 0aa38d9188 | |||
| 08599e6056 | |||
| 08e72a5173 | |||
| 14d422295f | |||
| 262b874bd9 | |||
| 6ec746fad2 | |||
| 486c478618 | |||
| be9a501ab4 | |||
| 34b9900a90 | |||
| 8500a1add1 | |||
| a24d0d5905 | |||
| 0b539fc327 | |||
| bdd20654bf | |||
| 3a348a550c | |||
| aeb1c91b3c | |||
| 0f3da7308d | |||
| d3d89c890e | |||
| 99aa194ad8 | |||
| 9d546fef9a | |||
| 11aece2bf7 | |||
| 48957a105d | |||
| 21ccf8d8eb | |||
| aefffb2fd9 | |||
| e070fd5425 | |||
| 86e174ee49 | |||
| 37a27c6dff | |||
| a9535f52e4 | |||
| 80c0fa8e45 | |||
| 7f66116cd5 | |||
| 86ea1b3220 | |||
| 3b3a78820a | |||
| 1f4080e75d | |||
| 35102be863 | |||
| 9db3687752 | |||
| 50bd390b00 | |||
| 1bf63b053b | |||
| a79506c18f | |||
| ba54092e0a | |||
| da6e69cd44 | |||
| dc642b8a1f | |||
| 3d6ae0680c | |||
| ca38147576 | |||
| 032284bab3 | |||
| 116671236a | |||
| 0c29583db3 | |||
| 40538b89a1 |
4
.binary-allowlist
Normal file
4
.binary-allowlist
Normal file
@ -0,0 +1,4 @@
|
||||
# The management page's only non-text asset. An SVG so it stays reviewable
|
||||
# in a diff: the gate blocks the extension, not the content, and a PNG would
|
||||
# need a negation in .gitignore as well as an entry here.
|
||||
web/public/*.svg
|
||||
48
.env.sample
48
.env.sample
@ -59,6 +59,10 @@ CAT_API_URL=
|
||||
DOG_API_URL=
|
||||
GECKO_API_URL=
|
||||
COMMONS_API_URL=
|
||||
# Template; {slug} is substituted per poem.
|
||||
POEM_TEXT_URL=
|
||||
# Seconds between liveness probes; lower only for tests.
|
||||
LIVENESS_TICK_SECONDS=
|
||||
|
||||
# Optional. Where !papież keeps the 267 papal portraits it has downloaded.
|
||||
# Default: $CACHE_DIRECTORY/portraits under systemd (the unit sets
|
||||
@ -71,8 +75,52 @@ PORTRAIT_CACHE_DIR=
|
||||
# command without a second account, and it posts for real.
|
||||
SELFTEST_GROUPS=
|
||||
|
||||
# Optional, and private. The album the per-person photo commands draw
|
||||
# from: a directory holding people.json, tags.json and media/. Everything
|
||||
# identifying lives in there and nothing about it is in this repository --
|
||||
# see DOCS-photo-pack.md, and ./web.sh for how it gets filled in.
|
||||
#
|
||||
# Leave it empty and the bot looks in $STATE_DIRECTORY/pack (systemd) and
|
||||
# then $XDG_DATA_HOME/signal-bot/pack. Not finding one is normal: the
|
||||
# commands simply do not exist. Naming one that is not there, or one whose
|
||||
# manifest cannot be honoured, fails startup rather than every command.
|
||||
PHOTO_PACK_DIR=
|
||||
|
||||
# Optional. Where the /healthz readiness probe binds.
|
||||
# Default: 127.0.0.1:8137 -- deliberately not 8000, which unrelated dev
|
||||
# servers grab, and below the ephemeral range so the kernel cannot take it.
|
||||
BIND_HOST=
|
||||
BIND_PORT=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# scripts/signal_api_watchdog.sh -- the two-minute timer that restarts the
|
||||
# container when signal-cli can no longer reach Signal. Every value here is
|
||||
# optional; the defaults are what the timer has always used.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# What reaches the DESKTOP. journald gets every message either way, twice --
|
||||
# `journalctl --user -t signal-api-watchdog` and `-u signal-api-watchdog`.
|
||||
# escalation only the give-up message pops, and only once per window
|
||||
# always every transition pops, reusing one replaceable slot
|
||||
# never nothing pops
|
||||
# Default: escalation. The routine restart/recovery pair is the noise this
|
||||
# exists to stop -- it used to pop on every firing, with critical urgency and
|
||||
# no replace id, so one outage left a screen of boxes to dismiss by hand.
|
||||
WATCHDOG_NOTIFY=
|
||||
|
||||
# How long a popup stays up, in milliseconds. Default: 20000.
|
||||
WATCHDOG_NOTIFY_EXPIRE_MS=
|
||||
|
||||
# Consecutive failed probes before the container is restarted. Default: 2.
|
||||
FAILURES_BEFORE_RESTART=
|
||||
|
||||
# Restarts allowed inside RESTART_WINDOW before it gives up and escalates
|
||||
# instead. A deregistered account is not fixable by restarting, and a
|
||||
# forever-loop would hide that. Defaults: 3 restarts, 21600 seconds (6h).
|
||||
MAX_RESTARTS=
|
||||
RESTART_WINDOW=
|
||||
|
||||
# Seconds to wait after a restart before deciding whether it helped, and the
|
||||
# per-probe curl timeout. Defaults: 45 and 15.
|
||||
SETTLE_SECONDS=
|
||||
PROBE_TIMEOUT=
|
||||
|
||||
60
.github/workflows/ci.yml
vendored
60
.github/workflows/ci.yml
vendored
@ -15,6 +15,37 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# scripts/install_service.sh installs both of these for the host and
|
||||
# this file never did, so ./scripts/e2e.sh has failed on every commit
|
||||
# for as long as it has existed: tool/e2e_fixtures.py builds its JPEG
|
||||
# and MP4 fixtures with ffmpeg, and signal_bot/media_strip.py strips
|
||||
# metadata with exiftool. Verified rather than trusted, because an
|
||||
# apt exit code is not the same claim as "the binary resolves".
|
||||
# Every binary tool/e2e_fixtures.py shells out to, which is the whole
|
||||
# list rather than the first one that failed: ffmpeg builds the JPEG
|
||||
# and MP4 fixtures, heif-enc the HEIC one, and exiftool is what
|
||||
# signal_bot/media_strip.py strips metadata with. scripts/e2e.sh has
|
||||
# needed all three since it was written and this file installed none of
|
||||
# them, which is why CI has been red on every commit.
|
||||
#
|
||||
# Verified by resolving each binary, not by trusting apt's exit code --
|
||||
# the same distinction install_service.sh makes for the host.
|
||||
- name: Install the tools the fixtures are built with
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y ffmpeg libimage-exiftool-perl \
|
||||
libheif-examples libheif-plugin-x265
|
||||
for tool in ffmpeg exiftool heif-enc; do
|
||||
command -v "$tool" || { echo "::error::$tool did not resolve"; exit 1; }
|
||||
done
|
||||
# Resolving heif-enc is not the same claim as being able to encode:
|
||||
# Ubuntu splits the HEVC encoder into its own plugin, so the binary
|
||||
# is there and answers "No HEVC encoder available". One real encode
|
||||
# fails here, naming the problem, instead of inside a fixture builder.
|
||||
ffmpeg -loglevel error -f lavfi -i color=c=red:s=32x32 -frames:v 1 -y /tmp/probe.png
|
||||
heif-enc -q 50 /tmp/probe.png -o /tmp/probe.heic
|
||||
test -s /tmp/probe.heic || { echo "::error::heif-enc produced nothing"; exit 1; }
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
|
||||
@ -36,6 +67,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 .
|
||||
|
||||
@ -48,7 +85,7 @@ jobs:
|
||||
- name: shellcheck
|
||||
# --severity=style is the floor CI enforces; the local pre-commit hook
|
||||
# uses the same flag so a version skew cannot pass locally and fail here.
|
||||
run: shellcheck -x --severity=style run.sh scripts/*.sh
|
||||
run: shellcheck -x --severity=style ./*.sh scripts/*.sh
|
||||
|
||||
- name: pytest (100% branch coverage)
|
||||
run: .venv/bin/python -m pytest -q
|
||||
@ -62,3 +99,24 @@ jobs:
|
||||
# for eight hours on 2026-08-30.
|
||||
- name: cold start
|
||||
run: ./scripts/cold_start.sh
|
||||
|
||||
# The management page. Its own job rather than more steps on `check`: it
|
||||
# needs a Node toolchain that the Python job has no use for, and the two
|
||||
# halves fail for unrelated reasons.
|
||||
web:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: web
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24.20.0"
|
||||
- run: corepack enable
|
||||
- name: install (exact pins, from the lockfile)
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: tsc --noEmit and eslint
|
||||
run: pnpm lint
|
||||
- name: vitest (100% coverage, no excludes)
|
||||
run: pnpm coverage
|
||||
|
||||
29
.gitignore
vendored
29
.gitignore
vendored
@ -164,9 +164,28 @@ cython_debug/
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
*.jpg
|
||||
*.jpeg
|
||||
*.png
|
||||
*.webp
|
||||
*.gif
|
||||
*.mp4
|
||||
nohup.out
|
||||
|
||||
# The private photo album. It lives outside this repository -- in
|
||||
# ~/.local/share/signal-bot/pack -- and these names exist so that a stray
|
||||
# copy inside it cannot be committed by accident. people.json and tags.json
|
||||
# are the two files that hold the names; media/ holds the photos.
|
||||
people.json
|
||||
tags.json
|
||||
media/
|
||||
pack/
|
||||
# sources.json is the import ledger. custom.json and adds.json were never
|
||||
# listed and hold private data too: custom.json is free text somebody typed
|
||||
# into the group, adds.json holds Signal member uuids.
|
||||
sources.json
|
||||
custom.json
|
||||
adds.json
|
||||
|
||||
# The message database is private and never leaves this machine. A live
|
||||
# SQLite database is three files, so all three are named.
|
||||
*.sqlite3
|
||||
@ -175,3 +194,13 @@ nohup.out
|
||||
|
||||
# Wine/Bottles scratch dir, never part of the bot.
|
||||
bottles/
|
||||
|
||||
# The management page's build inputs and reports. dist/ is already covered by
|
||||
# the bare entry above, which matches at any depth.
|
||||
node_modules/
|
||||
web/coverage/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Disposable resume cache for tool/gen_art.py; see commons_api.load_cache.
|
||||
tool/.commons_cache.json
|
||||
tool/.commons_cache.tmp
|
||||
|
||||
93
.personal-data-allowlist
Normal file
93
.personal-data-allowlist
Normal file
@ -0,0 +1,93 @@
|
||||
# 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
|
||||
|
||||
# Polish painters whose given name is also somebody's. Permitted ONLY in the
|
||||
# category list that harvests their work, exactly as a pope's name is
|
||||
# permitted only in popes.json -- the same name in a test fixture or a
|
||||
# document is still refused.
|
||||
tool/art_categories.json::jan
|
||||
@ -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
|
||||
@ -98,7 +119,7 @@ repos:
|
||||
entry: .venv/bin/mypy
|
||||
language: system
|
||||
pass_filenames: false
|
||||
args: [signal_bot, fake_signal]
|
||||
args: [signal_bot, fake_signal, tool]
|
||||
|
||||
- id: shellcheck
|
||||
name: shellcheck
|
||||
@ -109,13 +130,14 @@ repos:
|
||||
types: [shell]
|
||||
|
||||
# The watchdog is bash, so pytest cannot reach it. Its own suite stubs
|
||||
# curl and docker and drives every branch, including the give-up cap.
|
||||
# curl, docker, notify-send and logger, and drives every branch --
|
||||
# including the give-up cap and how many popups a whole window produces.
|
||||
- id: watchdog-tests
|
||||
name: watchdog behaviour (bash)
|
||||
entry: scripts/test_watchdog.sh
|
||||
language: system
|
||||
pass_filenames: false
|
||||
files: ^scripts/(signal_api_watchdog|test_watchdog)\.sh$
|
||||
files: ^scripts/(signal_api_watchdog|test_watchdog|test_watchdog_harness)\.sh$
|
||||
|
||||
# 100% branch coverage, no omits. Runs on every commit: coverage is the
|
||||
# whole point of the suite, and a partial run cannot prove it.
|
||||
|
||||
173
DOCS-add-photo.md
Normal file
173
DOCS-add-photo.md
Normal file
@ -0,0 +1,173 @@
|
||||
# Adding a photo from the chat: `!add_photo`
|
||||
|
||||
How a member of an album's group proposes a photo for it by sending one,
|
||||
how the operator approves it, and what keeps that path from leaking anything.
|
||||
The album itself — where it lives, what its manifests look like, how several
|
||||
albums coexist — is `DOCS-photo-pack.md`.
|
||||
|
||||
Send a photo captioned `!dodaj alfa wakacje2019` in one of the album's groups.
|
||||
`alfa` resolves to that person's key; `wakacje2019` matches nobody and travels
|
||||
as a *proposed* label. The photo is stripped and lands in the album's
|
||||
`pending/`, and the reply is `czeka na zatwierdzenie: 1`. It is **not in the
|
||||
album yet**: `!alfa` and `!losowe wakacje2019` cannot find it until somebody
|
||||
approves it.
|
||||
|
||||
### Approval: the management page, and nothing else
|
||||
|
||||
`./web.sh` shows a **Waiting for approval** section above the tagger whenever
|
||||
`pending/` is not empty (`web/src/views/pending.tsx`). Each queued photo is a
|
||||
card with the album's people as checkboxes — the ones the sender named are
|
||||
pre-ticked — and one checkbox per proposed label. **Approve** moves the file
|
||||
into `media/`, writes `tags.json`, mints any ticked label in `custom.json`,
|
||||
records the sender in `adds.json` (with `approved_at`), and asks the bot for
|
||||
`POST /_reload`. From that moment `!losowe wakacje2019` answers with it, with
|
||||
no restart. **Reject** deletes the file and its sidecar; nothing else to undo,
|
||||
because nothing else was written.
|
||||
|
||||
The queue is the only door in. The bot's own process never approves anything;
|
||||
it writes `pending/<content-hash>.<ext>` plus a `.json` sidecar (`keys`,
|
||||
`labels`, `by`, `at`, `group`) and reads the result back at the reload. The
|
||||
page can only approve a label the sender actually proposed (anything else is
|
||||
`400`), and it can keep fewer — an unticked label is simply not minted. If the
|
||||
bot then refuses the reload (a stale alias, say) the page **reports** it and
|
||||
does not roll back: the photo is in the album with the tags the approver
|
||||
chose, and the bot's reason is about something else.
|
||||
|
||||
What holds:
|
||||
|
||||
- **No typed label travels.** Labels become keys in the registry's
|
||||
`arg_resolver`, at the dispatcher, *before* the line that logs the command
|
||||
name; a new word becomes a per-message token (`new1`, `new2`) and is claimed
|
||||
back by its own handler, so concurrent frames cannot swap labels. The reply
|
||||
is counts only — `czeka na zatwierdzenie: 2 · 1 już było` — and the proposed
|
||||
word exists in exactly one place, the sidecar inside the album's `0700`.
|
||||
`scripts/check_personal_data.sh` reads those sidecars too, so a proposed
|
||||
name is on the forbidden list before it is ever approved.
|
||||
- **No cap.** No cooldown, no size limit, no daily limit. `!random` takes the
|
||||
ordinary pack cooldown; `!add_photo` takes no decorator at all.
|
||||
- **At most six labels**, the same ceiling `!alfa beta …` has.
|
||||
- **The attachment must be on the same message**, never a quoted one: a reply
|
||||
carries the *quoted* message's media in `quote.attachments`, and taking it
|
||||
would let anyone re-file somebody else's photo.
|
||||
- **A duplicate changes nothing.** The stored name is a content hash, so a
|
||||
photo already in the album is `już było`, one already waiting is `już
|
||||
czeka` — the same photo twice in one message counts as waiting too — and a
|
||||
mixed batch queues the new files and says how many were which.
|
||||
- **Approval is whole or not at all.** Media, `tags.json`, `custom.json` and
|
||||
`adds.json` are written, the album is loaded again, and only a load that
|
||||
succeeds keeps them. A load that fails rolls all four back and leaves the
|
||||
photo in the queue with a `400` naming the reason.
|
||||
- **The second copy goes.** signal-cli keeps its own copy of every inbound
|
||||
attachment outside the album's `0700`. `!add_photo` deletes it once the file
|
||||
is queued, and also when the upload was refused.
|
||||
- **Stripping happens before the wait.** The file in `pending/` is already
|
||||
the stripped one; `scripts/e2e.sh` re-reads the queue with exiftool before
|
||||
approving anything, and the album after.
|
||||
|
||||
### Stripping: exiftool, with a positive check
|
||||
|
||||
`ffmpeg -map_metadata -1 -c copy` was tried first and **fails silently on
|
||||
photos**: given a JPEG with a real APP1 Exif segment it returned a
|
||||
byte-identical file, GPS marker intact, and exited 0. `-c copy` passes the
|
||||
compressed frame through as an opaque packet and a JPEG's Exif lives inside
|
||||
it. Do not reintroduce ffmpeg for this.
|
||||
|
||||
So the gate is a **positive check on the output**, never an exit code:
|
||||
|
||||
```sh
|
||||
exiftool -all= -time:all= -o <dst> <src> # never --overwrite_original
|
||||
exiftool -G0 -s -all <dst> # then read it back
|
||||
```
|
||||
|
||||
`-time:all=` is **not** redundant with `-all=`. `media_tags.ZEROED_DATE_TAGS`
|
||||
allows the QuickTime date tags "by value" on the grounds that `-all=` zeroes
|
||||
rather than deletes them. Measured 2026-08-31: it does not. `-all=` alone left
|
||||
`QuickTime:CreateDate` at its real value, and the consequence was that **every
|
||||
mp4 was refused** — the code documented behaviour it did not get. `-time:all=`
|
||||
is what actually zeroes them.
|
||||
|
||||
and every surviving tag must be **structural**. Measured against exiftool
|
||||
13.55, the plan's original "the surviving set must be empty" would have
|
||||
rejected every format except JPEG — a valid PNG always reports its IHDR under
|
||||
`PNG`, a GIF its screen descriptor under `GIF`, an MP4 some fifty `QuickTime`
|
||||
tags that *are* the container. So the allowlist is per **tag name**
|
||||
(`signal_bot/media_tags.py`), which keeps the property that matters: an atom
|
||||
nobody anticipated arrives as a name that is not on the list, and the upload
|
||||
is refused.
|
||||
|
||||
Allowlist, not blocklist, for the reason a naive gate misses: an MP4 geotag is
|
||||
a QuickTime `udta`/`©xyz` atom reported under **`QuickTime`**, not `GPS`.
|
||||
|
||||
### Tracks: ffmpeg, for what no tag allowlist can see
|
||||
|
||||
A tag allowlist is structurally blind to one class, and it is the worst one.
|
||||
`IMG_6924.mp4` in the live album carries two `mebx` timed-metadata tracks, and
|
||||
after a *complete* `-all= -time:all=` pass its bytes still contain:
|
||||
|
||||
```
|
||||
com.apple.quicktime.detected-face
|
||||
com.apple.quicktime.detected-face.bounds
|
||||
com.apple.quicktime.detected-face.face-id
|
||||
com.apple.quicktime.detected-face.roll-angle / .yaw-angle
|
||||
```
|
||||
|
||||
Per-frame face bounding boxes and a face identity number. exiftool is not
|
||||
broken for missing them: `mebx` samples live in `mdat` as opaque payload, not
|
||||
as tags, so **there is no tag to refuse**. The only answer is to drop the
|
||||
track, which `signal_bot/media_tracks.py` does before exiftool runs:
|
||||
|
||||
```sh
|
||||
ffmpeg -v error -i <src> -map 0:v -map "0:a?" -c copy \
|
||||
-map_metadata -1 -map_chapters -1 -y <dst>
|
||||
ffprobe -v error -show_entries stream=codec_type -of csv=p=0 <dst>
|
||||
```
|
||||
|
||||
and every surviving stream must be `video` or `audio`.
|
||||
|
||||
**This is not the ffmpeg mistake above.** That one was ffmpeg replacing
|
||||
exiftool for *tag* removal, where it silently failed on JPEG. Here ffmpeg
|
||||
removes *tracks*, exiftool still does every tag removal afterwards, and the
|
||||
gate is a positive read-back of which streams survived rather than an exit
|
||||
code. `-c copy` is safe for this job precisely because nothing is being
|
||||
claimed about the frames — the claim is about which tracks exist, and ffprobe
|
||||
answers that directly. Do not collapse the two jobs back together.
|
||||
|
||||
`QuickTime:MetaFormat` is deliberately **absent** from the allowlist: a file
|
||||
still reporting one has a metadata track the re-mux should have removed, and
|
||||
refusing it is the correct outcome.
|
||||
|
||||
**Both entry points obey this.** `./web.sh --import` used to be a
|
||||
verbatim `shutil.copy2`; since 2026-08-31 it runs the same stripper, so the
|
||||
guarantee on this page covers the whole album and not just the chat path.
|
||||
|
||||
The list includes the **codec's own configuration** — `CompressorName`,
|
||||
`VendorID`, and the HEVC parameter set (`HEVCConfigurationVersion`,
|
||||
`GeneralProfileIDC`, `BitDepthLuma`, …). That was forced by measurement, not
|
||||
chosen for convenience: with them off the list exiftool 13.55 could produce no
|
||||
acceptable video *and* no acceptable HEIC, because `CompressorName` lives in
|
||||
the `avc1` sample description and exiftool reports it as not writable at all
|
||||
(`Sorry, QuickTime:CompressorName doesn't exist or isn't writable`), and a real
|
||||
HEIC reports sixteen HEVC config tags. Since HEIC is the iPhone default, the
|
||||
choice was codec parameters or no phone photos. They describe how the picture
|
||||
is encoded, never where it was taken or who took it; every location and
|
||||
identity tag is still refused, and `scripts/e2e.sh` proves a QuickTime GPS atom
|
||||
does not survive on a file the album actually stored.
|
||||
|
||||
Formats are decided by **magic bytes**, never by the filename: jpg, png, gif,
|
||||
webp, mp4, mov, m4v, heic, heif, avif, webm and svg. Every one of them must
|
||||
also be in `pack.MEDIA_TYPES`, or a file that uploaded cleanly is refused by
|
||||
the *next* load and takes startup down.
|
||||
|
||||
### ⚠️ .webm and .svg are stored unstripped
|
||||
|
||||
exiftool 13.55 can write neither (`exiftool -listwf` lists no `WEBM` and no
|
||||
`SVG`; a strip attempt returns `Can't write SVG files`). Its SVG *reader* does
|
||||
not surface `<metadata>` content either, so there is no detection to fall back
|
||||
on.
|
||||
|
||||
kuhy's decision of 2026-08-31 is to keep both on the allowlist and store them
|
||||
**as they arrive, metadata included**. The consequence, stated plainly: an
|
||||
`.svg` carrying `<metadata>` or an embedded
|
||||
`<image xlink:href="data:image/jpeg;base64,…">` keeps whatever is in it, and
|
||||
every later `!alfa` re-sends that file verbatim. Every other format on the
|
||||
list is stripped and verified.
|
||||
163
DOCS-analytics.md
Normal file
163
DOCS-analytics.md
Normal file
@ -0,0 +1,163 @@
|
||||
# Command analytics
|
||||
|
||||
`message_events` answers *who said how much*. This answers *what they asked
|
||||
the bot for, when, and whether it worked* — a different table, a different
|
||||
package, and one that did not exist before 0.2.6.
|
||||
|
||||
## Why it needed a new table
|
||||
|
||||
The old record was two aggregates, and neither was a history.
|
||||
|
||||
- `command_uses` upserts one row per (group, user, command) holding **last use
|
||||
only**. It is cooldown state.
|
||||
- `daily_uses` counts uses per day. It is daily-limit state.
|
||||
|
||||
Both are written by `store.record_use`, which the dispatcher calls **only on
|
||||
success**. A gate refusal returned before it and a handler crash returned
|
||||
before it, so a command that failed every single time was indistinguishable
|
||||
from one nobody ran. There was no hour, no duration, and no idea which
|
||||
command a counted message had been.
|
||||
|
||||
`command_events` takes one row per **attempt**: outcome, duration, the item
|
||||
served, and the hour it happened in. `upstream_calls` takes one row per
|
||||
outbound HTTP request made while serving it.
|
||||
|
||||
## The two writes, and why they may never be merged
|
||||
|
||||
```
|
||||
store.commands.record(...) every attempt analytics
|
||||
store.record_use(...) successes only cooldown + daily limit
|
||||
```
|
||||
|
||||
`CommandRefusedError`'s docstring records the bug that made the split
|
||||
necessary: recording a refused use slid the cooldown window forward every
|
||||
time somebody was told to wait, so waiting the stated time never helped.
|
||||
|
||||
Now that the history takes refusals, unifying the two calls looks like an
|
||||
obvious simplification. `test_dispatcher_recording` fires a refused
|
||||
invocation and asserts both that the history grew **and** that `last_used`
|
||||
did not move. That test is the whole guard.
|
||||
|
||||
## What is never stored
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| the words after a command | `args` holds a registry-resolved pack key (`p1`, `add`, `random`) or `""` / `"+args"`. A user-typed column is the shape that put real names in this repository once. |
|
||||
| an exception's message | `error_class` is a class name. `str(error)` carries whatever was being processed, and here that is somebody's message. |
|
||||
| a URL | `upstream_calls.host` is a hostname. A URL carries identifiers, and identifiers belong in `item_id` where a decision governs them. |
|
||||
| a title or a caption | `item_id` is a QID, a Commons page id, a Wolne Lektury slug or a content hash. An identifier cannot carry a name; free text eventually will. |
|
||||
|
||||
## Three kinds of row
|
||||
|
||||
`source` says where a row came from, and the three are not interchangeable.
|
||||
|
||||
| `source` | group & person | hour | duration | outcome |
|
||||
|---|---|---|---|---|
|
||||
| `live` | observed | observed | observed | observed |
|
||||
| `journald` | observed | observed | **none** | **inferred** |
|
||||
| `daily_uses` | observed | **none** (`-1`) | **none** | assumed `ok` |
|
||||
|
||||
`scope.py` is the only place that names the filters, so "is the exclusion
|
||||
still there?" is one grep rather than six independent remembers. Hour and
|
||||
weekday panels take `local_hour >= 0`; latency panels take
|
||||
`handler_ms IS NOT NULL`; sessions and co-occurrence take a real hour,
|
||||
because a `daily_uses` timestamp is built from midnight and treating those as
|
||||
a sequence invents a sitting of forty commands in forty milliseconds.
|
||||
|
||||
Durations are `NULL`, never `0`. A zero reads as data and would drag a p50
|
||||
down — the same lesson `stats/backfill.UNKNOWN_HOUR` already records.
|
||||
|
||||
## The backfill
|
||||
|
||||
`tool/backfill_analytics.py` recovers the history that predates the table.
|
||||
Run it against a **copy** first. It is insert-only and re-runnable; there is
|
||||
no delete-then-rebuild pass, because journald has no retention configuration
|
||||
and a second run after the log had rotated would delete rows it could no
|
||||
longer replace.
|
||||
|
||||
Two sources that overlap, which is the whole difficulty — summing them would
|
||||
roughly double every historical count and nothing would fail:
|
||||
|
||||
- **journald** knows which command ran and when, but has no group and no
|
||||
uuid: `dispatcher.py` logs a display name, because the line was written to
|
||||
be read by a human.
|
||||
- **daily_uses** knows the group, the uuid and the day, and counts correctly,
|
||||
but has no times of day at all.
|
||||
|
||||
So the join runs **over `message_events`**, which already holds the group,
|
||||
the uuid, the real hour and the display name of every counted command, and
|
||||
lacks only the command's name — which is exactly what journald has. Then
|
||||
`daily_uses` fills the **residual**: `max(0, uses − matched)` per (group,
|
||||
user, command, day). Nothing is counted twice.
|
||||
|
||||
### Two things that were wrong on the first attempt
|
||||
|
||||
**The window is symmetric.** The log line is written strictly after the
|
||||
message arrives, so `[0, +15 s]` looks obviously right. Measured against the
|
||||
live database: of 299 counted commands, **194 had a journal line earlier than
|
||||
their own `sent_at_ms`**, by up to 3.2 seconds. The two stamps come from two
|
||||
different clocks — `sent_at_ms` is set on the *sender's phone*, the journal
|
||||
stamp is this host's — so their difference is skew and has either sign. The
|
||||
asymmetric window dropped 47% of the data.
|
||||
|
||||
**Matching is a global assignment, not per-row.** Two commands from one
|
||||
person seconds apart put both log lines inside both windows; picking
|
||||
best-for-each-row in turn cannot tell them apart and drops both. Closest-pair
|
||||
first, globally, can: the skew is systematic per sender, so the true pairs are
|
||||
the two smallest distances available. On the live database that took the
|
||||
drops from 137 to **zero**.
|
||||
|
||||
### One inaccuracy, stated rather than hidden
|
||||
|
||||
`dispatcher.py` logs `running command X` **before** the handler runs, so a
|
||||
journald row proves the command was *entered*, not that it finished. A
|
||||
handler that then crashed, or a `daily_limit`/`vote_gated` refusal — neither
|
||||
of which logged anything — is imported as `ok`. Measured on 2026-08-31 the
|
||||
gap is about 4%. Only `live` rows have outcomes that were actually observed,
|
||||
and the page says which is which.
|
||||
|
||||
## `!analityka`
|
||||
|
||||
Text only, Polish, twelve lines as a cap rather than a quota — a line with
|
||||
nothing to say is dropped, not printed empty. No padding, because Signal
|
||||
renders a proportional font.
|
||||
|
||||
It names at most three people, and only ones whose display name the bot has
|
||||
seen; everyone else is counted and left unnamed, exactly as the nightly
|
||||
report does. Full per-person matrices are on the management page. The trend
|
||||
verdict is suppressed below 14 days: the series is drawn, the direction is
|
||||
not claimed.
|
||||
|
||||
## The Analytics tab
|
||||
|
||||
`./web.sh`, then the first tab. Seven read-only routes under
|
||||
`/api/analytics`, each taking an optional `?group=`. Absent means the
|
||||
all-groups roll-up, and it runs the *same SQL* — every query is a complete
|
||||
literal string with `WHERE (:group IS NULL OR group_id = :group)`, so the
|
||||
roll-up and the per-group answer cannot drift. That spelling is also what
|
||||
keeps ruff's S608 quiet without a suppression.
|
||||
|
||||
Charts are Nivo 0.99.0, always the **fixed-size** components rather than the
|
||||
`Responsive*` ones: those measure their container, a jsdom container has no
|
||||
size, and a suite full of charts would otherwise assert against empty divs.
|
||||
`charts/size.ts` supplies a real measurement in a browser and a stated
|
||||
fallback everywhere else. Colours are `@kuhyx/web-ui` tokens, not a Nivo
|
||||
scheme.
|
||||
|
||||
## Verifying a change
|
||||
|
||||
```sh
|
||||
.venv/bin/python -m pytest -q # 100%, no omits
|
||||
./scripts/e2e.sh # incl. analytics_command.yaml
|
||||
pnpm --dir web lint && pnpm --dir web coverage # 100% on all four metrics
|
||||
pre-commit run --all-files
|
||||
|
||||
# the backfill, against a copy, never the live file
|
||||
cp signal_bot.sqlite3 /tmp/check.sqlite3
|
||||
journalctl --user -u signal-bot --no-pager -o short-iso > /tmp/bot.log
|
||||
python tool/backfill_analytics.py --db /tmp/check.sqlite3 --log /tmp/bot.log --dry-run
|
||||
```
|
||||
|
||||
CI runs `pnpm lint` and `pnpm coverage` but **never `pnpm build`**, so a
|
||||
bundling or CSP break is invisible to every automated gate. Building and
|
||||
opening the page is a required manual step, not an optional one.
|
||||
79
DOCS-changelog-archive.md
Normal file
79
DOCS-changelog-archive.md
Normal file
@ -0,0 +1,79 @@
|
||||
# Changelog archive
|
||||
|
||||
Releases older than the ten most recent, moved out of `DOCS-changelog.md`
|
||||
when it crossed the shared 250-line cap. The bot does not read this file:
|
||||
`signal_bot/changelog.py` only ever loads `DOCS-changelog.md`, and a
|
||||
backlog announcement is capped at ten releases anyway. Kept because the
|
||||
release history is the record of what the group was told, and when.
|
||||
|
||||
## 0.1.0 — Hyginus
|
||||
|
||||
papież nr 9 z 267, pontyfikat 136-140
|
||||
|
||||
- podsumowanie liczy teraz zdjęcia i filmy — zdjęcie bez podpisu było
|
||||
wcześniej warte zero, więc liczby wszystkim podskoczyły
|
||||
- kto nic nie napisał, pojawia się z zerem
|
||||
- nowe komendy: !staty, !wczoraj, !tydzień, !miesiąc, !rok, !rekordy,
|
||||
!korona, !dni, !ja
|
||||
- bot pamięta historię: rekordy życiowe, serie z koroną, średnie dni
|
||||
tygodnia. Liczymy od 30 sierpnia 2026
|
||||
|
||||
## 0.0.7 — Telesphorus
|
||||
|
||||
papież nr 8 z 267, pontyfikat 125-136
|
||||
|
||||
- nowa komenda `!papież` (działa też jako `!papiez`, `!papa`, `!pope`) — bot
|
||||
wrzuca losowego papieża: zdjęcie, imię po łacinie, lata pontyfikatu i link
|
||||
do polskiej Wikipedii. Do wyboru jest cała lista, 267 papieży od Świętego
|
||||
Piotra do Leona XIV — czyli ci sami, po których nazywają się wersje bota
|
||||
|
||||
## 0.0.6 — Xystus I
|
||||
|
||||
papież nr 7 z 267, pontyfikat 115-125
|
||||
|
||||
- po restarcie komputera bot wraca sam — wcześniej, jeśli włączył się zanim
|
||||
Signal był gotowy, poddawał się po 25 sekundach i milczał do końca dnia;
|
||||
30 sierpnia stało się to na osiem godzin
|
||||
|
||||
## 0.0.5 — Alexander I
|
||||
|
||||
papież nr 6 z 267, pontyfikat 105-115
|
||||
|
||||
- komendy działają teraz niezależnie od tego, na jakiej klawiaturze je piszesz
|
||||
— `!gekon` napisany rosyjskimi literami (albo jako `!g3ck0`) wreszcie
|
||||
odpowiada, wcześniej bot go po prostu ignorował
|
||||
|
||||
## 0.0.4 — Evaristus
|
||||
|
||||
papież nr 5 z 267, pontyfikat 97-105
|
||||
|
||||
- `!gekon` pokazuje teraz za każdym razem innego gekona — wcześniej wrzucał
|
||||
w kółko to samo zdjęcie
|
||||
|
||||
## 0.0.3 — Clemens I
|
||||
|
||||
papież nr 4 z 267, pontyfikat 88-97
|
||||
|
||||
- nowa komenda `!gekon` — bot wrzuca losowe zdjęcie gekona; działa też
|
||||
`!gecko`, `!geckon`, `!geko`, `!gekony` i `!geckos`
|
||||
|
||||
## 0.0.2 — Anacletus
|
||||
|
||||
papież nr 3 z 267, pontyfikat 76-88
|
||||
|
||||
- bot odpowiada teraz na konkretną wiadomość, cytując ją — w ruchliwej
|
||||
rozmowie widać, czyje `!cat` to jest
|
||||
|
||||
## 0.0.1 — Linus
|
||||
|
||||
papież nr 2 z 267, pontyfikat 64-76
|
||||
|
||||
- wersje bota nazywają się teraz imionami kolejnych papieży
|
||||
- po aktualizacji bot ogłasza w grupie nową wersję i listę zmian
|
||||
- `/healthz` pokazuje wersję i papieża, więc widać co naprawdę działa
|
||||
|
||||
## 0.0.0 — Petrus
|
||||
|
||||
papież nr 1 z 267, pontyfikat 33-64
|
||||
|
||||
- bot taki, jaki był przed drabinką papieską: `!cat`, `!dog` i dzienne podsumowanie
|
||||
@ -5,6 +5,19 @@ version number is a pure function of that ordinal — see `signal_bot/versioning
|
||||
for the two constants and why they are what they are. The short version is that
|
||||
Jan Paweł II has to land on 2.13.7, and everything else follows.
|
||||
|
||||
**The name is Latin, and it is derived, not typed.** `latin_name()` in
|
||||
`signal_bot/versioning.py` builds it from `popes.json`: the Latin stem plus the
|
||||
Roman numeral off the Polish name. So 0.0.3 is «Clemens I», not «Klemens I» and
|
||||
not the raw `latin` field's «Clemens Romanus»; 3.0.4 will be «Leo XIV», not «Leo
|
||||
Decimus Quartus». Ask the code rather than guessing:
|
||||
|
||||
```
|
||||
python -c 'from signal_bot.versioning import *; print(latin_name(pope_for(14)))'
|
||||
```
|
||||
|
||||
The same string goes in the release commit subject, in `/healthz`, and in the
|
||||
heading below.
|
||||
|
||||
The bot reads this file. The heading format is load-bearing:
|
||||
|
||||
```
|
||||
@ -14,7 +27,27 @@ The bot reads this file. The heading format is load-bearing:
|
||||
|
||||
Add the new section at the top when you bump `RELEASE_ORDINAL`. Anything that
|
||||
is not a `##` heading or a `-` bullet under one is ignored, so prose between
|
||||
releases is free.
|
||||
releases is free. The heading *name* is checked rather than trusted —
|
||||
`tests/test_changelog.py` asserts it equals `latin_name()` for that version —
|
||||
and the announcement reads `popes.json` regardless, so a typo here fails the
|
||||
suite instead of reaching a group.
|
||||
|
||||
**Preview the announcement before you bump.** Because a bump is a publish,
|
||||
the announcement was the one feature that could never be exercised first.
|
||||
`tool/preview_release.py testbot` posts exactly what the next release would
|
||||
say, into one test group, touching neither `RELEASE_ORDINAL` nor any
|
||||
`announced_version` row; `--print-only` renders it without sending. It is
|
||||
fail-closed on `SELFTEST_GROUPS`, so it cannot post a release that does not
|
||||
exist into the group that would believe it. Write the `##` section below
|
||||
first -- the preview reads it, so what you see is what ships.
|
||||
|
||||
**A release announcement is a photo now.** It carries the pope's portrait, his
|
||||
Latin name and his pl.wikipedia article, the same three things `!papież` posts.
|
||||
Only for the pope in the header: a backlog announcement lists up to ten
|
||||
releases, and ten attachments is the wall the cap exists to avoid. If Commons
|
||||
will not serve the portrait the announcement still goes out, as text — the
|
||||
`announced_version` marker is written before the send, so a failure there would
|
||||
otherwise cost that group its release notes permanently.
|
||||
|
||||
**Every release takes the next pope, however small.** A one-line fix consumes
|
||||
an ordinal exactly like a feature does; the ladder tracks releases, not
|
||||
@ -38,63 +71,169 @@ this repo: describe what changes for them. 0.0.1 shipped a bullet about
|
||||
`/healthz`, an endpoint bound to 127.0.0.1 that nobody in a Signal group can
|
||||
reach, and it was rightly called out.
|
||||
|
||||
## 0.2.6 — Stephanus I
|
||||
|
||||
papież nr 23 z 267, pontyfikat 254-257
|
||||
|
||||
- nowa komenda `!analityka` — kto ile używa bota, o której godzinie i które komendy nikomu się nie przydają
|
||||
- bot pamięta teraz każde użycie komendy, także to nieudane; wcześniej komenda, która zawsze się psuła, wyglądała jak taka, której nikt nie używa
|
||||
- historia sprzed tej wersji została odtworzona z dziennika systemowego, więc statystyki zaczynają się od 29 sierpnia, a nie od dzisiaj
|
||||
- strona zarządzania ma nową zakładkę z wykresami
|
||||
|
||||
## 0.2.5 — Lucius I
|
||||
|
||||
papież nr 22 z 267, pontyfikat 253-254
|
||||
|
||||
- dodano !sztuka — losowe dzieło sztuki, częściej polskie niż nie
|
||||
- dodano !wiersz — losowy krótki wiersz, w całości, z domeny publicznej
|
||||
|
||||
## 0.2.4 — Cornelius
|
||||
|
||||
papież nr 21 z 267, pontyfikat 251-253
|
||||
|
||||
- dodano !traps
|
||||
|
||||
## 0.2.3 — Fabianus
|
||||
|
||||
papież nr 20 z 267, pontyfikat 236-250
|
||||
|
||||
- jak wpiszecie komendę z literówką, bot podpowie tę właściwą: `!cta`
|
||||
dostaje odpowiedź „chodziło o `!kot`?”. Wcześniej milczał, więc pomyłka
|
||||
wyglądała dokładnie tak samo jak bot, który nie działa
|
||||
- komenda, której bot nie zna i do której nic nie pasuje, dostaje
|
||||
odpowiedź z podpowiedzią, co z tym zrobić
|
||||
- nowa komenda `!zaproponuj` — zapisuje pomysł na komendę, której jeszcze
|
||||
nie ma, np. `!zaproponuj !pogoda pokaż pogodę na jutro`. Pomysły trafiają
|
||||
na listę, którą czytam
|
||||
- każde `!słowo`, którego bot nie zna, jest zapisywane. To jest lista
|
||||
komend, które warto dopisać — więc wpisywanie ich ma sens nawet wtedy,
|
||||
kiedy jeszcze nie działają
|
||||
- bez zmian: zwykłe pisanie, samo `!!` i `!kot` z dopiskiem dalej nie
|
||||
wywołują żadnej odpowiedzi
|
||||
|
||||
## 0.2.2 — Anterus
|
||||
|
||||
papież nr 19 z 267, pontyfikat 235-236
|
||||
|
||||
- album urósł do 180 zdjęć — każda komenda ma ich teraz więcej, a jedna
|
||||
zaczęła w ogóle działać: nie miała dotąd ani jednego zdjęcia, więc nie
|
||||
odpowiadała nikomu, kto ją wpisał
|
||||
- `!dodaj` przyjmuje wreszcie filmy. Do tej pory odrzucał **każdy** film:
|
||||
bot sprawdzał, czy z pliku zniknęły daty, a narzędzie, którym je usuwał,
|
||||
wcale ich nie usuwało — więc film nigdy nie przechodził tej kontroli
|
||||
- z filmów wycinana jest teraz też ścieżka, którą iPhone dopisuje obok
|
||||
obrazu: ramka po ramce, gdzie na nagraniu są twarze i który to numer
|
||||
twarzy. Nie da się jej usunąć jak zwykłych metadanych, bo to nie jest
|
||||
opis pliku tylko osobny strumień — więc film jest przepakowywany i
|
||||
zostaje w nim wyłącznie obraz i dźwięk
|
||||
- zdjęcia wrzucane hurtem z folderu przechodzą teraz dokładnie tę samą
|
||||
kontrolę metadanych co te z czatu — wcześniej były wgrywane jak leci
|
||||
- to samo zdjęcie wysłane dwa razy, pod dwiema nazwami albo raz z czatu
|
||||
i raz z folderu, trafia do albumu jeden raz
|
||||
- to samo co wcześniej: te komendy istnieją tylko w tej grupie
|
||||
|
||||
## 0.2.1 — Pontianus
|
||||
|
||||
papież nr 18 z 267, pontyfikat 230-235
|
||||
|
||||
- zdjęcia można teraz dorzucać z czatu: podpisz wysyłane zdjęcie
|
||||
`!dodaj <imię>` i od razu wychodzi na `!<imię>`, bez restartu bota
|
||||
- można podać kilka etykiet naraz (`!dodaj ktos ktos2`) — zdjęcie dostaje
|
||||
wszystkie; można też wysłać kilka zdjęć w jednej wiadomości
|
||||
- etykieta, która nie pasuje do nikogo, zakłada nową — bot mówi wtedy, że
|
||||
powstała, i sięga się po nią przez `!losowe <etykieta>`
|
||||
- `!losowe` bez niczego to losowe zdjęcie z całego albumu
|
||||
- z każdego dodanego zdjęcia bot wycina metadane — w tym miejsce, w którym
|
||||
zostało zrobione, i model telefonu — po czym sprawdza, czy naprawdę
|
||||
zniknęły; jak nie potrafi tego zrobić, nie zapisuje zdjęcia w ogóle
|
||||
- kasuje też swoją kopię odebranego załącznika, żeby nie została poza albumem
|
||||
- w odpowiedzi są tylko liczby i skróty (`dodano · p1: 4`) — to, co
|
||||
wpisaliście, nie trafia ani do odpowiedzi, ani do logów
|
||||
- to samo co wcześniej: te komendy istnieją tylko w tej grupie
|
||||
|
||||
## 0.2.0 — Urbanus I
|
||||
|
||||
papież nr 17 z 267, pontyfikat 222-230
|
||||
|
||||
- nowe komendy ze zdjęciami: każda osoba ma swoją, a kilka naraz
|
||||
(`!ktos !ktos2`) daje zdjęcie, na którym są wszyscy naraz
|
||||
- jak takiego zdjęcia nie ma, bot mówi to jednym zdaniem zamiast milczeć
|
||||
- filmiki też działają — lecą jako filmiki, a nie jako plik, którego nie da
|
||||
się odtworzyć
|
||||
- te komendy istnieją tylko w tej jednej grupie: w każdej innej nie ma ich
|
||||
wcale, `!pomoc` ich tam nie pokazuje i bot na nie nie odpowiada
|
||||
- zdjęcia i imiona nie są w kodzie bota ani nigdzie w internecie — leżą w
|
||||
jednym katalogu na komputerze i nigdy go nie opuszczają
|
||||
|
||||
## 0.1.7 — Callistus I
|
||||
|
||||
papież nr 16 z 267, pontyfikat 217-222
|
||||
|
||||
- każda wersja mówi teraz, którym z kolei papieżem jest jej patron i w jakich
|
||||
latach panował — w ogłoszeniu nowej wersji, w changelogu i przy `!papież`
|
||||
- `!papież` pisze to samo zdaniem, którym opisuje każdego innego papieża, więc
|
||||
nie trzeba już zgadywać, czy «Callistus I» to ten piętnasty, czy szesnasty
|
||||
|
||||
## 0.1.6 — Zephyrinus
|
||||
|
||||
papież nr 15 z 267, pontyfikat 199-217
|
||||
|
||||
- statystyki o 21:37 są od teraz o **dzisiejszym** dniu, a nie o wczorajszym —
|
||||
wieczorny post pokazuje wreszcie ten dzień, który właśnie mijał
|
||||
- licznik zamyka się o 21:37, więc to, co napiszecie później, wpada do jutra;
|
||||
post sam to mówi, żeby liczba nie udawała całej doby
|
||||
- **jedna wiadomość, nie kilka**: dzień, tydzień, miesiąc i to, co bot
|
||||
przegapił, są blokami w jednym poście, a nie osobnymi powiadomieniami
|
||||
- dzień, którego bot przegapił (bo go nie było o 21:37), nadal zostanie
|
||||
wysłany później — nic nie ginie
|
||||
|
||||
## 0.1.5 — Victor I
|
||||
|
||||
papież nr 14 z 267, pontyfikat 189-199
|
||||
|
||||
- ogłoszenie nowej wersji przychodzi teraz ze zdjęciem papieża, po którym ta
|
||||
wersja się nazywa, i z linkiem do jego strony na polskiej Wikipedii —
|
||||
dokładnie tak, jak robi to `!papież`
|
||||
- wersje nazywają się od teraz po łacinie: ta jest «Victor I», a nie
|
||||
«Wiktor I», a poprzednia była «Eleutherius», nie «Eleuteriusz»
|
||||
|
||||
## 0.1.4 — Eleutherius
|
||||
|
||||
papież nr 13 z 267, pontyfikat 175-189
|
||||
|
||||
- `!pomoc` (albo `!help`) wypisuje wszystkie komendy i mówi jednym zdaniem,
|
||||
co każda robi — do tej pory nigdzie na czacie nie było ich listy
|
||||
|
||||
## 0.1.3 — Soterius
|
||||
|
||||
papież nr 12 z 267, pontyfikat 166-175
|
||||
|
||||
- "POCZEKAJ 1 SEKUND" odmienia się wreszcie po polsku — teraz jest
|
||||
1 SEKUNDĘ, 2 SEKUNDY, 5 SEKUND
|
||||
- odczekanie tego, co bot każe, wreszcie działa: dotąd każda nieudana próba
|
||||
po cichu przedłużała blokadę, więc kto grzecznie czekał, ten dostawał
|
||||
odmowę w kółko
|
||||
|
||||
## 0.1.2 — Anicetus
|
||||
|
||||
papież nr 11 z 267, pontyfikat 155-166
|
||||
|
||||
- `!ja` pokazywał wszystkim tę samą godzinę (12:00) — to był artefakt
|
||||
danych sprzed przebudowy licznika, a nie wasze zwyczaje. Teraz pokazuje
|
||||
prawdziwe godziny, a komu ich nie znamy, temu nie zmyśla
|
||||
|
||||
## 0.1.1 — Pius I
|
||||
|
||||
papież nr 10 z 267, pontyfikat 140-155
|
||||
|
||||
- komendy mówią teraz, co pokazują — `!korona` na pustych danych tłumaczy,
|
||||
co się w niej pojawi, zamiast samego "za mało danych"
|
||||
- `!ja` pisze "między 12:00 a 13:00" zamiast "o 12:00" — bo to godzina, a
|
||||
nie moment
|
||||
|
||||
## 0.1.0 — Hygin
|
||||
|
||||
- podsumowanie liczy teraz zdjęcia i filmy — zdjęcie bez podpisu było
|
||||
wcześniej warte zero, więc liczby wszystkim podskoczyły
|
||||
- kto nic nie napisał, pojawia się z zerem
|
||||
- nowe komendy: !staty, !wczoraj, !tydzień, !miesiąc, !rok, !rekordy,
|
||||
!korona, !dni, !ja
|
||||
- bot pamięta historię: rekordy życiowe, serie z koroną, średnie dni
|
||||
tygodnia. Liczymy od 30 sierpnia 2026
|
||||
|
||||
## 0.0.7 — Telesfor
|
||||
|
||||
- nowa komenda `!papież` (działa też jako `!papiez`, `!papa`, `!pope`) — bot
|
||||
wrzuca losowego papieża: zdjęcie, imię po łacinie, lata pontyfikatu i link
|
||||
do polskiej Wikipedii. Do wyboru jest cała lista, 267 papieży od Świętego
|
||||
Piotra do Leona XIV — czyli ci sami, po których nazywają się wersje bota
|
||||
|
||||
## 0.0.6 — Sykstus I
|
||||
|
||||
- po restarcie komputera bot wraca sam — wcześniej, jeśli włączył się zanim
|
||||
Signal był gotowy, poddawał się po 25 sekundach i milczał do końca dnia;
|
||||
30 sierpnia stało się to na osiem godzin
|
||||
|
||||
## 0.0.5 — Aleksander I
|
||||
|
||||
- komendy działają teraz niezależnie od tego, na jakiej klawiaturze je piszesz
|
||||
— `!gekon` napisany rosyjskimi literami (albo jako `!g3ck0`) wreszcie
|
||||
odpowiada, wcześniej bot go po prostu ignorował
|
||||
|
||||
## 0.0.4 — Ewaryst
|
||||
|
||||
- `!gekon` pokazuje teraz za każdym razem innego gekona — wcześniej wrzucał
|
||||
w kółko to samo zdjęcie
|
||||
|
||||
## 0.0.3 — Klemens I
|
||||
|
||||
- nowa komenda `!gekon` — bot wrzuca losowe zdjęcie gekona; działa też
|
||||
`!gecko`, `!geckon`, `!geko`, `!gekony` i `!geckos`
|
||||
|
||||
## 0.0.2 — Anaklet
|
||||
|
||||
- bot odpowiada teraz na konkretną wiadomość, cytując ją — w ruchliwej
|
||||
rozmowie widać, czyje `!cat` to jest
|
||||
|
||||
## 0.0.1 — Linus
|
||||
|
||||
- wersje bota nazywają się teraz imionami kolejnych papieży
|
||||
- po aktualizacji bot ogłasza w grupie nową wersję i listę zmian
|
||||
- `/healthz` pokazuje wersję i papieża, więc widać co naprawdę działa
|
||||
|
||||
## 0.0.0 — Święty Piotr Apostoł
|
||||
|
||||
- bot taki, jaki był przed drabinką papieską: `!cat`, `!dog` i dzienne podsumowanie
|
||||
Releases older than the ten most recent live in **`DOCS-changelog-archive.md`**.
|
||||
Nothing reads them: `announce` caps a backlog at ten releases, and `since()`
|
||||
resolves the floor from the version ladder rather than from this file, so a
|
||||
group that has been quiet for twenty releases still gets the ten newest that
|
||||
are here. Moved when this file crossed the shared 250-line cap; keep doing
|
||||
that rather than letting it grow.
|
||||
|
||||
153
DOCS-commands.md
Normal file
153
DOCS-commands.md
Normal file
@ -0,0 +1,153 @@
|
||||
# Commands
|
||||
|
||||
The copy that matters is not this file: `!pomoc` prints the same list **in the
|
||||
group**, in Polish, one line of description per command. That version is built
|
||||
from the command registry at run time and cannot drift, because
|
||||
`CommandRegistry.register` takes the description as a required argument and
|
||||
`help_command.render_help` prints any command its `GROUPS` table forgot to
|
||||
file anyway, under no heading.
|
||||
|
||||
This file is the trigger reference — every spelling that resolves, which
|
||||
`!pomoc` deliberately does not print.
|
||||
|
||||
| Command | Aliases |
|
||||
|---|---|
|
||||
| cat | `!kot` `!koty` `!kots` `!cat` `!cats` `!meow` `!miau` + styled variants |
|
||||
| dog | `!pies` `!psy` `!dog` `!dogs` `!woof` `!szczek` + styled variants |
|
||||
| gecko | `!gekon` `!gekony` `!geko` `!gecko` `!geckon` `!geckos` |
|
||||
| art | `!sztuka` `!sztukę` `!sztuki` `!art` |
|
||||
| poem | `!wiersz` `!wiersze` `!wiersza` `!wierszyk` `!poem` `!poems` `!poezja` `!poezje` `!poetry` |
|
||||
| trap | `!pułapka` `!pulapka` `!pułapki` `!pulapki` `!pułapkę` `!pulapke` `!trap` `!traps` `!trapy` `!trapper` `!sidła` `!sidla` `!sidło` `!sidlo` `!wnyk` `!wnyki` `!potrzask` `!zasadzka` `!klatka` `!samołówka` `!samolowka` `!beartrap` `!bear_trap` `!snare` `!snares` `!mousetrap` |
|
||||
| pope | `!papież` `!papiez` `!papieze` `!papieża` `!papa` `!pope` `!popes` `!pontifex` + styled variants |
|
||||
| stats_today | `!staty` `!statystyki` `!stats` `!dziś` `!dzis` |
|
||||
| stats_yesterday | `!wczoraj` `!yesterday` |
|
||||
| stats_week | `!tydzień` `!tydzien` `!week` |
|
||||
| stats_month | `!miesiąc` `!miesiac` `!month` |
|
||||
| stats_year | `!rok` `!year` |
|
||||
| stats_records | `!rekordy` `!rekord` `!records` |
|
||||
| stats_crown | `!korona` `!crown` |
|
||||
| stats_weekdays | `!dni` `!days` |
|
||||
| stats_me | `!ja` `!moje` `!mnie` |
|
||||
| command_stats | `!analityka` `!command_stats` `!commandstats` `!analytics` `!uzycie` `!użycie` `!ranking` |
|
||||
| help | `!pomoc` `!pomocy` `!help` `!komendy` `!commands` |
|
||||
| random | `!losowe` `!losuj` `!random` `!rand` |
|
||||
| add_photo | `!dodaj` `!dodaj_zdjecie` `!dodajzdjecie` `!add_photo` `!addphoto` |
|
||||
| suggest | `!zaproponuj` `!propozycja` `!sugestia` `!suggest` `!pomysl` |
|
||||
| good_bot | `!good bot` `!dobry bot` `!goodbot` `!dobrybot` `!good_bot` `!dobry_bot` |
|
||||
| food_today | `!co_jadł` `!co_jadl` `!cojadł` `!cojadl` `!jedzenie` `!dieta` + the operator's own in `aliases_custom.json` |
|
||||
| workout_today | `!co_ćwiczył` `!co_cwiczyl` `!coćwiczył` `!cocwiczyl` `!trening` `!workout` + the operator's own in `aliases_custom.json` |
|
||||
| mute | `!cisza` `!cicho` `!mute` |
|
||||
| unmute | `!odcisz` `!wracaj` |
|
||||
|
||||
Aliases live in `signal_bot/aliases.json`, not in source, because they are
|
||||
data: adding one should never mean touching Python. Two constraints on a new
|
||||
one. It is subtracted from the personal-data gate's word list by
|
||||
`_repo_vocabulary()`, so it must not be name-shaped; and
|
||||
`tests/test_nearest.py` fails if it lands within edit distance of a
|
||||
*different* command's trigger, because the near-miss suggestion below would
|
||||
then be a coin toss. `!poems` is the one trigger in the table that is only
|
||||
just legal: it sits two edits from `!pies`, and passes solely because `!poem`
|
||||
is one edit away and wins the tie. Deleting `!poem` would fail the suite at
|
||||
`!poems`, nowhere near where the change was made.
|
||||
|
||||
The operator can add words of their own without a commit: the Commands tab of
|
||||
the management page writes `aliases_custom.json` in the bot's state directory
|
||||
(`CUSTOM_ALIASES_FILE` names another path), keyed by command name, and the
|
||||
bot appends them to the shipped list when it builds the table. They are
|
||||
extra spellings only -- `!pomoc` keeps showing the shipped one -- and they go
|
||||
through the same clash check as `aliases.json`, so a word another command
|
||||
already answers to is refused, as is a name no command has. That is what
|
||||
makes the page's save a reload: the bot proves the file before it is kept.
|
||||
The tagger refuses to give a person a trigger that is already somebody's
|
||||
custom alias, for the same reason it refuses the shipped ones.
|
||||
|
||||
`good_bot` is the one two-word trigger. It works because `fold()` keeps inner
|
||||
spaces and the registry matches the whole text before it looks at the first
|
||||
word, so `!good bot` resolves and `!good` alone does not. Two spaces between
|
||||
the words resolve to nothing, which is what the one-word spellings are for.
|
||||
It is also the one command that answers with no message: it puts an emoji
|
||||
on the compliment itself (`POST /v1/reactions/<number>`), drawn from a small
|
||||
set, and says nothing in the group.
|
||||
|
||||
`food_today` and `workout_today` read two logs other programs keep --
|
||||
diet_guard's `food_log.json` and screen_locker's `log.json`, at those
|
||||
programs' default paths unless `FOOD_LOG_FILE` / `WORKOUT_LOG_FILE` say
|
||||
otherwise -- and print the current Warsaw day out of them, fresh on every
|
||||
call. The spelling the group actually uses for each has a surname in it,
|
||||
so it is a custom alias in the state directory and never appears in this
|
||||
repository. Food prints every entry and the day's total, never the daily
|
||||
budget, which diet_guard keeps on the machine on purpose. Workouts print
|
||||
every field an entry carries except a reservation phone number, a
|
||||
screenshot path and the sync record id.
|
||||
|
||||
## A word nothing claims
|
||||
|
||||
An unrecognised `!word` used to be dropped in silence, which made a typo
|
||||
indistinguishable from the bot being down. Now it is answered, and kept:
|
||||
|
||||
- within one edit (two, from six characters up) of a real trigger, the bot
|
||||
asks whether that is what you meant — `!cta` → *chodziło o !kot?* The
|
||||
distance is optimal string alignment rather than `difflib`'s ratio,
|
||||
because a transposition costs one edit there and shreds the matching
|
||||
blocks a ratio counts.
|
||||
- otherwise it points at `!zaproponuj`, which files the idea.
|
||||
- either way the word and the message go into `unknown_commands`, which the
|
||||
management page shows. That is the list of commands worth writing next.
|
||||
|
||||
Three things stay silent, and each for its own reason. `!kot ładny` is a
|
||||
real trigger with words after it, and always has been silent. `!!` and `!?`
|
||||
are punctuation, not a mistyped command. And a command scoped to some *other*
|
||||
group — the album's, whose triggers are real people's names — is neither
|
||||
answered nor recorded, because a refusal would announce that the album
|
||||
exists.
|
||||
|
||||
`random` exists **only in the photo album's own groups**, and only on an
|
||||
installation that has an album at all — everywhere else it is not a command,
|
||||
not refused and never logged. It takes arguments, and those
|
||||
arguments are resolved to pseudonym keys by the registry before any handler,
|
||||
log line or `command_uses` row sees them. See `DOCS-photo-pack.md`.
|
||||
|
||||
`mute` (`!cisza [1-10]`) silences the bot in **that group only**, for the
|
||||
minutes asked or ten by default, and ten at most. It confirms once and then
|
||||
answers nothing: no commands, no near-miss suggestion, and the nightly report
|
||||
is held back (not marked, so the next run still says that day). Messages are
|
||||
still counted. Anybody may ask -- ten quiet minutes is the whole downside.
|
||||
`unmute` (`!odcisz`) is the one command that runs through a mute, and only
|
||||
for `TRUSTED_INVITERS`; anyone else is refused without a reply, because a
|
||||
refusal is a reply. With `TRUSTED_INVITERS` unset, anybody may. There is no
|
||||
`!unmute` alias: it sits one edit from `!mute`, inside the near-miss budget.
|
||||
|
||||
`add_photo` (`!dodaj alfa wakacje` + an attachment) exists in the same groups
|
||||
as `random` and under the same silence elsewhere. It does **not** put the
|
||||
photo in the album: it strips it and queues it for approval on the management
|
||||
page (`./web.sh`), and answers with a count — `czeka na zatwierdzenie: 1`.
|
||||
Only an approved photo is retrievable, and it is from the moment it is
|
||||
approved. No cooldown, no size cap, no daily limit. `DOCS-add-photo.md`.
|
||||
|
||||
Any command can be **switched off** from the management page (Commands tab),
|
||||
outright or until a moment, and given its own cooldown. The bot reads the
|
||||
switch on every attempt, so it is live without a reload; a command that is
|
||||
off answers `BEEP BOOP !kot WYŁĄCZONE.` (or `… WYŁĄCZONE DO 14:01.`, Warsaw
|
||||
time) rather than going quiet, and the refusal neither starts the cooldown nor
|
||||
counts as a use. `!pomoc` still lists it. `DOCS-web-ui.md` has the page, and
|
||||
the same row is where a custom alias is typed.
|
||||
|
||||
## Why `!pomoc` shows the first alias and not a trigger
|
||||
|
||||
`Command.triggers` is a `frozenset` of **folded skeletons** — `folding.py`
|
||||
runs NFKC, strips diacritics and maps Cyrillic/Greek/small-caps/leet
|
||||
lookalikes onto ASCII, so that `!gecko` typed on a Russian keyboard still
|
||||
resolves. Everything that makes those triggers robust also makes them
|
||||
unprintable: the set has no order, `!papież` survives only as `!papiez`, and
|
||||
the styled variants come out as zalgo.
|
||||
|
||||
So `Command.primary` holds the first *raw* trigger instead, which
|
||||
`aliases.json` lists as the canonical Polish spelling for every command.
|
||||
|
||||
Two consequences worth remembering, both from `folding.py`:
|
||||
|
||||
- A command name can never contain a digit — `fold` maps `0134578` onto
|
||||
letters, so `!7dni` would resolve as `!tdni`.
|
||||
- The styled Unicode aliases in the table above are redundant now; folding
|
||||
covers them. They are kept because removing an alias can only take
|
||||
something away.
|
||||
@ -34,10 +34,43 @@ building anything, measure these two separately:
|
||||
that is the real win and it makes the prefetch pool far more valuable,
|
||||
because the pool could hold ids instead of bytes.
|
||||
|
||||
Head-of-line blocking is a separate, confirmed problem and does not need
|
||||
re-measuring: `bot.py` awaits each frame to completion, so one slow upstream
|
||||
fetch delays every other message behind it. Dispatching each frame as its own
|
||||
task under a bounded semaphore fixes that regardless of what the send costs.
|
||||
## Head-of-line blocking: fixed
|
||||
|
||||
This one was confirmed without re-measuring: `bot.py` awaited each frame to
|
||||
completion, so one slow upstream fetch delayed every message behind it. Now
|
||||
every frame runs in a task of its own (`signal_bot/inflight.py`), never more
|
||||
than `LIMIT` at once. The reader waits for a slot *before* spawning, so a
|
||||
burst applies back-pressure at the websocket instead of fanning out into a
|
||||
hundred upstream connections. The one thing that must not run side by side
|
||||
is one person's own commands -- two quick `!kot` would both pass the cooldown
|
||||
gate before either had recorded a use -- so `signal_bot/user_lock.py`
|
||||
serialises per sender, and nobody else waits.
|
||||
|
||||
## The bot's own share, measured and budgeted
|
||||
|
||||
Every measurement above is a measurement of somebody else. To say anything
|
||||
about *this* code, each served command now reports its timings split into
|
||||
the bot's own time and everybody else's (`signal_bot/served.py`):
|
||||
|
||||
| field | what it covers |
|
||||
|---|---|
|
||||
| `queue_ms` | frame read → a slot free: waiting behind other frames |
|
||||
| `dispatch_ms` | slot → handler: parse, scope, tally, resolve, sender lock |
|
||||
| `handler_ms` | the handler, wall clock |
|
||||
| `send_ms` | of which: `POST /v2/send` and friends |
|
||||
| `upstream_ms` | of which: Commons, the cat API, and the like |
|
||||
| `own_ms` | `queue + dispatch + handler - send - upstream` |
|
||||
|
||||
`POST /_selftest` answers with that block, and `tool/latency.py` holds it to
|
||||
the budget: **`own_ms` ≤ `OWN_BUDGET_MS` (100 ms) at p95**, over N replies of
|
||||
a text-only command in a test group, with the command's cooldown switched off
|
||||
for the run and put back afterwards. Exit 1 when over. The whole reply is
|
||||
bounded by the container and reported (`round_trip_ms`), not budgeted.
|
||||
|
||||
```sh
|
||||
tool/latency.py testbot # 20 x !pomoc
|
||||
tool/latency.py testbot --text '!ja' --times 50
|
||||
```
|
||||
|
||||
## Rule
|
||||
|
||||
|
||||
@ -29,6 +29,33 @@ Two things now make that impossible to miss:
|
||||
and a forever-restart loop would hide it. `scripts/install_service.sh`
|
||||
installs and arms it beside the bot's own unit.
|
||||
|
||||
## What reaches the desktop
|
||||
|
||||
Every watchdog message goes to journald twice — once via
|
||||
`logger --tag signal-api-watchdog`, once as the unit's own stdout — and the
|
||||
desktop popup is a *summary* of that, never the record. `WATCHDOG_NOTIFY`
|
||||
decides how much of it pops: `escalation` (the default, give-up only),
|
||||
`always`, or `never`.
|
||||
|
||||
It is a summary because it was once a flood. Three things made it one:
|
||||
|
||||
- The popups fired per **check**, not per state change, and `give_up()` had no
|
||||
once-only flag — so a six-hour give-up window announced itself every two
|
||||
minutes, up to 180 times, while its own text said "no further restarts".
|
||||
- `alarm()` hardcoded `--urgency=critical`, which `xfce4-notifyd` never
|
||||
expires, and passed no replace id, so they stacked instead of superseding.
|
||||
`notify-send --print-id` now feeds `--replace-id` from
|
||||
`${STATE_DIR}/notify_id`: one slot, reused.
|
||||
- Worst of all, none of it needed an outage. `scripts/test_watchdog.sh` stubbed
|
||||
only `curl` and `docker`, so `notify-send` was the real binary and the suite
|
||||
fired a dozen genuine critical popups per run — from a pre-commit hook. On
|
||||
2026-08-31 the timer emitted **zero** messages and the desktop still
|
||||
collected 297, every one of them self-inflicted by committing.
|
||||
|
||||
The stubs now live in the harness, and the suite asserts the counts rather than
|
||||
the absence: a healthy check pops nothing, a routine restart-and-recovery pops
|
||||
nothing, and a sustained give-up window pops exactly once.
|
||||
|
||||
```bash
|
||||
systemctl --user list-timers signal-api-watchdog.timer
|
||||
journalctl --user -u signal-api-watchdog.service -n 20
|
||||
|
||||
250
DOCS-photo-pack.md
Normal file
250
DOCS-photo-pack.md
Normal file
@ -0,0 +1,250 @@
|
||||
# The photo pack
|
||||
|
||||
A pack is a private album the bot posts from: `!alfa` returns a random photo
|
||||
of alfa, and `!alfa beta` returns one with both of them on it.
|
||||
|
||||
**Nothing about a real pack is in this repository, and nothing ever will be.**
|
||||
Who the people are, what somebody types to get their photo and which file
|
||||
shows whom live in one directory outside the repo. This document, the tests
|
||||
and the offline end-to-end run all use the same invented people — alfa, beta,
|
||||
gamma — so the mechanism can be public while the album stays private.
|
||||
|
||||
## Where it lives
|
||||
|
||||
```
|
||||
~/.local/share/signal-bot/pack/
|
||||
people.json # the commands, and the only place names exist
|
||||
tags.json # which file shows whom
|
||||
custom.json # labels approved from !add_photo (optional)
|
||||
adds.json # who added what, read by nothing (optional)
|
||||
media/ # the photos and videos themselves
|
||||
pending/ # !add_photo sends waiting for approval (optional)
|
||||
```
|
||||
|
||||
Found in this order: `PHOTO_PACK_DIR`, then `$STATE_DIRECTORY/pack` (what
|
||||
systemd hands a **system** unit), then `$XDG_DATA_HOME/signal-bot/pack`.
|
||||
`StateDirectory=` is deliberately **not** in the unit this repo installs: for
|
||||
a *user* unit systemd resolves it below `$XDG_STATE_HOME`, which is not where
|
||||
the album is. Discovery finds the same directory, and leaving `PHOTO_PACK_DIR`
|
||||
unset keeps a not-yet-filled-in pack from turning `Restart=always` into a loop.
|
||||
|
||||
Two absences, deliberately different:
|
||||
|
||||
- a pack **nobody named** that is not there, or that has no `people.json`
|
||||
yet, is an installation without an album: the commands do not exist, and
|
||||
that is the normal case on every machine and in the whole test suite;
|
||||
- a pack named in `PHOTO_PACK_DIR` that is missing, or whose manifest cannot
|
||||
be honoured, **fails startup**. `load_config` raising is an answer, and a
|
||||
bot that came up healthy and said "no such photo" forever would not be.
|
||||
|
||||
### More than one album
|
||||
|
||||
`PHOTO_PACK_DIR` takes a **comma-separated list**, the same spelling
|
||||
`GROUP_IDS` and `TRUSTED_INVITERS` use:
|
||||
|
||||
```sh
|
||||
PHOTO_PACK_DIR=~/.local/share/signal-bot/pack,~/.local/share/signal-bot/pack-testbot
|
||||
```
|
||||
|
||||
Each album carries its own group scope in its own `people.json`, and the two
|
||||
share nothing but the process: separate people, separate tags, separate
|
||||
custom labels. That is what lets a test group have working `!add_photo` and
|
||||
`!losowe` over invented people while the real album does not exist there at
|
||||
all — which one album cannot do, because its `groups` list *is* the privacy
|
||||
guarantee.
|
||||
|
||||
Trigger collisions between albums are therefore legal. Two albums are two
|
||||
sets of people and both may call somebody `!alfa`; no message can ever mean
|
||||
both, because a command a group is not scoped to is not a command there.
|
||||
`!dodaj` and `!losowe` are registered once per album for exactly this reason.
|
||||
|
||||
**Two albums whose groups overlap are refused at startup** with
|
||||
`DuplicateTriggerError`. Overlapping scopes would make "which album does
|
||||
`!dodaj` write to?" a coin toss, and a coin toss about which private album
|
||||
receives a photo is the outcome this whole design exists to prevent.
|
||||
|
||||
## people.json
|
||||
|
||||
```json
|
||||
{
|
||||
"groups": ["group.WlRKbExYZGhkR05vWldRdA=="],
|
||||
"people": [
|
||||
{"key": "p1", "triggers": ["!alfa", "!alfka"]},
|
||||
{"key": "p2", "triggers": ["!beta"]}
|
||||
],
|
||||
"combos": [
|
||||
{"key": "c1", "triggers": ["!oboje"], "all_of": ["p1", "p2"]},
|
||||
{"key": "c2", "triggers": ["!ktokolwiek"], "any_of": ["p1", "p2"]}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- **`groups` is the whole privacy guarantee.** In any other group these are
|
||||
not commands at all: not answered, not counted as a command, not named in a
|
||||
log line. A handler that quietly returned would still leak all three, which
|
||||
is why the *dispatcher* enforces it — `registry.resolve_with_args` takes the
|
||||
group and gives back nothing outside it.
|
||||
- **`key` is a pseudonym**, and the only identifier that reaches the journal
|
||||
or the `command_uses` table. A command is `pack:p1`; reading either says
|
||||
somebody asked for a photo and nothing about whose.
|
||||
- **`all_of` means one photo with all of them; `any_of` means either**, and
|
||||
where a choice exists the bot picks among the readings that actually have a
|
||||
photo, so a combination that could be answered is not refused.
|
||||
- Triggers may not contain a digit — `folding.py` maps `0134578` onto letters,
|
||||
so `!alfa7` is not what you typed. Two spellings that fold the same are fine
|
||||
*within one entry* (`!celina`/`!celiną` is one person written twice) and
|
||||
refused across two.
|
||||
|
||||
## tags.json
|
||||
|
||||
```json
|
||||
{"alfa.png": ["p1"], "both.png": ["p1", "p2"], "clip.mp4": ["p1"]}
|
||||
```
|
||||
|
||||
A tag naming a file that is not in `media/` fails startup: a renamed file
|
||||
would otherwise drop out of the album in silence. An empty list means "seen,
|
||||
nobody in it", which is how the tagger records a photo you skipped.
|
||||
|
||||
Videos work. The attachment goes out as
|
||||
`data:video/mp4;filename=<name>;base64,<data>` — the spelling is the
|
||||
container's own, from its `/swagger/doc.json` — because told it is a photo,
|
||||
Signal renders a clip as a file nobody can play.
|
||||
|
||||
## custom.json
|
||||
|
||||
```json
|
||||
{"t1": ["wakacje2019"], "t2": ["gorybieszczady"]}
|
||||
```
|
||||
|
||||
Labels `!add_photo` learned from the chat that match no person. The key is a
|
||||
pseudonym in the same family as `p1` — it is what reaches `tags.json`, a log
|
||||
line and a reply — and the label is the only place the typed word is kept.
|
||||
Custom tags define **no commands**: `!random <label>` is the only door to one.
|
||||
That is deliberate, and it is what makes a live album swap safe — a swap
|
||||
replaces pack *data* only, and can never register a trigger or widen a
|
||||
command's groups.
|
||||
|
||||
A label that folds onto a person's trigger, or onto another key's label, fails
|
||||
startup rather than shadowing the command it collides with.
|
||||
|
||||
## adds.json
|
||||
|
||||
```json
|
||||
{"a3ae52c7….jpg": {"by": "<uuid>", "at": 1756600000, "approved_at": 1756700000}}
|
||||
```
|
||||
|
||||
Who added which file, and when it was approved. **Nothing reads it** —
|
||||
`pack_load` ignores it — and it exists only so `!undo_photo` can be built
|
||||
later without a gap in its history; the adder is recorded at add time or not
|
||||
at all. It lives inside the album (`0700`, outside this repository), so it is
|
||||
handled exactly like the photos.
|
||||
|
||||
## sources.json
|
||||
|
||||
```json
|
||||
{
|
||||
"IMG_0234.jpg": {"source": "9f2c…", "stored": "b71e…"},
|
||||
"a3ae52c7….jpg": {"source": null, "stored": "a3ae…"}
|
||||
}
|
||||
```
|
||||
|
||||
The import ledger: every file the album has ever taken in, under **two**
|
||||
SHA-256s, because stripping changes the bytes.
|
||||
|
||||
- `source` — the file as it *arrived*, before metadata was removed. This
|
||||
answers "have I imported this photo before?" when the same export folder is
|
||||
pointed at the album a second time.
|
||||
- `stored` — the bytes on disk now. This answers "is the photo somebody just
|
||||
uploaded from the chat already here?", because `!add_photo` hashes *after*
|
||||
stripping.
|
||||
|
||||
Neither alone is enough. The same photo arriving through `--import` and
|
||||
through Signal has different source bytes — the phone re-encodes — but strips
|
||||
to the same output. `source` is `null` when the original bytes were never
|
||||
kept, which is exactly the `!add_photo` files.
|
||||
|
||||
Like `adds.json`, **`pack_load` never touches it.** A corrupt ledger degrades
|
||||
to "I have seen nothing", costing a duplicate photo; a corrupt ledger checked
|
||||
at startup would be a restart loop under `Restart=always`.
|
||||
|
||||
## Adding from the chat: !add_photo
|
||||
|
||||
A member of one of the album's groups sends a photo captioned
|
||||
`!add_photo alfa wakacje`. It is stripped and lands in **`pending/`** — under
|
||||
its content hash, beside a `<file>.json` sidecar with the proposed `keys`,
|
||||
`labels`, sender and group — until the operator approves it on `./web.sh`,
|
||||
which is when it is tagged, moved into `media/` and retrievable. `pack_load`
|
||||
never reads `pending/`. The path has its own document, because its metadata
|
||||
handling and privacy invariants are longer than the album format itself:
|
||||
**`DOCS-add-photo.md`**.
|
||||
|
||||
## Filling it in: ./web.sh
|
||||
|
||||
```sh
|
||||
./web.sh --import ~/photos # dedup, strip, import, then tag
|
||||
./web.sh --import ~/photos --dry-run # the report only
|
||||
./web.sh --untagged # only items with no tags yet
|
||||
./web.sh # carry on where you left off
|
||||
```
|
||||
|
||||
`tag.sh` sets up `.venv` on first use, so this works on a fresh clone with
|
||||
nothing installed. Same for `run.sh`, which used to print the two commands to
|
||||
type and exit 1.
|
||||
|
||||
It serves one page on `127.0.0.1:8140`: the item, a labelled button per
|
||||
person, `1`–`0` then `q`–`p` to toggle, `→`/`←` to move. Every toggle rewrites
|
||||
`tags.json` atomically, so closing the tab loses nothing. Names are typed
|
||||
**into the page**, never as arguments — an argument would land in the shell
|
||||
history — and the group is picked from the live `/v1/groups` listing rather
|
||||
than pasted.
|
||||
|
||||
**`--import` is not a copy.** Every file is identified by its magic bytes
|
||||
(not its suffix), stripped through the same `media_strip` path `!add_photo`
|
||||
uses, and checked against `sources.json` twice: the source hash first, then
|
||||
the post-strip hash. What the album refuses is **moved, never deleted**, to
|
||||
`_skipped/<reason>/` beside the source folder, and the printed report has to
|
||||
account for every file seen:
|
||||
|
||||
```
|
||||
174 files seen
|
||||
1 not media -> _skipped/not-media/
|
||||
0 could not be stripped -> _skipped/unstrippable/
|
||||
82 already in pack -> _skipped/already-in-pack/
|
||||
24 duplicate in batch -> _skipped/exact-dup/
|
||||
67 imported
|
||||
---
|
||||
174 accounted for 85 -> 152 in media/
|
||||
```
|
||||
|
||||
Every bucket prints even at zero: a suppressed zero is a silently missing
|
||||
category, and `0 could not be stripped` is how you see the video pipeline is
|
||||
still working. Re-running on the same folder is a no-op — that is what the
|
||||
ledger is for.
|
||||
|
||||
**`--untagged` filters the page, never the server's list.** `/media/{index}`
|
||||
and `/api/tag` both index the full album, so a shorter list on the server
|
||||
would show item *i* of the album while tagging item *i* of the filtered set —
|
||||
every photo tagged with the wrong people, and no error anywhere.
|
||||
|
||||
**Adding a person works mid-session.** The roster used to be write-once: the
|
||||
setup form only appeared on an empty album, `Album.setup` overwrote the file
|
||||
whole, and keys were positional (`p{len+1}`), so inserting somebody renumbered
|
||||
everyone after them and repointed every `tags.json` entry at the wrong person.
|
||||
Now keys come from the highest number in use, the form merges, and there is an
|
||||
"add person" box in the tagger itself — for the photo of somebody new that is
|
||||
already on the screen.
|
||||
|
||||
When you stop it, it prints how many items were never opened and how many
|
||||
photos each command would have. **A command showing `0` is dead on arrival**;
|
||||
that is the number to check before restarting the bot, not after somebody in
|
||||
the group tries it.
|
||||
|
||||
## Running the mechanism without an album
|
||||
|
||||
`scripts/e2e.sh` writes a synthetic pack with `tool/e2e_pack.py` and proves,
|
||||
offline and with no Signal account, that one name posts a photo, two names
|
||||
intersect, an impossible combination gets one line and no attachment, a video
|
||||
leaves as a video, and the same command in another group produces **zero**
|
||||
sends. It also drives `!dodaj` against the real exiftool on media generated at
|
||||
run time (no photo enters this repository), approves the queue through the
|
||||
management page it boots beside the bot, and has `!losowe` answer, no restart.
|
||||
116
DOCS-privacy.md
Normal file
116
DOCS-privacy.md
Normal file
@ -0,0 +1,116 @@
|
||||
# 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 |
|
||||
| every command anybody has run | `command_events` and `upstream_calls` in the database | the database is ignored, and the columns are chosen so there is nothing to leak: a resolved pack key or `+args` instead of the words typed, an exception's *class* instead of its message, a hostname instead of a URL, and a public identifier instead of a title. See `DOCS-analytics.md`. |
|
||||
| what people typed after a `!` the bot did not know | `unknown_commands` in the database | the database is ignored; the log line names neither the word nor the message, and the export the management page writes goes to the state directory, never into this tree |
|
||||
|
||||
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.
|
||||
|
||||
## Why no command commits the words it posts
|
||||
|
||||
Every content command posts what it just fetched. `commons.caption_for` says
|
||||
why that matters: *the text is fetched at run time and posted to the group;
|
||||
none of it is ever written into this repository, which is why it may be free
|
||||
text where `traps.json` may not.*
|
||||
|
||||
`!wiersz` was written the other way first, and it is worth recording because
|
||||
the other way looked entirely reasonable. A pack of several hundred short
|
||||
public-domain Polish poems was harvested, filtered for names, and committed —
|
||||
and `scripts/check_personal_data.sh` refused it. Three lines carried a real
|
||||
group member's given name. After a further filter that discarded 31% of the
|
||||
corpus, two still did.
|
||||
|
||||
The reason is structural rather than a matter of filtering harder. Polish
|
||||
verse capitalises **every** line, so a capital at the start of a line carries
|
||||
no information — and that is exactly where a name in a poem tends to sit, in a
|
||||
dedication (`Basi`), an epigraph credit (`Shakspeare.`) or simply mid-sentence
|
||||
across a line break. A filter strict enough to catch those rejects all verse.
|
||||
|
||||
So the pack holds Wolne Lektury **slugs**, and the poem, its title and its
|
||||
author arrive over the wire when somebody types the command. `Maria
|
||||
Konopnicka` is posted into the group every week and appears nowhere in git.
|
||||
The same shape as `traps.json`'s page ids and `art.json`'s, for the same
|
||||
reason: an identifier cannot carry a name, and free text eventually will.
|
||||
|
||||
The failure mode this avoids is the nasty one — it appears **only locally**.
|
||||
CI has no private data to compare against, so CI stays green while every
|
||||
commit on the maintainer's machine is refused, over data nobody can edit out
|
||||
by hand.
|
||||
|
||||
## 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.
|
||||
74
DOCS-reload.md
Normal file
74
DOCS-reload.md
Normal file
@ -0,0 +1,74 @@
|
||||
# Picking up a change without a restart
|
||||
|
||||
Two kinds of change reach the running bot, and they are handled differently.
|
||||
|
||||
## Data: `POST /_reload`
|
||||
|
||||
Aliases (shipped, and the operator's own `aliases_custom.json`), an album's
|
||||
`people.json`/`tags.json`, and the `.env` and album settings the management
|
||||
UI writes are *data*. The command table is built from that data by one
|
||||
function (`signal_bot/wiring.py`), and `RegistryHolder` can run it again:
|
||||
the new table is built whole, then swapped in with a single assignment. Nothing
|
||||
restarts, the websocket stays up, and no frame is lost or replayed.
|
||||
|
||||
```sh
|
||||
scripts/reload.sh # rebuild the table, report how many commands are live
|
||||
scripts/reload.sh '!nowaosoba' # ...and then type that at the bot in testbot
|
||||
```
|
||||
|
||||
The answer is what the bot is serving **now**:
|
||||
|
||||
| status | code | meaning |
|
||||
|------------|------|--------------------------------------------------------------------|
|
||||
| `reloaded` | 200 | the new table is live; `commands` is its size |
|
||||
| `kept` | 409 | the data no longer builds; the previous table is still answering, and `reason` says what was wrong |
|
||||
| `connecting` | 503 | the bot has not reached Signal yet; there is no table to rebuild |
|
||||
|
||||
A rejected reload is deliberate: the builder reads files people edit by hand
|
||||
and pages write mid-session, and the only safe answer to a file that does not
|
||||
load is the table that was already answering. Only bad *data* is survived
|
||||
(`ConfigError`, `DuplicateTriggerError`); a programming error still surfaces
|
||||
as a 500 and a traceback in the journal, because a bug is not a file.
|
||||
|
||||
Startup is the one time a bad table is fatal: there is nothing to fall back
|
||||
on, and `Restart=always` looping on a `ConfigError` is a clearer signal than a
|
||||
bot that answers nothing.
|
||||
|
||||
One thing the management UI writes needs neither: the per-command switches
|
||||
on its Commands tab (`/_commands`) live in the bot's own database and are read
|
||||
on every attempt, so they are live on the next message without a rebuild.
|
||||
They are also what a reload does *not* reset -- `command_settings` is a
|
||||
permanent table. The aliases field on the same tab is the opposite case: it
|
||||
is data, so the page performs the reload itself after writing the file, and
|
||||
undoes the write if the answer is not `reloaded` (`DOCS-web-ui.md`).
|
||||
|
||||
## Code: `scripts/reload.sh --code`
|
||||
|
||||
A Python change needs the process replaced. The restart is about a second and
|
||||
lossless (signal-cli replays what arrived meanwhile), but it is a deploy step,
|
||||
so it is the flag rather than the default:
|
||||
|
||||
```sh
|
||||
scripts/reload.sh --code '!pomoc'
|
||||
```
|
||||
|
||||
restarts the unit, waits for `/healthz` to report `ok` again, prints the
|
||||
version it came back as, and -- with a command given -- types it in the test
|
||||
group so "restarted" means "answered". There is no hot-reload of Python on
|
||||
purpose: `importlib.reload` leaves every object the old module already
|
||||
created bound to the old classes, which is a bug that looks like a feature
|
||||
until the first `isinstance` check.
|
||||
|
||||
## Proven where
|
||||
|
||||
`scripts/e2e.sh` brackets a `scripts/reload.sh` with the two scenarios in
|
||||
`fake_signal/proofs/`: `!delta` is unknown, a fifth person is tagged into the
|
||||
synthetic pack, the script runs, and the same process posts delta's photo.
|
||||
The route itself is covered in `tests/test_app.py`, the holder in
|
||||
`tests/test_registry_holder.py`, and the album case in `tests/test_bot.py`.
|
||||
The switches get the same bracket: `switch_off.yaml` and `switch_on.yaml`
|
||||
around a `PUT /_commands/cat`, with no reload in between. The operator's
|
||||
aliases get a three-way one: `alias_before.yaml` (`!e2ekot` is nobody), the
|
||||
file is written and `scripts/reload.sh` runs, `alias_after.yaml` (it posts a
|
||||
cat), then a file naming a command that does not exist is refused with
|
||||
`kept` and `alias_kept.yaml` shows the previous table still answering.
|
||||
@ -29,6 +29,15 @@ scripts/selftest.sh testbot '!papież' 3
|
||||
the self-test is always the same author, so a tighter loop just exercises the
|
||||
cooldown instead of the command.
|
||||
|
||||
## What it answers with
|
||||
|
||||
`delivered`, the group id, the text -- and, when the text was a command,
|
||||
`served`: what that one reply cost, split into the bot's own time and the
|
||||
time spent on signal-cli and upstream (`signal_bot/served.py`, and the table
|
||||
in `DOCS-latency.md`). A plain message or an unknown word is delivered and
|
||||
counted but has no `served`, because nothing ran. `tool/latency.py` reads
|
||||
that block repeatedly to hold the bot to its budget.
|
||||
|
||||
## What it will not do
|
||||
|
||||
It **posts for real**, into a real group. Two things keep that from becoming a
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
# Statistics
|
||||
|
||||
This is the **message** history: who said how much, when. The **command**
|
||||
history -- what people asked the bot for, whether it worked and how long it
|
||||
took -- is a different table and a different package; see
|
||||
`DOCS-analytics.md`.
|
||||
|
||||
## What is recorded
|
||||
|
||||
One row per message in `message_events`: sender uuid and the display name they
|
||||
@ -45,6 +50,13 @@ Only people whose display name has been seen are named. Somebody who has never
|
||||
written is counted, not named — the group listing knows them only as a raw
|
||||
uuid or a phone number, and printing either into a chat is a leak.
|
||||
|
||||
Seven numbers are funny to this group and get a marker wherever they appear
|
||||
as a *count* — `69 (nice)`, `2137 🙏`, `1410 ⚔️`, `1916 🇵🇱`, `420 🌿`,
|
||||
`314 🥧`, `67 🤷` — in the nightly report, every `!stats_*` and `!ja`, and the
|
||||
highlights. Percentages, shares and the `(N 📷)` aside stay bare: a 69% share
|
||||
is not a 69, and marking the photo count next to an already-marked message
|
||||
count would be two jokes on one line. The table is `FUNNY` in `stats/text.py`.
|
||||
|
||||
## Outages
|
||||
|
||||
Nothing is ever deleted, and the last day reported is recorded per group.
|
||||
|
||||
250
DOCS-web-ui.md
Normal file
250
DOCS-web-ui.md
Normal file
@ -0,0 +1,250 @@
|
||||
# The management page
|
||||
|
||||
`./web.sh` serves one page on `127.0.0.1:8140`: whether the bot is alive, what
|
||||
it is configured with, and the photo album's tagger. It replaces
|
||||
`tool/tagger.py`, which served the album half of this on the same port.
|
||||
|
||||
```sh
|
||||
./web.sh # the configured pack
|
||||
./web.sh --dir ~/pack --port 8140
|
||||
./web.sh --import ~/photos # dedup, strip and import, then serve
|
||||
./web.sh --import ~/photos --dry-run # the report, changing nothing
|
||||
./web.sh --untagged # only items with no tags yet
|
||||
```
|
||||
|
||||
## Why it is started by hand
|
||||
|
||||
A systemd unit would be more convenient and is the wrong shape. This page can
|
||||
rewrite `.env` — the file that decides which groups the bot answers in and who
|
||||
may add it to one — and a unit would keep that writable on loopback all day
|
||||
for the sake of a bookmark. Started by hand, the window is the few minutes
|
||||
somebody is actually looking at it.
|
||||
|
||||
It is also a **second process**, not more routes on the bot. The bot's own
|
||||
port already serves `POST /_selftest`, which posts for real into a real group;
|
||||
putting a configuration writer beside it would put both behind one mistake.
|
||||
|
||||
## Why loopback is not the whole defence
|
||||
|
||||
Binding to `127.0.0.1` stops the network. It does not stop the browser already
|
||||
running on this machine, and two attacks reach a loopback port from an
|
||||
ordinary web page. `signal_bot/manage/guard.py` closes both, in about forty
|
||||
lines, with nothing to configure and no session to keep:
|
||||
|
||||
- **Cross-origin form posts.** Any site can POST a `<form>` at
|
||||
`127.0.0.1:8140` without reading the reply. A form can only send
|
||||
`application/x-www-form-urlencoded`, `multipart/form-data` or `text/plain`,
|
||||
so every mutating request must declare `application/json` — which a form
|
||||
cannot. Anything else is **415**.
|
||||
- **DNS rebinding.** A hostname the attacker controls is re-pointed at
|
||||
`127.0.0.1` after the page loads, which makes the browser treat this app as
|
||||
same-origin. The rebound request still carries the attacker's hostname, so
|
||||
a `Host` that is not loopback is **421**.
|
||||
|
||||
Neither is authentication, and neither pretends to be. They are the two
|
||||
cheapest things that make "only this machine" mean it.
|
||||
|
||||
## Editing .env
|
||||
|
||||
Three refusals, in this order, and the order is the point.
|
||||
|
||||
1. **A key that may not be written is refused before anything is parsed**, so
|
||||
a read-only key cannot be smuggled past by making the rest of the file
|
||||
invalid. `403`, naming the key.
|
||||
2. **The whole candidate file is validated by the function startup uses** —
|
||||
`load_config(candidate, use_dotenv=False)`. An edit this bot would refuse
|
||||
at boot is refused at the form instead, with the journal's own sentence.
|
||||
`400`.
|
||||
3. **Only then is anything written.** A rejected edit leaves `.env`
|
||||
byte-identical.
|
||||
|
||||
The write itself is line-by-line: only the `KEY=` line of a key that actually
|
||||
changed is replaced, so the paragraphs explaining each setting survive an edit
|
||||
made in a browser. A key the file never had is appended under a marker rather
|
||||
than dropped among comments that explain something else. Temp-then-replace
|
||||
with mode `0600` reapplied before the swap, because this file holds the
|
||||
account's number and is edited from a tab that can be closed mid-save.
|
||||
|
||||
### What is read-only, and why
|
||||
|
||||
| Key | Why the page will not write it |
|
||||
|---|---|
|
||||
| `PHONE_NUMBER` | Identifies the account; the container holds the matching registration. |
|
||||
| `API_BASE_URL` | Repointing it sends every outgoing message and attachment fetch elsewhere at the next restart. Strictly worse than changing the number. |
|
||||
| `DATABASE_PATH` | Repointing it silently orphans the whole statistical history. |
|
||||
| `PHOTO_PACK_DIR` | **Cannot be validated.** `_photo_pack_dirs()` reads `os.environ` directly rather than the mapping it is handed — deliberately, since that is what keeps the test suite away from a real album. |
|
||||
| `BIND_HOST` / `BIND_PORT` | Moving the bot's port from a page that proxies to it. |
|
||||
|
||||
### Group settings need a group listing
|
||||
|
||||
`GROUP_IDS` and `SELFTEST_GROUPS` are editable only while `/api/groups` has
|
||||
actually returned something. `parse_group_ids` checks *syntax*; startup also
|
||||
requires each listed group to be one the bot really joined, judged against a
|
||||
listing that was really read. With the container down, a syntactically valid
|
||||
write can name a group the next startup rejects — a scoping mistake turned
|
||||
into a unit that will not start. The page says so and leaves the rest editable.
|
||||
|
||||
Group ids are **picked, never typed** — `GROUP_IDS` and `SELFTEST_GROUPS`
|
||||
render as a `<select>` filled from `/api/groups`, for the same reason the
|
||||
tagger picked them: an id is 44 characters of base64.
|
||||
|
||||
> `SELFTEST_GROUPS` is editable and worth understanding before you widen it.
|
||||
> It is what keeps `POST /_selftest` fail-closed; every group named in it is a
|
||||
> group the bot can be made to post arbitrary text into.
|
||||
|
||||
## The album, in two halves
|
||||
|
||||
Which half you get is the album's own `configured` flag, as the tagger decided.
|
||||
|
||||
- **No `people.json` yet** → the roster form. One person per line, aliases
|
||||
separated by commas; a line with an `=` is a combination, `+` for "both of
|
||||
them" and `|` for "either of them". Keys are reused for anyone already on
|
||||
the roster and otherwise handed out one past the highest in use — never the
|
||||
count, or a key deleted by hand would be reissued and inherit somebody
|
||||
else's photos (`pack_people.next_key` is the authority). Names are typed
|
||||
into the page, never passed as arguments: an argument lands in shell history.
|
||||
- **Configured** → the tagger: the item, a checkbox per person, saved on the
|
||||
click rather than on a "done" button, and an "add somebody" box for the
|
||||
usual case of noticing a new face in the photo already on screen. Above it,
|
||||
only while something is in it, the `!dodaj` queue: one card per photo with
|
||||
the sender's people and labels pre-ticked, **Approve** or **Reject** — the
|
||||
only door into the album from the chat (`DOCS-add-photo.md`).
|
||||
|
||||
`--untagged` is a *view filter*, never a shorter list — `/api/album/media` and
|
||||
the tag route both index the full album, so filtering server-side would show
|
||||
item *i* of the album while tagging item *i* of the filtered set. Anything
|
||||
seen this session stays navigable even once it has tags, or tagging the photo
|
||||
you are looking at would remove it from the list you are walking.
|
||||
|
||||
## Analytics
|
||||
|
||||
The first tab: what the group actually asks the bot for. Read-only, and the
|
||||
only thing on this page that is worth nothing to an attacker and everything
|
||||
to a maintainer -- so there is deliberately no write route under it.
|
||||
|
||||
`?group=` is optional on every route. Absent means the all-groups roll-up,
|
||||
and it runs the *same SQL* rather than a second query that would drift.
|
||||
|
||||
Every panel carries the window it covers and the three source counts, because
|
||||
two of the three classes of row were reconstructed rather than observed.
|
||||
`DOCS-analytics.md` says what each class knows and what it does not.
|
||||
|
||||
Charts are Nivo, always the fixed-size components: the `Responsive*` ones
|
||||
measure their container, and a jsdom container has none, so the whole test
|
||||
suite would assert against empty divs.
|
||||
|
||||
## Commands
|
||||
|
||||
The second tab is the switchboard: one row per command the bot is serving
|
||||
right now, each with an on/off switch, an optional "off until" moment and a
|
||||
cooldown that overrides the bot's default for that one command. Every row has
|
||||
its own Save, because a switch is thrown for one command at a time.
|
||||
|
||||
No switch goes through `/_reload`. The bot keeps them in its own SQLite
|
||||
(`command_settings`) and reads the row on every attempt, so a switch is live
|
||||
on the next message -- `DOCS-reload.md` is about the *table*, and a switch
|
||||
does not touch it. A command that is off answers `BEEP BOOP !kot WYŁĄCZONE`
|
||||
(with `DO HH:MM` for a timed switch) rather than staying silent, so nobody
|
||||
reports it broken; the refusal is filed under `refused_off`, counted by the
|
||||
Analytics tab with the other refusals, and does not start the cooldown.
|
||||
|
||||
The list is the bot's, fetched live, which is why the page says so when the
|
||||
bot is still connecting: there is no table to list until it has one. `!cisza`
|
||||
and `!odcisz` have no cooldown to override, so the field is disabled with
|
||||
"none" in it: a number typed there would be a lie the bot ignored.
|
||||
|
||||
The one thing in a row that *does* go through `/_reload` is the aliases
|
||||
field: the operator's own comma-separated trigger words for that command.
|
||||
They are data the table is built from, so `PUT /api/aliases/{name}` writes
|
||||
`aliases_custom.json` in the bot's state directory and then asks the bot to
|
||||
rebuild. Write and reload are one step on purpose: whenever the answer is
|
||||
anything but `reloaded` -- a word another command has, a name no command
|
||||
has, a bot still connecting -- the file is put back byte for byte and the
|
||||
bot's own sentence is shown next to the field, so what is on disk is always
|
||||
what the bot serves and a typo cannot poison every later reload. A missing
|
||||
`!` is added, a word with a space is refused before any write, and an empty
|
||||
field removes the command's custom words.
|
||||
|
||||
## Ideas
|
||||
|
||||
The fourth tab lists what the group has asked for: `!zaproponuj` notes with
|
||||
the text people wrote, and every unrecognised `!word` with a count and the
|
||||
messages it came in.
|
||||
|
||||
The **Export** button writes that to the state directory — `$STATE_DIRECTORY`,
|
||||
else `$XDG_DATA_HOME/signal-bot/` — never into this repository. Two reasons,
|
||||
both about a public git tree that has leaked real names once: a file the group
|
||||
can write into would wedge `check_personal_data` the moment somebody's name
|
||||
landed in it, refusing every commit until a human edited it by hand; and the
|
||||
gate learns the names it refuses from the live database, so it catches `anna`
|
||||
and cannot catch `!annna`, which is what an unrecognised-command corpus is full
|
||||
of. So the export carries deliberate notes in full and unrecognised words as
|
||||
counts only. Copy what is worth keeping into the tree yourself.
|
||||
|
||||
## Routes
|
||||
|
||||
Everything under `/api`. The page itself is served from `web/dist` at every
|
||||
other path, so a reload on a sub-view works.
|
||||
|
||||
| Method | Path | What it does |
|
||||
|---|---|---|
|
||||
| GET | `/api/status` | Proxies the bot's `/healthz`. Unreachable is a state, never an error. |
|
||||
| GET | `/api/groups` | The live listing, or `[]` when the container is down. |
|
||||
| GET | `/api/config` | The resolved config. The number is cut to its last four digits. |
|
||||
| GET / PUT | `/api/env` | Read and write `.env`, per the rules above. |
|
||||
| POST | `/api/selftest` | Proxied to the bot verbatim, including its 403. |
|
||||
| POST | `/api/restart-container` | `docker restart`, through `signal_bot/external.py`. |
|
||||
| GET | `/api/album/state` | The album, the roster and the groups to pick from. |
|
||||
| POST | `/api/album/tag` | Record who is in one file. |
|
||||
| GET | `/api/album/summary` | What each command would answer with. |
|
||||
| POST | `/api/album/setup` | Write the roster, then prove the bot could load it. |
|
||||
| POST | `/api/album/person` | Add one person mid-session. |
|
||||
| GET | `/api/album/media/{index}` | One item, **by position**. |
|
||||
| GET | `/api/album/pending`, `…/pending/{name}/media` | The `!dodaj` queue, oldest first and naming no sender; one queued file, by the name the listing gave. |
|
||||
| POST | `/api/album/pending/{name}/{approve,reject}` | Into the album with the ticked people and labels, then the bot reloads (a refused reload is reported, not rolled back); or deleted. |
|
||||
| GET | `/api/suggestions` | Filed ideas and unrecognised-word tallies, bodies included. |
|
||||
| GET | `/api/commands` | Proxies the bot's `/_commands`: the live table with each command's switches. |
|
||||
| PUT | `/api/commands/{name}` | One command's switches, forwarded to the bot; its 404/503 come back unwrapped. |
|
||||
| GET | `/api/aliases` | The operator's custom aliases, by command name; 409 if the file cannot be read. |
|
||||
| PUT | `/api/aliases/{name}` | Writes the file and reloads the bot; anything but `reloaded` restores the file and passes the bot's reason on. |
|
||||
| GET | `/api/analytics/groups` | Every group with a command history, for the picker. |
|
||||
| GET | `/api/analytics/{overview,time,health,demand,behaviour,content}` | One per panel, all read-only, all taking an optional `?group=`. |
|
||||
| POST | `/api/suggestions/export` | Write those to a file **outside the repository**. |
|
||||
|
||||
That last one is not a style choice. A filename supplied by a client is a
|
||||
path, and this directory holds private photographs; the route takes an `int`,
|
||||
so FastAPI refuses a name with **422** before any handler code runs.
|
||||
|
||||
## Building it
|
||||
|
||||
`dist/` is a build output and this repository does not commit those.
|
||||
`scripts/install_service.sh` builds it, and verifies the artefact rather than
|
||||
trusting the exit code. Until it has been built the page answers **503** with
|
||||
the command to run, because `StaticFiles` raises at construction on a missing
|
||||
directory — mounting it unconditionally would take the app down on a fresh
|
||||
clone, and in CI, where pytest never runs `pnpm build`.
|
||||
|
||||
```sh
|
||||
pnpm --dir web install --frozen-lockfile
|
||||
pnpm --dir web build
|
||||
pnpm --dir web lint # tsc --noEmit + eslint strictTypeChecked
|
||||
pnpm --dir web coverage # vitest, 100% with no exclude list
|
||||
```
|
||||
|
||||
React 19, Vite 8, TypeScript **6.0.3**, ESLint 10 with `strictTypeChecked` +
|
||||
`stylisticTypeChecked` + unicorn + sonarjs + perfectionist, Vitest 4 at 100%
|
||||
on all four metrics. TypeScript stays on 6.0.3 because
|
||||
`typescript-eslint@8.68.0` declares `typescript: ">=4.8.4 <6.1.0"`; the pin is
|
||||
fleet-wide in `~/utils/dependency-freshness.allowlist.yaml` and clears itself
|
||||
when the linter catches up. The compiler waits for the linter.
|
||||
|
||||
`web/tsconfig.json` is strict JSON, not JSONC: the `check-json` hook is
|
||||
generic and a commentable tsconfig is a convention, not a requirement. What
|
||||
the comments used to say: `lib` is `ESNext` while `target` stays `ES2023`, so
|
||||
iterator helpers are declared without being emitted for; and every strictness
|
||||
flag is on, `noUncheckedIndexedAccess` and `exactOptionalPropertyTypes` included.
|
||||
|
||||
There is **no coverage exclude list**, including for `src/main.tsx`. A
|
||||
coverage omit is a suppression, and "the DOM bootstrap is awkward to assert"
|
||||
is the same sentence as "this code is untested" — so the bootstrap is a shim
|
||||
thin enough to test in jsdom instead.
|
||||
48
README.md
48
README.md
@ -1,7 +1,7 @@
|
||||
# signal-bot
|
||||
|
||||
A Signal group bot: `!cat` / `!dog` / `!gekon` image commands, `!papież`, and
|
||||
group message statistics.
|
||||
A Signal group bot: `!cat` / `!dog` / `!gekon` / `!pułapka` / `!sztuka` /
|
||||
`!wiersz` / `!papież`, and group statistics. 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
|
||||
@ -56,7 +56,7 @@ expect:
|
||||
- { kind: image, to: reply }
|
||||
```
|
||||
|
||||
Give each scenario its own `uuid`: one bot process serves them all and the
|
||||
It enforces a 40-second budget on itself. Give each scenario its own `uuid`: one bot process serves them all and the
|
||||
cooldown lives in SQLite, so a shared actor leaks state between files.
|
||||
|
||||
## Run it for real
|
||||
@ -170,18 +170,12 @@ its own number and no access to your chats at all.
|
||||
|
||||
### Why not Shelter / a second Signal app on the phone
|
||||
|
||||
Two independent reasons, both verified rather than assumed:
|
||||
|
||||
1. **A second app instance is not a second account.** Every Signal account
|
||||
needs its own phone number, so Shelter never removed the actual blocker.
|
||||
Signal's phone-number-free "Signal Login" is announced but not shipped.
|
||||
2. **It cannot be installed anyway** on a phone with a Device Owner. Setting a
|
||||
Device Owner makes Android enable `no_add_managed_profile`,
|
||||
`no_add_private_profile` and `no_add_clone_profile` on every user — killing
|
||||
Shelter, Island, Private Space and app cloning alike. Verified on-device:
|
||||
`pm create-user --profileOf 0 --managed` returns
|
||||
`Cannot add user: no_add_managed_profile is enabled`. This is the Android 11
|
||||
COMP deprecation and is unchanged through Android 16.
|
||||
Two reasons, both verified rather than assumed. **A second app instance is not
|
||||
a second account** — every Signal account needs its own phone number, so
|
||||
Shelter never removed the actual blocker. And **it cannot be installed anyway**
|
||||
on a phone with a Device Owner: Android then enables `no_add_managed_profile`
|
||||
and its two siblings on every user, killing Shelter, Island, Private Space and
|
||||
cloning alike. Unchanged through Android 16.
|
||||
|
||||
## When it cannot hear, and who may add it
|
||||
|
||||
@ -193,12 +187,12 @@ container, and `TRUSTED_INVITERS` decides who may add this bot to a group:
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Aliases |
|
||||
|---|---|
|
||||
| cat | `!kot` `!koty` `!kots` `!cat` `!cats` `!meow` `!miau` + styled variants |
|
||||
| dog | `!pies` `!psy` `!dog` `!dogs` `!woof` `!szczek` + styled variants |
|
||||
| gecko | `!gekon` `!gekony` `!geko` `!gecko` `!geckon` `!geckos` |
|
||||
| pope | `!papież` `!papiez` `!papa` `!pope` `!popes` `!pontifex` + styled variants |
|
||||
`!pomoc` (or `!help`) prints every command **in the group**, in Polish, one
|
||||
line of description each. It is built from the registry at run time and
|
||||
`CommandRegistry.register` takes that description as a required argument, so a
|
||||
command cannot be added without appearing there. Every trigger that resolves,
|
||||
what the bot now answers to a `!word` nothing claims, and why the printed
|
||||
spelling is not one of them: [DOCS-commands.md](DOCS-commands.md).
|
||||
|
||||
Aliases live in `signal_bot/aliases.json`, not in source. Each command is rate-limited per
|
||||
user, per command — the original bot shared one 10-second window across everybody.
|
||||
@ -231,10 +225,16 @@ at 0, up to three highlights. Sundays close a week, month ends a month. Ask it
|
||||
things: `!staty` `!wczoraj` `!tydzień` `!miesiąc` `!rok` `!rekordy` `!korona`
|
||||
`!dni` `!ja`. Full behaviour: `DOCS-statistics.md`.
|
||||
|
||||
## Managing it
|
||||
|
||||
`./web.sh` serves one page on `127.0.0.1:8140`: health, the configuration (editable) and the
|
||||
album's tagger. Started by hand and stopped again, because it can rewrite `.env`: [DOCS-web-ui.md](DOCS-web-ui.md).
|
||||
A tagged person goes live with `scripts/reload.sh`, no restart; code with `--code`: [DOCS-reload.md](DOCS-reload.md).
|
||||
|
||||
## Develop
|
||||
|
||||
```sh
|
||||
pytest # 377 tests, 100% branch coverage, no network
|
||||
pytest # 100% branch coverage, no network
|
||||
pre-commit run --all-files # ruff ALL + mypy strict + 250-line cap + gates
|
||||
./scripts/e2e.sh # the real bot against the fake server
|
||||
```
|
||||
@ -242,8 +242,8 @@ pre-commit run --all-files # ruff ALL + mypy strict + 250-line cap + gates
|
||||
No `# noqa`, no `# type: ignore`, no coverage omits. Never bump `SCHEMA_VERSION` to add a table —
|
||||
read `signal_bot/schema.py` first; a bump DROPs every table and throws the live tallies away, while
|
||||
`CREATE TABLE IF NOT EXISTS` is additive and safe. Wire format: [DOCS-signal-envelopes.md](DOCS-signal-envelopes.md).
|
||||
Releases are named after the popes, in order, and a bump publishes to the group: [DOCS-changelog.md](DOCS-changelog.md). Latency: [DOCS-latency.md](DOCS-latency.md).
|
||||
Signal never delivers an account its own sends: `scripts/selftest.sh testbot '!papież' 3` drives the live dispatcher instead, [DOCS-selftest.md](DOCS-selftest.md).
|
||||
Releases are named after the popes, in order and in Latin — 0.1.4 is «Eleutherius» — and a bump publishes to the group as the pope's portrait, name and article: [DOCS-changelog.md](DOCS-changelog.md). Latency: [DOCS-latency.md](DOCS-latency.md).
|
||||
Signal never delivers an account its own sends: `scripts/selftest.sh testbot '!papież' 3` drives the live dispatcher instead, [DOCS-selftest.md](DOCS-selftest.md). The page has its own gates: `pnpm --dir web lint` and `pnpm --dir web coverage`, both at 100% with no excludes.
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
9
fake_signal/approved/dodaj_again_is_a_duplicate.yaml
Normal file
9
fake_signal/approved/dodaj_again_is_a_duplicate.yaml
Normal file
@ -0,0 +1,9 @@
|
||||
# Content-addressed: the same bytes sent a second time, after approval, are
|
||||
# recognised as already in the album -- the queue is not a way to store a
|
||||
# photo twice. tool/e2e_media.py re-seeds the fake's store first, because
|
||||
# the bot deleted the container's copy the first time.
|
||||
name: "the same photo sent again after approval is already in the album"
|
||||
inject:
|
||||
- { text: "!dodaj wakacje", uuid: uuid-approved-again, name: ApprovedAgain, attachment: att-custom }
|
||||
expect:
|
||||
- { kind: text, to: reply, contains: "JUŻ JEST W ALBUMIE" }
|
||||
@ -0,0 +1,9 @@
|
||||
# The second half of scenarios/pack_add_custom.yaml. Replayed only after
|
||||
# scripts/e2e.sh has approved the queue through the management page and the
|
||||
# bot has rebuilt its table: the word that was NIE MAM TAKIEGO ZDJĘCIA a
|
||||
# moment ago now opens a photo, with no restart in between.
|
||||
name: "!losowe finds a label the moment it is approved"
|
||||
inject:
|
||||
- { text: "!losowe wakacje", uuid: uuid-approved-random, name: ApprovedRandom }
|
||||
expect:
|
||||
- { kind: image, to: reply }
|
||||
@ -10,13 +10,41 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from fake_signal.scenario import Scenario, check, load_scenario
|
||||
|
||||
SETTLE_SECONDS = 0.35
|
||||
# Measured, not guessed. A surplus send is *always* detected whatever this is,
|
||||
# because collect() re-reads after the expected count arrives -- injecting a
|
||||
# deliberate double-send proved that at every value tried, six runs, all red.
|
||||
# What this buys is **attribution**: whether the surplus is blamed on the
|
||||
# scenario that caused it or on the innocent one that follows. Measured with a
|
||||
# surplus delayed by hand:
|
||||
#
|
||||
# surplus at +0.05s / +0.25s blamed correctly at 0.35 and at 0.15
|
||||
# surplus at +0.50s blamed correctly at 0.35, wrongly at 0.15
|
||||
#
|
||||
# 0.15 halves the replay (35s -> 19s, against a 40s budget) and only
|
||||
# misattributes a surplus arriving half a second late, which took an explicit
|
||||
# asyncio.sleep to construct -- a real double-send comes from the same handler
|
||||
# microseconds apart. Raise it with --settle if a slow machine starts blaming
|
||||
# the wrong scenario; that symptom is what this number is for.
|
||||
SETTLE_SECONDS = 0.15
|
||||
# How long a scenario may wait for the sends it expects before giving up.
|
||||
#
|
||||
# A single fixed settle cannot cover both a reply that is a dictionary lookup
|
||||
# and one that fetches an image and shells out to exiftool, and the failure
|
||||
# that produced was not "slow" but "wrong": the late send landed after the
|
||||
# NEXT scenario's reset and was counted against that one instead. So two
|
||||
# different scenarios failed per run, never the same two twice -- one with
|
||||
# "captured 0" and one with "captured 2". Waiting for the count is a
|
||||
# guarantee; a bigger number is a guess that fails again on a slower machine.
|
||||
PATIENCE_SECONDS = 15.0
|
||||
POLL_SECONDS = 0.05
|
||||
|
||||
|
||||
def _emit(line: str) -> None:
|
||||
@ -51,8 +79,51 @@ def resolve_groups(args: argparse.Namespace) -> dict[str, str]:
|
||||
return groups
|
||||
|
||||
|
||||
async def captured(client: httpx.AsyncClient) -> list[dict[str, Any]]:
|
||||
"""Every send the fake server has recorded since the last reset."""
|
||||
response = await client.get("/_control/sent")
|
||||
response.raise_for_status()
|
||||
sends: list[dict[str, Any]] = response.json()
|
||||
return sends
|
||||
|
||||
|
||||
async def collect(
|
||||
client: httpx.AsyncClient, want: int, *, settle: float, patience: float
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Wait for the sends a scenario expects, then for a beat of quiet.
|
||||
|
||||
The quiet beat is not politeness: returning the moment the expected count
|
||||
arrives would make "expected 1, captured 2" unobservable, because the
|
||||
surplus send is exactly the one that has not landed yet. A scenario that
|
||||
expects nothing skips the poll entirely and waits out the settle, which
|
||||
is the only way to assert that nothing happens.
|
||||
|
||||
Args:
|
||||
client: A client pointed at the fake server's control plane.
|
||||
want: How many sends the scenario expects.
|
||||
settle: How long "quiet" is.
|
||||
patience: How long to wait for ``want`` sends before giving up.
|
||||
|
||||
Returns:
|
||||
Every send recorded, which may be more or fewer than expected -- the
|
||||
comparison is somebody else's job.
|
||||
"""
|
||||
deadline = time.monotonic() + patience
|
||||
sends = await captured(client)
|
||||
while len(sends) < want and time.monotonic() < deadline:
|
||||
await asyncio.sleep(POLL_SECONDS)
|
||||
sends = await captured(client)
|
||||
await asyncio.sleep(settle)
|
||||
return await captured(client)
|
||||
|
||||
|
||||
async def run_scenario(
|
||||
base_url: str, scenario: Scenario, groups: dict[str, str], *, settle: float
|
||||
base_url: str,
|
||||
scenario: Scenario,
|
||||
groups: dict[str, str],
|
||||
*,
|
||||
settle: float,
|
||||
patience: float = PATIENCE_SECONDS,
|
||||
) -> list[str]:
|
||||
"""Inject every frame, wait for the bot to react, then compare."""
|
||||
async with httpx.AsyncClient(base_url=base_url, timeout=10.0) as client:
|
||||
@ -67,9 +138,8 @@ async def run_scenario(
|
||||
for injection in scenario.inject:
|
||||
await client.post("/_control/inject", json={"raw": injection.to_frame(groups)})
|
||||
await asyncio.sleep(settle)
|
||||
response = await client.get("/_control/sent")
|
||||
response.raise_for_status()
|
||||
return check(scenario, response.json(), groups)
|
||||
sent = await collect(client, len(scenario.expect), settle=settle, patience=patience)
|
||||
return check(scenario, sent, groups)
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
@ -79,6 +149,9 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser.add_argument("--base-url", default="http://127.0.0.1:8099")
|
||||
parser.add_argument("--group", action="append", default=[], metavar="NAME=ID")
|
||||
parser.add_argument("--settle", type=float, default=SETTLE_SECONDS)
|
||||
# Overridable so the tests can drive the give-up path without waiting it
|
||||
# out, and so a slow machine can be given longer without editing source.
|
||||
parser.add_argument("--patience", type=float, default=PATIENCE_SECONDS)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
@ -88,7 +161,9 @@ async def _main_async(args: argparse.Namespace) -> int:
|
||||
failed = 0
|
||||
for path in args.scenarios:
|
||||
scenario = load_scenario(path)
|
||||
failures = await run_scenario(args.base_url, scenario, groups, settle=args.settle)
|
||||
failures = await run_scenario(
|
||||
args.base_url, scenario, groups, settle=args.settle, patience=args.patience
|
||||
)
|
||||
if failures:
|
||||
failed += 1
|
||||
_emit(f"FAIL {scenario.name}")
|
||||
|
||||
7
fake_signal/proofs/alias_after.yaml
Normal file
7
fake_signal/proofs/alias_after.yaml
Normal file
@ -0,0 +1,7 @@
|
||||
# After the reload, the operator's spelling is !kot: the same process that
|
||||
# just said "Nie znam" posts a cat.
|
||||
name: "!e2ekot posts a cat once the alias file is live"
|
||||
inject:
|
||||
- { text: "!e2ekot", uuid: uuid-alias-after, name: AliasAfter }
|
||||
expect:
|
||||
- { kind: image, to: reply }
|
||||
8
fake_signal/proofs/alias_before.yaml
Normal file
8
fake_signal/proofs/alias_before.yaml
Normal file
@ -0,0 +1,8 @@
|
||||
# Not in scenarios/: the pair brackets the alias file that
|
||||
# scripts/e2e_proofs.sh writes and reloads in between. Before it, !e2ekot
|
||||
# is a word nobody registered.
|
||||
name: "!e2ekot is unknown before the alias file exists"
|
||||
inject:
|
||||
- { text: "!e2ekot", uuid: uuid-alias, name: Alias }
|
||||
expect:
|
||||
- { kind: text, to: reply, contains: "Nie znam !e2ekot" }
|
||||
8
fake_signal/proofs/alias_kept.yaml
Normal file
8
fake_signal/proofs/alias_kept.yaml
Normal file
@ -0,0 +1,8 @@
|
||||
# Replayed after a bad alias file is refused: the table that answered
|
||||
# alias_after.yaml is the one still serving. Its own actor, because the
|
||||
# cooldown lives in SQLite and alias_after's uuid is inside its window.
|
||||
name: "!e2ekot still posts a cat while a bad alias file is refused"
|
||||
inject:
|
||||
- { text: "!e2ekot", uuid: uuid-alias-kept, name: AliasKept }
|
||||
expect:
|
||||
- { kind: image, to: reply }
|
||||
7
fake_signal/proofs/reload_after.yaml
Normal file
7
fake_signal/proofs/reload_after.yaml
Normal file
@ -0,0 +1,7 @@
|
||||
# The other half of the bracket: the same process, one POST /_reload later,
|
||||
# and the person tagged into the album meanwhile answers with a photo.
|
||||
name: "!delta posts a photo after the reload"
|
||||
inject:
|
||||
- { text: "!delta", uuid: uuid-reload-after, name: ReloadAfter }
|
||||
expect:
|
||||
- { kind: image, to: reply }
|
||||
8
fake_signal/proofs/reload_before.yaml
Normal file
8
fake_signal/proofs/reload_before.yaml
Normal file
@ -0,0 +1,8 @@
|
||||
# Not in scenarios/: that glob replays against the pack as first written,
|
||||
# and this pair brackets the POST /_reload that scripts/e2e_proofs.sh makes
|
||||
# in between. Before it, !delta is a word the bot has never heard.
|
||||
name: "!delta is unknown before the reload"
|
||||
inject:
|
||||
- { text: "!delta", uuid: uuid-reload-before, name: ReloadBefore }
|
||||
expect:
|
||||
- { kind: text, to: reply, contains: "Nie znam !delta" }
|
||||
9
fake_signal/proofs/switch_off.yaml
Normal file
9
fake_signal/proofs/switch_off.yaml
Normal file
@ -0,0 +1,9 @@
|
||||
# Not in scenarios/: this pair brackets the PUT /_commands/cat that
|
||||
# scripts/e2e_proofs.sh makes in between, with no reload and no restart.
|
||||
# After the switch is thrown, the same process that has been posting cats
|
||||
# all run refuses in so many words.
|
||||
name: "!cat switched off answers WYŁĄCZONE instead of a photo"
|
||||
inject:
|
||||
- { text: "!cat", uuid: uuid-switch, name: Switch }
|
||||
expect:
|
||||
- { kind: text, to: reply, contains: "!kot WYŁĄCZONE" }
|
||||
9
fake_signal/proofs/switch_on.yaml
Normal file
9
fake_signal/proofs/switch_on.yaml
Normal file
@ -0,0 +1,9 @@
|
||||
# The second half of the pair: the switch is back on, and the *same* actor
|
||||
# gets the photo. Same uuid on purpose -- a refusal must not start the
|
||||
# cooldown, or switching a command back on would leave everyone who tried
|
||||
# it meanwhile waiting out a window they never used.
|
||||
name: "!cat switched back on posts a photo to the actor it just refused"
|
||||
inject:
|
||||
- { text: "!cat", uuid: uuid-switch, name: Switch }
|
||||
expect:
|
||||
- { kind: image, to: reply }
|
||||
@ -3,13 +3,16 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, Final
|
||||
|
||||
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
|
||||
from fake_signal.state import PIXEL_PNG, State
|
||||
|
||||
# Read off the real container's /swagger/doc.json, not remembered.
|
||||
REACTION_FIELDS: Final = frozenset({"reaction", "recipient", "target_author", "timestamp"})
|
||||
|
||||
|
||||
def _state(request: Request) -> State:
|
||||
"""Return the State attached to the running app."""
|
||||
@ -57,21 +60,49 @@ def build_api_router() -> APIRouter:
|
||||
_state(request).record_send(payload)
|
||||
return JSONResponse({"timestamp": 1719177917717}, status_code=201)
|
||||
|
||||
@router.post("/v1/reactions/{number}")
|
||||
async def react(request: Request, number: str, payload: dict[str, Any]) -> Response:
|
||||
"""The container's api.SendReactionRequest: all four fields required."""
|
||||
if REACTION_FIELDS - payload.keys():
|
||||
return JSONResponse({"error": "missing reaction fields"}, status_code=400)
|
||||
_state(request).record_reaction(number, payload)
|
||||
return Response(status_code=204)
|
||||
|
||||
_add_attachment_routes(router)
|
||||
_add_receive_route(router)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def _add_attachment_routes(router: APIRouter) -> None:
|
||||
"""Attach the attachment store's three endpoints.
|
||||
|
||||
Split out so the router builder stays under the complexity cap, exactly
|
||||
as _add_receive_route already is.
|
||||
"""
|
||||
|
||||
@router.get("/v1/attachments")
|
||||
async def list_attachments(request: Request) -> list[str]:
|
||||
return list(_state(request).attachments)
|
||||
|
||||
@router.get("/v1/attachments/{attachment_id}")
|
||||
async def fetch_attachment(request: Request, attachment_id: str) -> Response:
|
||||
"""Hand over one stored attachment, the way the container does."""
|
||||
payload = _state(request).stored.get(attachment_id)
|
||||
if payload is None:
|
||||
return JSONResponse({"error": "no such attachment"}, status_code=404)
|
||||
return Response(content=payload, media_type="application/octet-stream")
|
||||
|
||||
@router.delete("/v1/attachments/{attachment_id}")
|
||||
async def delete_attachment(request: Request, attachment_id: str) -> Response:
|
||||
attachments = _state(request).attachments
|
||||
if attachment_id in attachments:
|
||||
attachments.remove(attachment_id)
|
||||
state = _state(request)
|
||||
if attachment_id in state.attachments:
|
||||
state.attachments.remove(attachment_id)
|
||||
# The bytes go too, which is what makes "the second copy is gone"
|
||||
# something a scenario can assert rather than assume.
|
||||
state.stored.pop(attachment_id, None)
|
||||
return Response(status_code=204)
|
||||
|
||||
_add_receive_route(router)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def _add_receive_route(router: APIRouter) -> None:
|
||||
"""Attach the WebSocket receive endpoint.
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
@ -79,4 +80,32 @@ def build_control_router() -> APIRouter:
|
||||
async def ready(request: Request) -> dict[str, int]:
|
||||
return {"sockets": len(_state(request).sockets)}
|
||||
|
||||
_add_attachment_routes(router)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def _add_attachment_routes(router: APIRouter) -> None:
|
||||
"""Attach the inbound-attachment control endpoints.
|
||||
|
||||
Split out to keep the builder under the complexity cap.
|
||||
"""
|
||||
|
||||
@router.post("/attachment")
|
||||
async def put_attachment(request: Request, body: dict[str, Any]) -> dict[str, int]:
|
||||
"""Put one inbound attachment into the container's store.
|
||||
|
||||
base64 rather than raw bytes so a scenario's fixture can be generated
|
||||
at run time by scripts/e2e.sh -- no binary file enters this repo.
|
||||
"""
|
||||
state = _state(request)
|
||||
attachment_id = str(body["id"])
|
||||
state.stored[attachment_id] = base64.b64decode(str(body["base64"]))
|
||||
if attachment_id not in state.attachments:
|
||||
state.attachments.append(attachment_id)
|
||||
return {"stored": len(state.stored)}
|
||||
|
||||
@router.get("/attachments")
|
||||
async def stored_attachments(request: Request) -> list[str]:
|
||||
"""Which attachment ids still have bytes behind them."""
|
||||
return sorted(_state(request).stored)
|
||||
|
||||
@ -44,6 +44,23 @@ def build_fixture_router() -> APIRouter:
|
||||
}
|
||||
return {"query": {"pages": [page]}}
|
||||
|
||||
# Wolne Lektury serves the poem as plain text in a fixed shape: author,
|
||||
# blank, title, the poem, then a footer behind a lone rule. The slug is
|
||||
# echoed into the title so a scenario proves the bot asked for the poem
|
||||
# it drew rather than a hard-coded one. The public-domain sentence is
|
||||
# here because poem_command refuses anything without it.
|
||||
@router.get("/poem-text")
|
||||
async def poem_text(request: Request) -> Response:
|
||||
slug = request.query_params.get("slug", "")
|
||||
body = (
|
||||
f"Adam Testowy\n\n{slug}\n\n\n"
|
||||
"Pierwsza linijka wiersza,\n"
|
||||
"druga linijka wiersza.\n\n"
|
||||
"-----\n"
|
||||
"Ten utwór jest w domenie publicznej.\n"
|
||||
)
|
||||
return Response(content=body, media_type="text/plain; charset=utf-8")
|
||||
|
||||
@router.get("/image.png")
|
||||
async def image() -> Response:
|
||||
return Response(content=PIXEL_PNG, media_type="image/png")
|
||||
|
||||
@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
import yaml
|
||||
|
||||
@ -28,6 +28,12 @@ class Injection:
|
||||
sticker: bool = False
|
||||
reaction: bool = False
|
||||
sync: bool = False
|
||||
# The id of an attachment the scenario put in the store beforehand, via
|
||||
# POST /_control/attachment. Named rather than inlined: the bytes are
|
||||
# generated at run time by scripts/e2e.sh, because a fixture photo in
|
||||
# this repository is exactly what the binary gate exists to prevent.
|
||||
attachment: str | None = None
|
||||
attachment_type: str = "image/jpeg"
|
||||
|
||||
def to_frame(self, groups: dict[str, str]) -> str:
|
||||
"""Render this injection as a raw signal-cli envelope."""
|
||||
@ -39,6 +45,11 @@ class Injection:
|
||||
msg = f"unknown group alias {self.group!r}; known: {sorted(groups)}"
|
||||
raise ScenarioError(msg)
|
||||
payload["groupInfo"] = {"groupId": groups[self.group], "type": "DELIVER"}
|
||||
if self.attachment is not None:
|
||||
# Top level, never inside "quote": that is where a *reply* carries
|
||||
# the quoted message's media, and !add_photo must never take a
|
||||
# photo somebody else posted.
|
||||
payload["attachments"] = [{"contentType": self.attachment_type, "id": self.attachment}]
|
||||
if self.sticker:
|
||||
payload["sticker"] = {"packId": "abc", "stickerId": 6}
|
||||
if self.reaction:
|
||||
@ -149,16 +160,35 @@ def load_scenario(path: Path) -> Scenario:
|
||||
)
|
||||
|
||||
|
||||
# How signal-cli-rest-api spells an attachment whose type the bot stated.
|
||||
# Anything else is bare base64, which is what an ordinary photo travels as.
|
||||
_VIDEO_PREFIX: Final = "data:video/"
|
||||
|
||||
|
||||
def describe(payload: dict[str, Any]) -> tuple[str, str, str]:
|
||||
"""Reduce a captured send to (kind, recipient, body) for comparison."""
|
||||
kind = "image" if payload.get("base64_attachments") else "text"
|
||||
"""Reduce a captured send to (kind, recipient, body) for comparison.
|
||||
|
||||
"video" is its own kind so a scenario can assert that a clip went out as
|
||||
a clip: told it is a photo, Signal renders an unplayable file, and every
|
||||
other check in this harness would still pass.
|
||||
"""
|
||||
if "reaction" in payload:
|
||||
return "reaction", str(payload.get("recipient", "")), str(payload["reaction"])
|
||||
attachments = payload.get("base64_attachments") or []
|
||||
kind = "text"
|
||||
if attachments:
|
||||
kind = "video" if str(attachments[0]).startswith(_VIDEO_PREFIX) else "image"
|
||||
recipients = payload.get("recipients") or [""]
|
||||
return kind, str(recipients[0]), str(payload.get("message", ""))
|
||||
|
||||
|
||||
def quotes(payload: dict[str, Any]) -> bool:
|
||||
"""True when a captured send points at a specific message."""
|
||||
return bool(payload.get("quote_timestamp"))
|
||||
"""True when a captured send points at a specific message.
|
||||
|
||||
A reply points with ``quote_timestamp``; a reaction has nothing else to
|
||||
do, and names its target as ``timestamp``.
|
||||
"""
|
||||
return bool(payload.get("quote_timestamp") or payload.get("timestamp"))
|
||||
|
||||
|
||||
def check(scenario: Scenario, sent: list[dict[str, Any]], groups: dict[str, str]) -> list[str]:
|
||||
|
||||
17
fake_signal/scenarios/analytics_command.yaml
Normal file
17
fake_signal/scenarios/analytics_command.yaml
Normal file
@ -0,0 +1,17 @@
|
||||
# Fresh uuid: one bot process serves every scenario and the cooldown lives in
|
||||
# SQLite, so a shared actor would leak a refusal into this file.
|
||||
#
|
||||
# Two frames on purpose. The first is a command that leaves a row behind; the
|
||||
# second asks about it. That is what makes this an end-to-end proof of the
|
||||
# recording rather than of the rendering: a broken recorder answers the
|
||||
# second frame with "nobody has used anything yet".
|
||||
name: "!analityka answers with the history the frame before it just made"
|
||||
inject:
|
||||
- { text: "!gekon", uuid: uuid-nadia, name: Nadia }
|
||||
- { text: "!analityka", uuid: uuid-nadia, name: Nadia }
|
||||
# One expectation per send. "!gecko" rather than the heading, because the
|
||||
# heading proves only that the command rendered -- naming the command from
|
||||
# the frame before it proves the dispatcher actually recorded that frame.
|
||||
expect:
|
||||
- { kind: image, to: reply }
|
||||
- { kind: text, to: reply, contains: "!gecko" }
|
||||
5
fake_signal/scenarios/art_command.yaml
Normal file
5
fake_signal/scenarios/art_command.yaml
Normal file
@ -0,0 +1,5 @@
|
||||
name: "!sztuka posts a work of art, captioned with what it is"
|
||||
inject:
|
||||
- { text: "!sztuka", uuid: uuid-gallery, name: Gallery }
|
||||
expect:
|
||||
- { kind: image, to: reply }
|
||||
9
fake_signal/scenarios/chatter_is_silent.yaml
Normal file
9
fake_signal/scenarios/chatter_is_silent.yaml
Normal file
@ -0,0 +1,9 @@
|
||||
# Ordinary conversation is not a command attempt and never was. Kept as its
|
||||
# own scenario when unknown_command.yaml stopped expecting silence, because
|
||||
# this half of the old assertion is the half still worth pinning.
|
||||
name: "ordinary chatter produces nothing"
|
||||
inject:
|
||||
- { text: "just chatting", uuid: uuid-alice, name: Alice }
|
||||
- { text: "hej! super", uuid: uuid-alice, name: Alice }
|
||||
- { text: "!!", uuid: uuid-alice, name: Alice }
|
||||
expect: []
|
||||
8
fake_signal/scenarios/did_you_mean.yaml
Normal file
8
fake_signal/scenarios/did_you_mean.yaml
Normal file
@ -0,0 +1,8 @@
|
||||
# The terminal's trick. "!gekno" is one transposition from "!gekon", which is
|
||||
# why the distance is optimal string alignment rather than difflib's ratio --
|
||||
# a swap costs one edit there and shreds the matching blocks difflib counts.
|
||||
name: "a near miss is corrected"
|
||||
inject:
|
||||
- { text: "!gekno", uuid: uuid-alice, name: Alice }
|
||||
expect:
|
||||
- { kind: text, to: reply, contains: "!gekon" }
|
||||
7
fake_signal/scenarios/food_today.yaml
Normal file
7
fake_signal/scenarios/food_today.yaml
Normal file
@ -0,0 +1,7 @@
|
||||
# The operator's food log for today, read from the file the run wrote
|
||||
# rather than the real one: the meal is invented, the day is real.
|
||||
name: "!co_jadł lists what the food log holds for today"
|
||||
inject:
|
||||
- { text: "!co_jadł", uuid: uuid-alice, name: Alice }
|
||||
expect:
|
||||
- { kind: text, to: reply, contains: "carbonara — 350 g, 550 kcal (manual)" }
|
||||
5
fake_signal/scenarios/good_bot.yaml
Normal file
5
fake_signal/scenarios/good_bot.yaml
Normal file
@ -0,0 +1,5 @@
|
||||
name: "a compliment gets an emoji on the compliment itself, not a message in the group"
|
||||
inject:
|
||||
- { text: "!good bot", uuid: uuid-fan, name: Fan }
|
||||
expect:
|
||||
- { kind: reaction, to: reply, quotes: true }
|
||||
7
fake_signal/scenarios/help_command.yaml
Normal file
7
fake_signal/scenarios/help_command.yaml
Normal file
@ -0,0 +1,7 @@
|
||||
# Fresh uuid: one bot process serves every scenario and the cooldown lives in
|
||||
# SQLite, so a shared actor would leak a refusal into this file.
|
||||
name: "!pomoc lists every command, including ones from other modules"
|
||||
inject:
|
||||
- { text: "!pomoc", uuid: uuid-helen, name: Helen }
|
||||
expect:
|
||||
- { kind: text, to: reply, contains: "!gekon" }
|
||||
16
fake_signal/scenarios/mute_command.yaml
Normal file
16
fake_signal/scenarios/mute_command.yaml
Normal file
@ -0,0 +1,16 @@
|
||||
# One confirmation, then nothing -- not even for a command that would
|
||||
# otherwise be served -- until the unmute, which is the only word that gets
|
||||
# through. The suite runs TRUSTED_INVITERS empty, so anybody may end it,
|
||||
# and the scenario MUST end unmuted: every later file shares this process
|
||||
# and this group, and a mute left standing would silence all of them.
|
||||
name: "!cisza silences the group until !odcisz"
|
||||
inject:
|
||||
- { text: "!cisza 1", uuid: uuid-mona, name: Mona }
|
||||
- { text: "!cat", uuid: uuid-mona, name: Mona }
|
||||
- { text: "!cta", uuid: uuid-mona, name: Mona }
|
||||
- { text: "!odcisz", uuid: uuid-mona, name: Mona }
|
||||
- { text: "!cat", uuid: uuid-mona, name: Mona }
|
||||
expect:
|
||||
- { kind: text, to: reply, contains: "CISZA PRZEZ 1 MINUTĘ" }
|
||||
- { kind: text, to: reply, contains: "WRACAM" }
|
||||
- { kind: image, to: reply }
|
||||
12
fake_signal/scenarios/pack_add_custom.yaml
Normal file
12
fake_signal/scenarios/pack_add_custom.yaml
Normal file
@ -0,0 +1,12 @@
|
||||
# A label matching nobody is a proposal, not a tag: the photo waits in the
|
||||
# queue and !losowe cannot find the word until somebody approves it on the
|
||||
# management page. fake_signal/approved/ replays the other half of this,
|
||||
# after scripts/e2e.sh has approved the queue. The typed label must appear
|
||||
# in neither reply.
|
||||
name: "!dodaj with a new label queues the photo, and !losowe cannot see it yet"
|
||||
inject:
|
||||
- { text: "!dodaj wakacje", uuid: uuid-add-custom, name: AddCustom, attachment: att-custom }
|
||||
- { text: "!losowe wakacje", uuid: uuid-add-custom-b, name: AddCustomB }
|
||||
expect:
|
||||
- { kind: text, to: reply, contains: "czeka na zatwierdzenie: 1" }
|
||||
- { kind: text, to: reply, contains: "NIE MAM TAKIEGO ZDJĘCIA" }
|
||||
14
fake_signal/scenarios/pack_add_heic.yaml
Normal file
14
fake_signal/scenarios/pack_add_heic.yaml
Normal file
@ -0,0 +1,14 @@
|
||||
# The iPhone default. Its HEVC parameter set survives `-all=` and is on the
|
||||
# structural allowlist by kuhy's decision of 2026-08-31; the GPS and the Make
|
||||
# do not survive, which is what e2e.sh re-reads the queued file to prove.
|
||||
name: "a geotagged heic is accepted and waits without its GPS"
|
||||
inject:
|
||||
- {
|
||||
text: "!dodaj gamma", uuid: uuid-add-heic, name: AddHeic,
|
||||
attachment: att-heic, attachment_type: "image/heic",
|
||||
}
|
||||
# Tagged to gamma rather than alfa on purpose: pack_command.yaml asserts
|
||||
# !alfa returns an image, and the pick is random -- putting a clip in p1
|
||||
# would make that scenario fail about half the time once approved.
|
||||
expect:
|
||||
- { kind: text, to: reply, contains: "czeka na zatwierdzenie" }
|
||||
9
fake_signal/scenarios/pack_add_no_attachment.yaml
Normal file
9
fake_signal/scenarios/pack_add_no_attachment.yaml
Normal file
@ -0,0 +1,9 @@
|
||||
# The proposed label is held in memory and reaches neither custom.json nor
|
||||
# the queue: this is the case that must leave the album byte-identical.
|
||||
# scripts/e2e.sh checks the files themselves, because a reply cannot prove
|
||||
# what was not written.
|
||||
name: "!dodaj with no attachment changes nothing on disk"
|
||||
inject:
|
||||
- { text: "!dodaj piesek", uuid: uuid-add-none, name: AddNone }
|
||||
expect:
|
||||
- { kind: text, to: reply, contains: "DOŁĄCZ ZDJĘCIE" }
|
||||
10
fake_signal/scenarios/pack_add_person.yaml
Normal file
10
fake_signal/scenarios/pack_add_person.yaml
Normal file
@ -0,0 +1,10 @@
|
||||
# The feature in the shape the group will actually use it: a photo captioned
|
||||
# with somebody's name. It is queued, not stored -- the attachment carries a
|
||||
# real APP1 Exif segment (tool/e2e_media.py puts GPS, a Make and an XMP
|
||||
# creator in it), and scripts/e2e.sh re-reads the queued file to prove the
|
||||
# strip happened before the file waited anywhere. The reply is a count.
|
||||
name: "!dodaj with an existing person queues the photo for approval"
|
||||
inject:
|
||||
- { text: "!dodaj alfa", uuid: uuid-add-person, name: AddPerson, attachment: att-person }
|
||||
expect:
|
||||
- { kind: text, to: reply, contains: "czeka na zatwierdzenie: 1" }
|
||||
10
fake_signal/scenarios/pack_add_rejected.yaml
Normal file
10
fake_signal/scenarios/pack_add_rejected.yaml
Normal file
@ -0,0 +1,10 @@
|
||||
# Sniffed on the bytes, never on the filename or the stated contentType --
|
||||
# this one claims to be a photo and is a text file.
|
||||
name: "a file that is not media is refused and never queued"
|
||||
inject:
|
||||
- {
|
||||
text: "!dodaj alfa", uuid: uuid-add-junk, name: AddJunk,
|
||||
attachment: att-junk, attachment_type: "image/jpeg",
|
||||
}
|
||||
expect:
|
||||
- { kind: text, to: reply, contains: "NIE PRZYJMUJĘ" }
|
||||
10
fake_signal/scenarios/pack_add_scoping.yaml
Normal file
10
fake_signal/scenarios/pack_add_scoping.yaml
Normal file
@ -0,0 +1,10 @@
|
||||
# The privacy guarantee, for the write path this time. In a group the album
|
||||
# was not given to, !dodaj is not a command at all: nothing is queued, nothing
|
||||
# is answered, and nothing names the album in a group that does not have one.
|
||||
name: "!dodaj does not exist in a group the album was not given to"
|
||||
inject:
|
||||
- {
|
||||
text: "!dodaj alfa", group: second, uuid: uuid-add-scope, name: AddScope,
|
||||
attachment: att-second,
|
||||
}
|
||||
expect: []
|
||||
12
fake_signal/scenarios/pack_add_svg.yaml
Normal file
12
fake_signal/scenarios/pack_add_svg.yaml
Normal file
@ -0,0 +1,12 @@
|
||||
# kuhy's decision of 2026-08-31: exiftool 13.55 can write neither .svg nor
|
||||
# .webm, and both stay on the allowlist and are queued exactly as they
|
||||
# arrive -- metadata included. This scenario exists so that hole is
|
||||
# demonstrated by the suite rather than only described in the docs.
|
||||
name: "an svg is accepted and queued unstripped, by decision"
|
||||
inject:
|
||||
- {
|
||||
text: "!dodaj alfa", uuid: uuid-add-svg, name: AddSvg,
|
||||
attachment: att-svg, attachment_type: "image/svg+xml",
|
||||
}
|
||||
expect:
|
||||
- { kind: text, to: reply, contains: "czeka na zatwierdzenie" }
|
||||
15
fake_signal/scenarios/pack_add_video.yaml
Normal file
15
fake_signal/scenarios/pack_add_video.yaml
Normal file
@ -0,0 +1,15 @@
|
||||
# An mp4 geotagged the way a phone does it: a QuickTime udta atom, which a
|
||||
# naive "-GPS:all" gate passes straight through because exiftool files it
|
||||
# under QuickTime and not under GPS. It is accepted, and scripts/e2e.sh then
|
||||
# re-reads the queued file and fails the run if the atom survived.
|
||||
name: "a geotagged mp4 is stripped of its QuickTime atom and queued"
|
||||
inject:
|
||||
- {
|
||||
text: "!dodaj gamma", uuid: uuid-add-video, name: AddVideo,
|
||||
attachment: att-video, attachment_type: "video/mp4",
|
||||
}
|
||||
# Tagged to gamma rather than alfa on purpose: pack_command.yaml asserts
|
||||
# !alfa returns an image, and the pick is random -- putting a clip in p1
|
||||
# would make that scenario fail about half the time once approved.
|
||||
expect:
|
||||
- { kind: text, to: reply, contains: "czeka na zatwierdzenie" }
|
||||
7
fake_signal/scenarios/pack_combination.yaml
Normal file
7
fake_signal/scenarios/pack_combination.yaml
Normal file
@ -0,0 +1,7 @@
|
||||
# The whole point of the feature: several names mean "a photo with all of
|
||||
# them", and only both.png is tagged with p1 and p2 at once.
|
||||
name: "two names ask for a photo with both people on it"
|
||||
inject:
|
||||
- { text: "!alfa beta", uuid: uuid-pack-two, name: Pack2 }
|
||||
expect:
|
||||
- { kind: image, to: reply }
|
||||
7
fake_signal/scenarios/pack_command.yaml
Normal file
7
fake_signal/scenarios/pack_command.yaml
Normal file
@ -0,0 +1,7 @@
|
||||
# The private album, against a synthetic pack the e2e script writes: the real
|
||||
# one lives outside this repository and must never be needed to run the suite.
|
||||
name: "!alfa posts a photo from the private album"
|
||||
inject:
|
||||
- { text: "!alfa", uuid: uuid-pack-one, name: Pack1 }
|
||||
expect:
|
||||
- { kind: image, to: reply }
|
||||
7
fake_signal/scenarios/pack_no_photo.yaml
Normal file
7
fake_signal/scenarios/pack_no_photo.yaml
Normal file
@ -0,0 +1,7 @@
|
||||
# A combination nobody photographed is answered, not met with silence: from
|
||||
# the group's side, silence is indistinguishable from a broken bot.
|
||||
name: "a combination with no photo gets one line and no attachment"
|
||||
inject:
|
||||
- { text: "!beta gamma", uuid: uuid-pack-three, name: Pack3 }
|
||||
expect:
|
||||
- { kind: text, to: reply, contains: "NIE MAM TAKIEGO" }
|
||||
7
fake_signal/scenarios/pack_scoping.yaml
Normal file
7
fake_signal/scenarios/pack_scoping.yaml
Normal file
@ -0,0 +1,7 @@
|
||||
# The privacy guarantee. In a group the pack does not list, "!alfa" is not a
|
||||
# command at all: no photo, no refusal, nothing -- a refusal would itself
|
||||
# announce that the album exists.
|
||||
name: "the album does not exist in a group it was not given to"
|
||||
inject:
|
||||
- { text: "!alfa", group: second, uuid: uuid-pack-five, name: Pack5 }
|
||||
expect: []
|
||||
7
fake_signal/scenarios/pack_video.yaml
Normal file
7
fake_signal/scenarios/pack_video.yaml
Normal file
@ -0,0 +1,7 @@
|
||||
# Told it is a photo, Signal renders a clip as a file nobody can play. The
|
||||
# harness reads the data URI the bot sent, so this fails if the type is lost.
|
||||
name: "a tagged video leaves as a video, not as a photo"
|
||||
inject:
|
||||
- { text: "!wideo", uuid: uuid-pack-four, name: Pack4 }
|
||||
expect:
|
||||
- { kind: video, to: reply }
|
||||
5
fake_signal/scenarios/poem_command.yaml
Normal file
5
fake_signal/scenarios/poem_command.yaml
Normal file
@ -0,0 +1,5 @@
|
||||
name: "!wiersz answers with the poem itself, no attachment and no network"
|
||||
inject:
|
||||
- { text: "!wiersz", uuid: uuid-poet, name: Poet }
|
||||
expect:
|
||||
- { kind: text, to: reply }
|
||||
7
fake_signal/scenarios/suggest.yaml
Normal file
7
fake_signal/scenarios/suggest.yaml
Normal file
@ -0,0 +1,7 @@
|
||||
# Filing an idea from inside the group. The word must not already be a
|
||||
# command anywhere, so this one is invented rather than borrowed.
|
||||
name: "!zaproponuj files an idea"
|
||||
inject:
|
||||
- { text: "!zaproponuj !pogoda pokaz pogode na jutro", uuid: uuid-alice, name: Alice }
|
||||
expect:
|
||||
- { kind: text, to: reply, contains: "Zapisane" }
|
||||
5
fake_signal/scenarios/trap_command.yaml
Normal file
5
fake_signal/scenarios/trap_command.yaml
Normal file
@ -0,0 +1,5 @@
|
||||
name: "!pułapka posts a photograph of the device, captioned with what it is"
|
||||
inject:
|
||||
- { text: "!pułapka", uuid: uuid-trap, name: Trapper }
|
||||
expect:
|
||||
- { kind: image, to: reply }
|
||||
@ -1,5 +1,8 @@
|
||||
name: "chatter and unknown commands produce nothing"
|
||||
# A "!" word nothing claims. Until 0.1.x this was silent, which made a typo
|
||||
# indistinguishable from the bot being down. Nothing is close enough to
|
||||
# "!nosuchcommand" to suggest, so the answer points at !zaproponuj instead.
|
||||
name: "an unrecognised command is answered, not ignored"
|
||||
inject:
|
||||
- { text: "just chatting", uuid: uuid-alice, name: Alice }
|
||||
- { text: "!nosuchcommand", uuid: uuid-alice, name: Alice }
|
||||
expect: []
|
||||
expect:
|
||||
- { kind: text, to: reply, contains: "!zaproponuj" }
|
||||
|
||||
9
fake_signal/scenarios/unknown_command_scoping.yaml
Normal file
9
fake_signal/scenarios/unknown_command_scoping.yaml
Normal file
@ -0,0 +1,9 @@
|
||||
# The privacy guarantee again, this time for the near-miss path. The album's
|
||||
# triggers are real people's names. In a group without the album "!alfa" is
|
||||
# unclaimed *here*, so a check scoped to this group would answer it and store
|
||||
# it -- announcing the album and putting a name in the database. The check is
|
||||
# global instead: claimed anywhere at all means silence.
|
||||
name: "an album name is not echoed in a group without the album"
|
||||
inject:
|
||||
- { text: "!alfa", group: second, uuid: uuid-pack-five, name: Pack5 }
|
||||
expect: []
|
||||
6
fake_signal/scenarios/workout_today.yaml
Normal file
6
fake_signal/scenarios/workout_today.yaml
Normal file
@ -0,0 +1,6 @@
|
||||
# Today's workout with every field it carries, from the run's own log.
|
||||
name: "!trening prints today's workout with all its fields"
|
||||
inject:
|
||||
- { text: "!trening", uuid: uuid-alice, name: Alice }
|
||||
expect:
|
||||
- { kind: text, to: reply, contains: "duration minutes: 112.7" }
|
||||
@ -24,6 +24,12 @@ class State:
|
||||
sockets: list[WebSocket] = field(default_factory=list)
|
||||
sent: list[dict[str, Any]] = field(default_factory=list)
|
||||
attachments: list[str] = field(default_factory=list)
|
||||
# Inbound attachment bytes by id, which the real container keeps in
|
||||
# its own store. Without these the fake can name an attachment on an
|
||||
# envelope but never hand it over, so !add_photo could not be proven
|
||||
# offline at all. Deliberately not cleared by reset(): a scenario
|
||||
# uploads its fixture before the run, not between injections.
|
||||
stored: dict[str, bytes] = field(default_factory=dict)
|
||||
mode: str = "json-rpc"
|
||||
base_url: str = "http://127.0.0.1:8099"
|
||||
# Registration and group state, so the registration runbook can be
|
||||
@ -40,6 +46,15 @@ class State:
|
||||
self.sent.clear()
|
||||
self.attachments.clear()
|
||||
|
||||
def record_reaction(self, number: str, payload: dict[str, Any]) -> None:
|
||||
"""Capture one reaction in the same list as the sends.
|
||||
|
||||
One list, because a scenario's ``expect`` is the bot's outbound
|
||||
traffic in order, and "it reacted instead of replying" is exactly
|
||||
the kind of claim that ordering has to be able to make.
|
||||
"""
|
||||
self.sent.append({"number": number, **payload})
|
||||
|
||||
def record_send(self, payload: dict[str, Any]) -> None:
|
||||
"""Capture one outgoing message and mint ids for its attachments."""
|
||||
self.sent.append(payload)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "signal-bot"
|
||||
version = "0.1.1"
|
||||
version = "0.2.6"
|
||||
description = "A Signal group bot: image commands and daily message tallies."
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
@ -26,7 +26,7 @@ dev = [
|
||||
"pytest==9.1.1",
|
||||
"pytest-asyncio==1.4.0",
|
||||
"pytest-cov==7.1.0",
|
||||
"ruff==0.16.5",
|
||||
"ruff==0.16.6",
|
||||
"types-PyYAML==6.0.12.20260815",
|
||||
]
|
||||
|
||||
@ -60,7 +60,37 @@ fixable = ["ALL"]
|
||||
# Verified empirically, not copied: with this block removed, ruff reports
|
||||
# exactly these four rules across tests/ and nothing else. SLF001 was in
|
||||
# the original list and fired zero times, so it is gone.
|
||||
[tool.ruff.lint.isort]
|
||||
# venv_bootstrap sets up .venv as a side effect of being imported, so it has
|
||||
# to be imported before anything that lives in .venv. Left to its own devices
|
||||
# isort sorts it alphabetically -- after fastapi and httpx -- which silently
|
||||
# breaks the thing it exists to do. Giving it its own section ahead of the
|
||||
# standard library makes the formatter enforce the order instead of undoing
|
||||
# it, which is better than a comment asking people not to reformat.
|
||||
section-order = [
|
||||
"future",
|
||||
"bootstrap",
|
||||
"standard-library",
|
||||
"third-party",
|
||||
"first-party",
|
||||
"local-folder",
|
||||
]
|
||||
|
||||
[tool.ruff.lint.isort.sections]
|
||||
bootstrap = ["venv_bootstrap"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
# The single subprocess in this repository. S603 flags every subprocess call
|
||||
# and has no code-level fix -- exiftool is a binary, and the pure-Python EXIF
|
||||
# libraries handle neither HEIC nor MP4. signal_bot/external.py exists so this
|
||||
# entry can name exactly one file: any other module that shells out fails the
|
||||
# gate, which is the point. Do not widen this to a directory.
|
||||
"signal_bot/external.py" = ["S603"]
|
||||
# The bootstrap that installs .venv. It cannot route through external.py --
|
||||
# that module lives inside the package it is installing -- so it is the one
|
||||
# other place allowed to start a process. Kept to a single small file for
|
||||
# exactly that reason. kuhy's call, 2026-08-31. Do not widen to a directory.
|
||||
"tool/venv_bootstrap.py" = ["S603", "S606"]
|
||||
"tests/**/*.py" = [
|
||||
"ARG", # fixture and mock params exist for their side effect
|
||||
"D", # test names are the documentation
|
||||
|
||||
158
scripts/check_personal_data.py
Executable file
158
scripts/check_personal_data.py
Executable file
@ -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"(?<![\w]){re.escape(word)}(?![\w])", lowered):
|
||||
# The word itself is never printed: this output can end up in
|
||||
# a CI log, and naming the name there would be the same leak
|
||||
# by a longer route.
|
||||
hits.append((number, "a name from your private data"))
|
||||
break
|
||||
hits.extend(
|
||||
(number, f"{label} {match!r} is not in .personal-data-allowlist")
|
||||
for label, pattern in PATTERNS.items()
|
||||
for match in pattern.findall(line)
|
||||
if match.lower() not in permitted
|
||||
)
|
||||
return hits
|
||||
|
||||
|
||||
def readable(path: Path) -> 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())
|
||||
73
scripts/check_personal_data.sh
Executable file
73
scripts/check_personal_data.sh
Executable file
@ -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 "$@"
|
||||
158
scripts/e2e.sh
158
scripts/e2e.sh
@ -13,11 +13,21 @@ set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
readonly SCRIPT_DIR
|
||||
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
# The post-replay assertions, split out at the 250-line cap. Sourced
|
||||
# rather than executed, because they run against the same fake server and
|
||||
# the same bot process the scenarios used -- and because the split lost
|
||||
# this line once already, which left every one of them silently unrun and
|
||||
# the whole script exiting 127.
|
||||
# shellcheck source=scripts/e2e_proofs.sh
|
||||
source "$SCRIPT_DIR/e2e_proofs.sh"
|
||||
readonly REPO_ROOT
|
||||
readonly PYTHON="${PYTHON:-$REPO_ROOT/.venv/bin/python}"
|
||||
readonly FAKE_PORT="${FAKE_PORT:-8099}"
|
||||
readonly BOT_PORT="${BOT_PORT:-8098}"
|
||||
readonly MANAGE_PORT="${MANAGE_PORT:-8097}"
|
||||
readonly FAKE_URL="http://127.0.0.1:${FAKE_PORT}"
|
||||
readonly MANAGE_URL="http://127.0.0.1:${MANAGE_PORT}"
|
||||
|
||||
# Both spellings of one group, exactly as the real API reports them:
|
||||
# /v1/groups returns the `group.<base64>` form (what .env holds), while an
|
||||
@ -39,6 +49,7 @@ readonly OTHER_GROUP="group.e2e.other=="
|
||||
|
||||
FAKE_PID=""
|
||||
BOT_PID=""
|
||||
MANAGE_PID=""
|
||||
TEMP_DIR=""
|
||||
|
||||
cleanup() {
|
||||
@ -48,6 +59,9 @@ cleanup() {
|
||||
if [[ -n "$BOT_PID" ]]; then
|
||||
kill "$BOT_PID" 2>/dev/null || true
|
||||
fi
|
||||
if [[ -n "$MANAGE_PID" ]]; then
|
||||
kill "$MANAGE_PID" 2>/dev/null || true
|
||||
fi
|
||||
if [[ -n "$FAKE_PID" ]]; then
|
||||
kill "$FAKE_PID" 2>/dev/null || true
|
||||
fi
|
||||
@ -87,6 +101,58 @@ wait_for_subscription() {
|
||||
done
|
||||
}
|
||||
|
||||
# Drive the fake through these scenario files, against the bot booted above.
|
||||
# One place for the group map, so a proof that injects after the main replay
|
||||
# resolves the same symbolic names the scenarios do.
|
||||
replay() {
|
||||
"$PYTHON" -m fake_signal.cli "$@" \
|
||||
--settle "${E2E_SETTLE:-0.15}" \
|
||||
--base-url "$FAKE_URL" \
|
||||
--group "watched=${WATCHED_GROUP_INTERNAL}" \
|
||||
--group "reply=${WATCHED_GROUP}" \
|
||||
--group "second=${SECOND_GROUP_INTERNAL}" \
|
||||
--group "second_reply=${SECOND_GROUP}" \
|
||||
--group "third=${THIRD_GROUP_INTERNAL}" \
|
||||
--group "third_reply=${THIRD_GROUP}" \
|
||||
--group "fourth=${FOURTH_GROUP_INTERNAL}" \
|
||||
--group "fourth_reply=${FOURTH_GROUP}" \
|
||||
--group "other=${OTHER_GROUP}"
|
||||
}
|
||||
|
||||
# The bot and the management page under one environment: the page approves
|
||||
# into the album the bot reads, and asks the bot at BIND_PORT to rebuild its
|
||||
# table -- a path that differed between them would prove nothing.
|
||||
# GROUP_IDS and TRUSTED_INVITERS deliberately empty: the bot discovers its
|
||||
# groups from the account, which is the configuration real deployments use.
|
||||
# Set rather than merely unset, because load_dotenv() would otherwise let
|
||||
# the developer's own .env decide what this run watches -- and a
|
||||
# TRUSTED_INVITERS there would refuse every fixture group. The gate itself
|
||||
# is covered in tests/test_trust.py.
|
||||
# exec, because this is backgrounded: without it $! is a subshell, and the
|
||||
# kill in cleanup would leave the real process running after the run.
|
||||
with_bot_env() {
|
||||
exec env -u CAT_API \
|
||||
PHONE_NUMBER="+15550000000" \
|
||||
GROUP_IDS="" \
|
||||
TRUSTED_INVITERS="" \
|
||||
API_BASE_URL="$FAKE_URL" \
|
||||
DATABASE_PATH="${TEMP_DIR}/e2e.sqlite3" \
|
||||
CAT_API_URL="${FAKE_URL}/_fixtures/cat-api" \
|
||||
DOG_API_URL="${FAKE_URL}/_fixtures/dog-api" \
|
||||
GECKO_API_URL="${FAKE_URL}/_fixtures/gecko-api" \
|
||||
COMMONS_API_URL="${FAKE_URL}/_fixtures/commons-api" \
|
||||
POEM_TEXT_URL="${FAKE_URL}/_fixtures/poem-text?slug={slug}" \
|
||||
PORTRAIT_CACHE_DIR="${TEMP_DIR}/portraits" \
|
||||
PHOTO_PACK_DIR="${TEMP_DIR}/pack" \
|
||||
CUSTOM_ALIASES_FILE="${TEMP_DIR}/aliases_custom.json" \
|
||||
FOOD_LOG_FILE="${TEMP_DIR}/logs/food_log.json" \
|
||||
WORKOUT_LOG_FILE="${TEMP_DIR}/logs/workout_log.json" \
|
||||
BIND_PORT="$BOT_PORT" \
|
||||
SILENCE_SECONDS=0 \
|
||||
LIVENESS_TICK_SECONDS=1 \
|
||||
"$@"
|
||||
}
|
||||
|
||||
validate_requirements() {
|
||||
if [[ ! -x "$PYTHON" ]]; then
|
||||
echo "Error: no interpreter at $PYTHON. Run: uv venv .venv && uv pip install -e '.[dev]'" >&2
|
||||
@ -98,6 +164,7 @@ validate_requirements() {
|
||||
main() {
|
||||
validate_requirements
|
||||
TEMP_DIR="$(mktemp -d)"
|
||||
STARTED_AT="$(date +%s)"
|
||||
|
||||
echo "== booting the fake Signal server on :${FAKE_PORT}"
|
||||
export FAKE_PORT
|
||||
@ -114,81 +181,38 @@ main() {
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "[{\"id\":\"${WATCHED_GROUP}\",\"internal_id\":\"${WATCHED_GROUP_INTERNAL}\",\"name\":\"e2e\",\"member\":true,\"joinable\":true},{\"id\":\"${SECOND_GROUP}\",\"internal_id\":\"${SECOND_GROUP_INTERNAL}\",\"name\":\"e2e-second\",\"member\":true,\"joinable\":true}]" >/dev/null
|
||||
|
||||
# GROUP_IDS and TRUSTED_INVITERS deliberately empty: the bot discovers
|
||||
# its groups from the account, which is the configuration real
|
||||
# deployments use. Set rather than merely unset, because load_dotenv()
|
||||
# would otherwise let the developer's own .env decide what this run
|
||||
# watches -- and a TRUSTED_INVITERS there would refuse every fixture
|
||||
# group. The gate itself is covered in tests/test_trust.py.
|
||||
# The album is private and lives outside this repository, so the run
|
||||
# builds a synthetic one: three invented people, a combination and a
|
||||
# video, scoped to the watched group so the scoping scenario can prove
|
||||
# it does not exist in the second one.
|
||||
echo "== writing a synthetic photo pack"
|
||||
"$PYTHON" "$REPO_ROOT/tool/e2e_pack.py" --dir "${TEMP_DIR}/pack" --group "$WATCHED_GROUP"
|
||||
# The food and workout logs are the operator's day, kept by other
|
||||
# programs; the run gets one invented entry of each, dated today.
|
||||
"$PYTHON" "$REPO_ROOT/tool/e2e_logs.py" --dir "${TEMP_DIR}/logs"
|
||||
echo "== generating media for !dodaj (real exif, never committed)"
|
||||
"$PYTHON" "$REPO_ROOT/tool/e2e_media.py" --upload "$FAKE_URL"
|
||||
|
||||
echo "== booting the bot on :${BOT_PORT}, pointed at the fake"
|
||||
env -u CAT_API \
|
||||
PHONE_NUMBER="+15550000000" \
|
||||
GROUP_IDS="" \
|
||||
TRUSTED_INVITERS="" \
|
||||
API_BASE_URL="$FAKE_URL" \
|
||||
DATABASE_PATH="${TEMP_DIR}/e2e.sqlite3" \
|
||||
CAT_API_URL="${FAKE_URL}/_fixtures/cat-api" \
|
||||
DOG_API_URL="${FAKE_URL}/_fixtures/dog-api" \
|
||||
GECKO_API_URL="${FAKE_URL}/_fixtures/gecko-api" \
|
||||
COMMONS_API_URL="${FAKE_URL}/_fixtures/commons-api" \
|
||||
PORTRAIT_CACHE_DIR="${TEMP_DIR}/portraits" \
|
||||
BIND_PORT="$BOT_PORT" \
|
||||
SILENCE_SECONDS=0 \
|
||||
"$PYTHON" -m signal_bot &
|
||||
with_bot_env "$PYTHON" -m signal_bot &
|
||||
BOT_PID=$!
|
||||
echo "== booting the management page on :${MANAGE_PORT}, pointed at the bot"
|
||||
with_bot_env "$PYTHON" "$REPO_ROOT/tool/web.py" \
|
||||
--dir "${TEMP_DIR}/pack" --port "$MANAGE_PORT" --no-open &
|
||||
MANAGE_PID=$!
|
||||
wait_for_url "http://127.0.0.1:${BOT_PORT}/healthz" "bot"
|
||||
wait_for_url "${MANAGE_URL}/api/album/pending" "management page"
|
||||
wait_for_subscription
|
||||
|
||||
echo "== replaying scenarios"
|
||||
"$PYTHON" -m fake_signal.cli \
|
||||
"$REPO_ROOT"/fake_signal/scenarios/*.yaml \
|
||||
--base-url "$FAKE_URL" \
|
||||
--group "watched=${WATCHED_GROUP_INTERNAL}" \
|
||||
--group "reply=${WATCHED_GROUP}" \
|
||||
--group "second=${SECOND_GROUP_INTERNAL}" \
|
||||
--group "second_reply=${SECOND_GROUP}" \
|
||||
--group "third=${THIRD_GROUP_INTERNAL}" \
|
||||
--group "third_reply=${THIRD_GROUP}" \
|
||||
--group "fourth=${FOURTH_GROUP_INTERNAL}" \
|
||||
--group "fourth_reply=${FOURTH_GROUP}" \
|
||||
--group "other=${OTHER_GROUP}"
|
||||
replay "$REPO_ROOT"/fake_signal/scenarios/*.yaml
|
||||
|
||||
prove_the_add_photo_queue
|
||||
prove_the_table_reloads_without_a_restart
|
||||
prove_a_switch_is_live_without_a_reload
|
||||
prove_an_alias_is_live_after_a_reload
|
||||
prove_the_deafness_gate
|
||||
}
|
||||
|
||||
# The gate that did not exist on 2026-08-29, proven against the real process:
|
||||
# with signal-cli unable to reach Signal, /healthz must go red rather than
|
||||
# keep answering 200 while nothing arrives.
|
||||
prove_the_deafness_gate() {
|
||||
local health="http://127.0.0.1:${BOT_PORT}/healthz"
|
||||
echo "== cutting the fake's connection to Signal"
|
||||
curl -sf -X POST "${FAKE_URL}/_control/connection" \
|
||||
-H 'Content-Type: application/json' -d '{"alive": false}' >/dev/null
|
||||
|
||||
local code="" waited=0
|
||||
while ((waited < 150)); do
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' "$health")"
|
||||
[[ "$code" == "503" ]] && break
|
||||
sleep 1
|
||||
waited=$((waited + 1))
|
||||
done
|
||||
[[ "$code" == "503" ]] || die "/healthz still answers ${code} with the connection cut"
|
||||
curl -s "$health" | grep -q "Closed unexpectedly" ||
|
||||
die "/healthz went red without saying why"
|
||||
echo "== /healthz turned 503 and named the reason after ${waited}s"
|
||||
|
||||
echo "== restoring the connection"
|
||||
curl -sf -X POST "${FAKE_URL}/_control/connection" \
|
||||
-H 'Content-Type: application/json' -d '{"alive": true}' >/dev/null
|
||||
waited=0
|
||||
while ((waited < 150)); do
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' "$health")"
|
||||
[[ "$code" == "200" ]] && break
|
||||
sleep 1
|
||||
waited=$((waited + 1))
|
||||
done
|
||||
[[ "$code" == "200" ]] || die "/healthz stayed ${code} after the connection came back"
|
||||
echo "== /healthz recovered on its own after ${waited}s"
|
||||
enforce_the_time_budget "$STARTED_AT"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
201
scripts/e2e_proofs.sh
Executable file
201
scripts/e2e_proofs.sh
Executable file
@ -0,0 +1,201 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================================================
|
||||
# The assertions scripts/e2e.sh makes after the scenarios have replayed.
|
||||
#
|
||||
# Split out of e2e.sh at the 250-line cap. A filing change, not an interface
|
||||
# change: these are sourced, so they still run against the same fake server
|
||||
# and the same bot process the scenarios used.
|
||||
# ============================================================================
|
||||
|
||||
# The gate that did not exist on 2026-08-29, proven against the real process:
|
||||
# with signal-cli unable to reach Signal, /healthz must go red rather than
|
||||
# keep answering 200 while nothing arrives.
|
||||
prove_the_deafness_gate() {
|
||||
local health="http://127.0.0.1:${BOT_PORT}/healthz"
|
||||
echo "== cutting the fake's connection to Signal"
|
||||
curl -sf -X POST "${FAKE_URL}/_control/connection" \
|
||||
-H 'Content-Type: application/json' -d '{"alive": false}' >/dev/null
|
||||
|
||||
local code="" waited=0
|
||||
while ((waited < 150)); do
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' "$health")"
|
||||
[[ "$code" == "503" ]] && break
|
||||
sleep 1
|
||||
waited=$((waited + 1))
|
||||
done
|
||||
[[ "$code" == "503" ]] || die "/healthz still answers ${code} with the connection cut"
|
||||
curl -s "$health" | grep -q "Closed unexpectedly" ||
|
||||
die "/healthz went red without saying why"
|
||||
echo "== /healthz turned 503 and named the reason after ${waited}s"
|
||||
|
||||
echo "== restoring the connection"
|
||||
curl -sf -X POST "${FAKE_URL}/_control/connection" \
|
||||
-H 'Content-Type: application/json' -d '{"alive": true}' >/dev/null
|
||||
waited=0
|
||||
while ((waited < 150)); do
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' "$health")"
|
||||
[[ "$code" == "200" ]] && break
|
||||
sleep 1
|
||||
waited=$((waited + 1))
|
||||
done
|
||||
[[ "$code" == "200" ]] || die "/healthz stayed ${code} after the connection came back"
|
||||
echo "== /healthz recovered on its own after ${waited}s"
|
||||
}
|
||||
|
||||
# A person tagged while the bot is up becomes a command after POST /_reload,
|
||||
# with no restart: the same process that just answered "Nie znam !delta"
|
||||
# posts delta's photo a moment later. The scenarios live next to the main
|
||||
# set but outside its glob, because the second one is only true after the
|
||||
# reload that this proof performs between them.
|
||||
prove_the_table_reloads_without_a_restart() {
|
||||
local proofs="${REPO_ROOT}/fake_signal/proofs"
|
||||
echo "== before the reload, !delta is nobody"
|
||||
replay "${proofs}/reload_before.yaml"
|
||||
|
||||
echo "== tagging a fifth person into the pack, then scripts/reload.sh"
|
||||
"$PYTHON" "$REPO_ROOT/tool/e2e_pack.py" \
|
||||
--dir "${TEMP_DIR}/pack" --group "$WATCHED_GROUP" --extra delta >/dev/null
|
||||
# The operator's script, not a bare curl: this is the one place it runs
|
||||
# against a real process in CI, so its exit code is the proof.
|
||||
BIND_PORT="$BOT_PORT" "$REPO_ROOT/scripts/reload.sh" ||
|
||||
die "scripts/reload.sh did not get the new table live"
|
||||
|
||||
echo "== after the reload, !delta posts a photo"
|
||||
replay "${proofs}/reload_after.yaml"
|
||||
}
|
||||
|
||||
# A switch thrown from the management page is read on the very next attempt:
|
||||
# no reload, no restart. The PUT here is what the page's Save button sends,
|
||||
# aimed at the bot directly; the two scenarios bracket it the same way the
|
||||
# reload pair does. Not a timed switch, because a proof that depends on the
|
||||
# clock has to wait on it, and the unit tests already cover the deadline.
|
||||
prove_a_switch_is_live_without_a_reload() {
|
||||
local proofs="${REPO_ROOT}/fake_signal/proofs"
|
||||
local switch="http://127.0.0.1:${BOT_PORT}/_commands/cat"
|
||||
|
||||
echo "== switching !kot off"
|
||||
curl -sf -X PUT "$switch" -H 'Content-Type: application/json' \
|
||||
-d '{"enabled": false}' | grep -q '"enabled":false' ||
|
||||
die "PUT /_commands/cat did not report the command off"
|
||||
replay "${proofs}/switch_off.yaml"
|
||||
|
||||
echo "== switching !kot back on"
|
||||
curl -sf -X PUT "$switch" -H 'Content-Type: application/json' \
|
||||
-d '{"enabled": true}' | grep -q '"enabled":true' ||
|
||||
die "PUT /_commands/cat did not report the command on"
|
||||
replay "${proofs}/switch_on.yaml"
|
||||
}
|
||||
|
||||
# The operator's alias file is data the table is built from, so it takes
|
||||
# the same path as a tagged person: written, then scripts/reload.sh. The
|
||||
# file is the one CUSTOM_ALIASES_FILE points the bot at, never the
|
||||
# operator's own; a name the table does not hold is what the second half
|
||||
# proves the bot refuses, with the old table still answering.
|
||||
prove_an_alias_is_live_after_a_reload() {
|
||||
local proofs="${REPO_ROOT}/fake_signal/proofs"
|
||||
local file="${TEMP_DIR}/aliases_custom.json"
|
||||
|
||||
echo "== before the alias, !e2ekot is nobody"
|
||||
replay "${proofs}/alias_before.yaml"
|
||||
|
||||
echo "== writing the operator's alias file, then scripts/reload.sh"
|
||||
printf '{"cat": ["!e2ekot"]}\n' > "$file"
|
||||
BIND_PORT="$BOT_PORT" "$REPO_ROOT/scripts/reload.sh" ||
|
||||
die "scripts/reload.sh did not get the alias live"
|
||||
replay "${proofs}/alias_after.yaml"
|
||||
|
||||
echo "== an alias for a command that does not exist keeps the old table"
|
||||
printf '{"cat": ["!e2ekot"], "nosuch": ["!nic"]}\n' > "$file"
|
||||
local answer
|
||||
answer="$(curl -s -w '%{http_code}' -X POST "http://127.0.0.1:${BOT_PORT}/_reload")"
|
||||
[[ "$answer" == *nosuch*409 ]] ||
|
||||
die "a bad alias file was not kept out with 409 and its name: ${answer}"
|
||||
replay "${proofs}/alias_kept.yaml"
|
||||
printf '{"cat": ["!e2ekot"]}\n' > "$file"
|
||||
}
|
||||
|
||||
# A budget, not a benchmark. This run was four minutes until the liveness
|
||||
# tick became configurable, and nothing announced the cost -- slower is the
|
||||
# one regression a green suite never reports. Exceeding it fails the run;
|
||||
# whoever exceeded it decides whether the check earned its seconds or the
|
||||
# budget moves. E2E_BUDGET_SECONDS raises it for a loaded machine.
|
||||
# What the !dodaj replies cannot prove on their own: every queued file was
|
||||
# stripped before it waited anywhere, a typed label reached nothing but the
|
||||
# queue, the refused and out-of-scope sends wrote nothing -- and then the one
|
||||
# door into the album, taken through the management page, after which the
|
||||
# bot answers with the photo and recognises a second copy of it.
|
||||
prove_the_add_photo_queue() {
|
||||
local pack="${TEMP_DIR}/pack"
|
||||
|
||||
echo "== checking every queued file was stripped before it waited"
|
||||
"$PYTHON" "$REPO_ROOT/tool/e2e_media.py" --verify-pack "$pack"
|
||||
|
||||
echo "== checking a typed label reached nothing but the queue"
|
||||
if [[ -e "${pack}/custom.json" ]]; then
|
||||
echo "Error: custom.json was written before anything was approved" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -rq "piesek" "$pack"; then
|
||||
echo "Error: a label survived a message that carried no attachment" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== checking exactly the accepted sends are waiting"
|
||||
local queued
|
||||
queued="$(find "${pack}/pending" -type f ! -name '*.json' | wc -l)"
|
||||
if [[ "$queued" != "5" ]]; then
|
||||
echo "Error: expected 5 queued files (person, custom, heic, svg, video), found ${queued}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== checking signal-cli's own copies were deleted, and only those"
|
||||
local left
|
||||
left="$(curl -sf "${FAKE_URL}/_control/attachments")"
|
||||
for taken in att-person att-custom att-heic att-svg att-video att-junk; do
|
||||
if [[ "$left" == *"$taken"* ]]; then
|
||||
echo "Error: ${taken} is still in the container store: ${left}" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
# Out of scope means never fetched: the second group's file is untouched.
|
||||
if [[ "$left" != *"att-second"* ]]; then
|
||||
echo "Error: a send in a group without the album was fetched: ${left}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== approving the queue through the management page"
|
||||
"$PYTHON" "$REPO_ROOT/tool/e2e_approve.py" --manage-url "$MANAGE_URL"
|
||||
|
||||
echo "== checking the album took them whole, and the queue is empty"
|
||||
"$PYTHON" "$REPO_ROOT/tool/e2e_media.py" --verify-pack "$pack"
|
||||
if [[ -n "$(ls -A "${pack}/pending")" ]]; then
|
||||
echo "Error: the queue is not empty after approving everything" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -qE '"(wakacje|piesek)"[[:space:]]*:' "${pack}/custom.json"; then
|
||||
echo "Error: a typed label became a key in custom.json" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q '"approved_at"' "${pack}/adds.json"; then
|
||||
echo "Error: adds.json does not record the approval" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== replaying what only an approved photo can answer"
|
||||
"$PYTHON" "$REPO_ROOT/tool/e2e_media.py" --upload "$FAKE_URL" >/dev/null
|
||||
replay "$REPO_ROOT"/fake_signal/approved/*.yaml
|
||||
}
|
||||
|
||||
enforce_the_time_budget() {
|
||||
local started="$1"
|
||||
local budget="${E2E_BUDGET_SECONDS:-40}"
|
||||
local elapsed=$(( $(date +%s) - started ))
|
||||
echo "== end to end took ${elapsed}s (budget ${budget}s)"
|
||||
if ((elapsed > budget)); then
|
||||
echo "Error: the offline run took ${elapsed}s, over its ${budget}s budget." >&2
|
||||
echo " Either the new check earns its seconds and the budget moves," >&2
|
||||
echo " or something got slower and this is the report of it." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
49
scripts/ensure_venv.sh
Executable file
49
scripts/ensure_venv.sh
Executable file
@ -0,0 +1,49 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================================================
|
||||
# Make sure .venv exists and has this project installed in it.
|
||||
#
|
||||
# run.sh used to answer a missing venv with a message telling you to type
|
||||
# "uv venv .venv && uv pip install -e '.[dev]'" yourself, and tool/tagger.py
|
||||
# did not even do that -- it failed with ModuleNotFoundError: fastapi. Both
|
||||
# are the same rule broken: a script owns its dependencies.
|
||||
#
|
||||
# Sourced or executed. Idempotent: the common case is two file tests and no
|
||||
# subprocess at all, so entry points can call it unconditionally.
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ensure_venv() {
|
||||
local repo_root="$1"
|
||||
local venv="${repo_root}/.venv"
|
||||
local python="${venv}/bin/python"
|
||||
|
||||
# Already good: the interpreter exists and the project imports from it.
|
||||
if [[ -x "$python" ]] && "$python" -c 'import fastapi, signal_bot' 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "== setting up ${venv} (one-off)" >&2
|
||||
if [[ ! -x "$python" ]]; then
|
||||
if command -v uv >/dev/null; then
|
||||
uv venv "$venv"
|
||||
elif ! python3 -m venv "$venv"; then
|
||||
echo "Error: could not create a virtualenv at ${venv}" >&2
|
||||
echo " python3 -m venv failed; on Arch: sudo pacman -S python" >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Editable, so a later git pull updates what the tagger actually runs.
|
||||
if command -v uv >/dev/null; then
|
||||
VIRTUAL_ENV="$venv" uv pip install -e "${repo_root}[dev]"
|
||||
else
|
||||
"$python" -m pip install -e "${repo_root}[dev]"
|
||||
fi
|
||||
}
|
||||
|
||||
# Executed rather than sourced: do the thing, for callers that are not bash.
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
ensure_venv "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
fi
|
||||
@ -47,8 +47,70 @@ bot_port() {
|
||||
printf '%s' "${port:-$DEFAULT_PORT}"
|
||||
}
|
||||
|
||||
# exiftool is what !add_photo strips uploads with, and the strip is not
|
||||
# optional: without it every upload is refused rather than stored dirty.
|
||||
# Installed here rather than documented, per the repo's own rule that a
|
||||
# script owns its dependencies.
|
||||
ensure_exiftool() {
|
||||
if ! command -v exiftool >/dev/null; then
|
||||
echo "Installing perl-image-exiftool (needed by !add_photo)..."
|
||||
if ! sudo pacman -S --needed --noconfirm perl-image-exiftool; then
|
||||
die "could not install perl-image-exiftool; run: sudo pacman -S perl-image-exiftool"
|
||||
fi
|
||||
fi
|
||||
# `command -v` answers for *this shell*, which is not who has to find it.
|
||||
# Arch installs to /usr/bin/vendor_perl, on an interactive PATH and not on
|
||||
# a systemd unit's -- so on 2026-08-31 the bot refused every upload while
|
||||
# exiftool sat plainly on the installer's PATH. Ask the bot's own resolver.
|
||||
if ! env -i "PATH=/usr/local/bin:/usr/bin" "${REPO_ROOT}/.venv/bin/python" -c \
|
||||
'import sys; from signal_bot.media_strip import locate; sys.exit(0 if locate() else 1)'; then
|
||||
die "exiftool is installed but signal_bot.media_strip.locate() cannot find it under the unit's PATH"
|
||||
fi
|
||||
}
|
||||
|
||||
# ffmpeg drops the metadata *tracks* exiftool cannot see. An iPhone clip
|
||||
# carries mebx timed-metadata holding per-frame face bounding boxes and a
|
||||
# face id, and those survive a complete "-all= -time:all=" pass because they
|
||||
# live in mdat as opaque samples, not as tags. Without ffmpeg every video is
|
||||
# refused rather than stored with them.
|
||||
ensure_ffmpeg() {
|
||||
if ! command -v ffmpeg >/dev/null || ! command -v ffprobe >/dev/null; then
|
||||
echo "Installing ffmpeg (needed to store video)..."
|
||||
if ! sudo pacman -S --needed --noconfirm ffmpeg; then
|
||||
die "could not install ffmpeg; run: sudo pacman -S ffmpeg"
|
||||
fi
|
||||
fi
|
||||
# Same trap as exiftool: ask the bot's own resolver, under the unit's PATH.
|
||||
if ! env -i "PATH=/usr/local/bin:/usr/bin" "${REPO_ROOT}/.venv/bin/python" -c \
|
||||
'import sys; from signal_bot.media_tracks import locate; sys.exit(0 if locate("ffmpeg") and locate("ffprobe") else 1)'; then
|
||||
die "ffmpeg is installed but signal_bot.media_tracks.locate() cannot find it under the unit's PATH"
|
||||
fi
|
||||
}
|
||||
|
||||
# The management page is built, not committed: dist/ is a build output and
|
||||
# this repository does not carry those. Verified by looking at the artefact
|
||||
# rather than trusting the exit code, the same way exiftool is.
|
||||
build_web() {
|
||||
local web="${REPO_ROOT}/web"
|
||||
if ! command -v pnpm >/dev/null; then
|
||||
echo "Installing pnpm (the management page is built with it)..."
|
||||
if ! sudo pacman -S --needed --noconfirm pnpm; then
|
||||
die "could not install pnpm; run: sudo pacman -S pnpm"
|
||||
fi
|
||||
fi
|
||||
command -v node >/dev/null || die "node not found; the page needs node >= 24"
|
||||
echo "Building the management page..."
|
||||
(cd "$web" && pnpm install --frozen-lockfile && pnpm build) ||
|
||||
die "pnpm build failed in ${web}"
|
||||
[[ -f "${web}/dist/index.html" ]] ||
|
||||
die "pnpm build reported success but ${web}/dist/index.html is not there"
|
||||
}
|
||||
|
||||
validate_requirements() {
|
||||
command -v systemctl >/dev/null || die "systemctl not found; this needs systemd"
|
||||
ensure_exiftool
|
||||
ensure_ffmpeg
|
||||
build_web
|
||||
[[ -f "${REPO_ROOT}/.env" ]] ||
|
||||
die "no ${REPO_ROOT}/.env -- run scripts/register_bot.sh first"
|
||||
[[ -x "${REPO_ROOT}/run.sh" ]] || die "no executable ${REPO_ROOT}/run.sh"
|
||||
|
||||
219
scripts/personal_data_sources.py
Executable file
219
scripts/personal_data_sources.py
Executable file
@ -0,0 +1,219 @@
|
||||
#!/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 Any, 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
|
||||
|
||||
# How much of a group id has to appear before it counts as that group. Eight
|
||||
# base64 characters are 48 bits: unique enough that a coincidence is not
|
||||
# worth worrying about, short enough to catch a truncated quotation.
|
||||
PREFIX: Final = 8
|
||||
|
||||
|
||||
def _pack_names(directory: Path) -> set[str]:
|
||||
"""Every trigger word in a photo pack manifest -- i.e. every name."""
|
||||
document = _pack_document(directory / "people.json")
|
||||
entries = [*document.get("people", []), *document.get("combos", [])]
|
||||
return {
|
||||
str(trigger).lstrip("!").strip()
|
||||
for entry in entries
|
||||
for trigger in entry.get("triggers", [])
|
||||
}
|
||||
|
||||
|
||||
def _custom_labels(directory: Path) -> set[str]:
|
||||
"""Every label !add_photo minted or proposed -- names too, however they got there.
|
||||
|
||||
Read separately from _pack_names because they are a different kind of
|
||||
thing. A people.json trigger is curated by the tagger; a custom label is
|
||||
free text somebody typed into a group chat, and treating arbitrary free
|
||||
text as a forbidden literal is what lets one message wedge this gate
|
||||
permanently. See _repo_vocabulary for the half that makes that safe.
|
||||
|
||||
The approval queue's sidecars (``pending/*.json``) count too: a label
|
||||
waiting there is the same typed text, one click earlier.
|
||||
"""
|
||||
labels: set[str] = set()
|
||||
for entry in _pack_document(directory / "custom.json").values():
|
||||
if isinstance(entry, list):
|
||||
labels |= {str(label).lstrip("!").strip() for label in entry}
|
||||
for sidecar in sorted((directory / "pending").glob("*.json")):
|
||||
proposed = _pack_document(sidecar).get("labels")
|
||||
if isinstance(proposed, list):
|
||||
labels |= {str(label).lstrip("!").strip() for label in proposed}
|
||||
return labels
|
||||
|
||||
|
||||
def _pack_document(path: Path) -> dict[str, Any]:
|
||||
"""One album manifest, or an empty mapping when it is absent or broken."""
|
||||
if not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
document = json.loads(path.read_text(encoding="utf-8"))
|
||||
except OSError, json.JSONDecodeError:
|
||||
return {}
|
||||
return document if isinstance(document, dict) else {}
|
||||
|
||||
|
||||
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.<base64>" 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.")
|
||||
# The leading run as well as the whole id: a log line quoted into a
|
||||
# commit message says "group.<first few chars>..." and stops, and
|
||||
# matching only the full id let exactly that through once.
|
||||
inner.add(payload[:PREFIX])
|
||||
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)),
|
||||
(f"pack custom tags ({directory})", _custom_labels(directory) - _repo_vocabulary()),
|
||||
("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
|
||||
|
||||
|
||||
def _repo_vocabulary() -> set[str]:
|
||||
"""Words this public repository already contains as its own command names.
|
||||
|
||||
Subtracted from the custom labels, and only from those. A word that is
|
||||
committed here *already* cannot be a private name this gate is stopping
|
||||
from getting out -- it is out. Without the subtraction, one member
|
||||
sending "!add_photo kot <photo>" would mint the label "kot", and the gate
|
||||
would then find "kot" in aliases.json and refuse every commit from then
|
||||
on, fixable only by hand-editing an album the committer cannot see.
|
||||
|
||||
people.json triggers are deliberately *not* filtered this way: those are
|
||||
curated real names, and one of them turning up in the repository is
|
||||
exactly the leak worth failing on.
|
||||
"""
|
||||
try:
|
||||
document = json.loads((REPO / "signal_bot" / "aliases.json").read_text(encoding="utf-8"))
|
||||
except OSError, json.JSONDecodeError:
|
||||
return set()
|
||||
return {
|
||||
str(trigger).lstrip("!").strip().lower()
|
||||
for name, triggers in document.items()
|
||||
if not name.startswith("_") and isinstance(triggers, list)
|
||||
for trigger in triggers
|
||||
}
|
||||
121
scripts/reload.sh
Executable file
121
scripts/reload.sh
Executable file
@ -0,0 +1,121 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ============================================================================
|
||||
# Make the running bot pick up a change, and prove that it did.
|
||||
#
|
||||
# Two kinds of change, two mechanisms:
|
||||
#
|
||||
# scripts/reload.sh [cmd] data changed (aliases, an album, a
|
||||
# command switched off): POST /_reload,
|
||||
# the table is rebuilt in place, nothing
|
||||
# restarts and no frame is lost.
|
||||
# scripts/reload.sh --code [cmd] code changed: restart the unit, wait for
|
||||
# /healthz to go green again. Lossless too
|
||||
# (signal-cli replays what arrived), but
|
||||
# a deploy step -- so it is the flag, not
|
||||
# the default.
|
||||
#
|
||||
# With [cmd], the bot then types that command at itself in the test group,
|
||||
# so "reloaded" means "answered", not "returned 200". E.g.
|
||||
#
|
||||
# scripts/reload.sh '!nowaosoba'
|
||||
# scripts/reload.sh --code '!pomoc'
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_NAME="$(basename "$0")"
|
||||
readonly SCRIPT_NAME
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
readonly SCRIPT_DIR
|
||||
readonly BOT="http://127.0.0.1:${BIND_PORT:-8137}"
|
||||
readonly UNIT="signal-bot"
|
||||
readonly TEST_GROUP="${RELOAD_GROUP:-testbot}"
|
||||
|
||||
RESTART=0
|
||||
PROBE=""
|
||||
|
||||
usage() {
|
||||
echo "Usage: $SCRIPT_NAME [--code] [command-to-selftest]"
|
||||
echo " --code restart the unit (code changed) instead of rebuilding the table"
|
||||
exit 0
|
||||
}
|
||||
|
||||
validate_requirements() {
|
||||
if ! command -v jq >/dev/null; then
|
||||
echo "Error: jq is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
wait_healthy() {
|
||||
local attempt=0
|
||||
# 150 x 0.4 s: a restart is ~1 s, but signal-cli can take a while to
|
||||
# accept the websocket again after its own reconnect.
|
||||
until [[ "$(curl -s "${BOT}/healthz" | jq -r '.status // "down"')" == "ok" ]]; do
|
||||
attempt=$((attempt + 1))
|
||||
if [[ $attempt -ge 150 ]]; then
|
||||
echo "Error: ${BOT}/healthz never reported ok" >&2
|
||||
curl -s "${BOT}/healthz" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.4
|
||||
done
|
||||
}
|
||||
|
||||
reload_data() {
|
||||
local reply status
|
||||
reply="$(curl -s -X POST "${BOT}/_reload")"
|
||||
status="$(jq -r '.status // "?"' <<<"$reply")"
|
||||
case "$status" in
|
||||
reloaded)
|
||||
echo "reloaded: $(jq -r '.commands' <<<"$reply") commands live"
|
||||
;;
|
||||
kept)
|
||||
# The old table is still answering; the new data is what is broken.
|
||||
echo "Error: reload refused, previous table kept: $(jq -r '.reason' <<<"$reply")" >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "Error: unexpected answer from ${BOT}/_reload: ${reply}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
restart_code() {
|
||||
systemctl --user restart "$UNIT"
|
||||
wait_healthy
|
||||
echo "restarted: $(curl -s "${BOT}/healthz" | jq -r '"\(.version) \(.pope)"')"
|
||||
}
|
||||
|
||||
main() {
|
||||
validate_requirements
|
||||
if [[ $RESTART -eq 1 ]]; then
|
||||
restart_code
|
||||
else
|
||||
wait_healthy
|
||||
reload_data
|
||||
fi
|
||||
if [[ -n "$PROBE" ]]; then
|
||||
"${SCRIPT_DIR}/selftest.sh" "$TEST_GROUP" "$PROBE"
|
||||
fi
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--code)
|
||||
RESTART=1
|
||||
shift
|
||||
;;
|
||||
-h | --help)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
PROBE="$1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
main
|
||||
@ -44,6 +44,10 @@ PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
ReadWritePaths=@REPO_ROOT@
|
||||
# The photo album. ProtectHome=read-only covers it too, and !add_photo
|
||||
# writes there -- media, tags.json, custom.json and adds.json. The pack
|
||||
# does not move; this is the one path that has to become writable.
|
||||
ReadWritePaths=%h/.local/share/signal-bot
|
||||
ProtectKernelTunables=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
|
||||
@ -28,9 +28,16 @@ readonly FAILURES_BEFORE_RESTART="${FAILURES_BEFORE_RESTART:-2}"
|
||||
readonly MAX_RESTARTS="${MAX_RESTARTS:-3}"
|
||||
readonly RESTART_WINDOW="${RESTART_WINDOW:-21600}" # 6 hours
|
||||
readonly SETTLE_SECONDS="${SETTLE_SECONDS:-45}"
|
||||
# always: every transition pops. escalation: only the give-up does. never: none.
|
||||
# Default escalation, because the routine restart/recovery pair is exactly the
|
||||
# noise this knob exists to stop; journald still gets all of it either way.
|
||||
readonly NOTIFY_MODE="${WATCHDOG_NOTIFY:-escalation}"
|
||||
readonly NOTIFY_EXPIRE_MS="${WATCHDOG_NOTIFY_EXPIRE_MS:-20000}"
|
||||
|
||||
readonly FAILURE_FILE="${STATE_DIR}/consecutive_failures"
|
||||
readonly RESTART_FILE="${STATE_DIR}/restarts"
|
||||
readonly NOTIFY_ID_FILE="${STATE_DIR}/notify_id"
|
||||
readonly ESCALATED_FILE="${STATE_DIR}/escalated_at"
|
||||
|
||||
usage() {
|
||||
echo "Usage: $SCRIPT_NAME"
|
||||
@ -45,12 +52,59 @@ die() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# journald and the desktop, always both: the broken path IS Signal, so the
|
||||
# bot cannot tell anybody about this itself.
|
||||
# The escalation repeats on every firing for as long as the window lasts. The
|
||||
# journal wants all of it; the desktop wants one box. So the stamp gates the
|
||||
# POPUP only -- never the log lines, which is why the give-up text still
|
||||
# appears on stdout for every check that reaches it.
|
||||
escalation_is_new() {
|
||||
local now stamp=0
|
||||
now="$(date +%s)"
|
||||
if [[ -f "$ESCALATED_FILE" ]]; then
|
||||
read -r stamp <"$ESCALATED_FILE" || true
|
||||
[[ "$stamp" =~ ^[0-9]+$ ]] || stamp=0
|
||||
fi
|
||||
if ((now - stamp < RESTART_WINDOW)); then
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "$now" >"$ESCALATED_FILE"
|
||||
}
|
||||
|
||||
# One desktop slot, reused. Without --replace-id every firing stacked another
|
||||
# popup that xfce4-notifyd never expires, so a single outage left a screenful
|
||||
# of identical boxes to dismiss by hand -- 297 of them in one day.
|
||||
notify() {
|
||||
local urgency="$1" message="$2" previous=0 id
|
||||
case "$NOTIFY_MODE" in
|
||||
never) return 0 ;;
|
||||
escalation)
|
||||
if [[ "$urgency" != critical ]]; then
|
||||
return 0
|
||||
fi
|
||||
;;
|
||||
*) ;;
|
||||
esac
|
||||
if [[ "$urgency" == critical ]] && ! escalation_is_new; then
|
||||
return 0
|
||||
fi
|
||||
if [[ -f "$NOTIFY_ID_FILE" ]]; then
|
||||
read -r previous <"$NOTIFY_ID_FILE" || true
|
||||
[[ "$previous" =~ ^[0-9]+$ ]] || previous=0
|
||||
fi
|
||||
id="$(notify-send --print-id --replace-id="$previous" --urgency="$urgency" \
|
||||
--expire-time="$NOTIFY_EXPIRE_MS" --app-name=signal-bot \
|
||||
"signal-bot" "$message" 2>/dev/null)" || return 0
|
||||
if [[ "$id" =~ ^[0-9]+$ ]]; then
|
||||
printf '%s\n' "$id" >"$NOTIFY_ID_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
# journald and stdout always; the desktop only when notify() agrees. The broken
|
||||
# path IS Signal, so the bot cannot tell anybody about this itself -- but it
|
||||
# only needs to say it once.
|
||||
alarm() {
|
||||
logger --priority user.warning --tag signal-api-watchdog -- "$1" || true
|
||||
echo "== $1"
|
||||
notify-send --urgency=critical --app-name=signal-bot "signal-bot" "$1" 2>/dev/null || true
|
||||
notify "${2:-normal}" "$1"
|
||||
}
|
||||
|
||||
validate_requirements() {
|
||||
@ -89,10 +143,17 @@ connection_is_alive() {
|
||||
return 1
|
||||
}
|
||||
|
||||
# Recovery re-arms the escalation: the next outage is a new event and deserves
|
||||
# its own box, rather than being silenced by a stamp from the previous one.
|
||||
clear_alarms() {
|
||||
rm -f "$ESCALATED_FILE"
|
||||
}
|
||||
|
||||
record_success() {
|
||||
local previous
|
||||
previous="$(read_count)"
|
||||
printf '0\n' >"$FAILURE_FILE"
|
||||
clear_alarms
|
||||
if ((previous >= FAILURES_BEFORE_RESTART)); then
|
||||
alarm "signal-api is reachable again after ${previous} failed probe(s)."
|
||||
fi
|
||||
@ -125,7 +186,7 @@ give_up() {
|
||||
This is probably not transient: the account may be deregistered, or the host \
|
||||
firewall may be blocking the container. Check 'docker exec ${CONTAINER} head -5 \
|
||||
/var/log/signal-cli-json-rpc-1/out.log' and 'docker exec ${CONTAINER} getent hosts \
|
||||
chat.signal.org'. No further restarts until the window passes."
|
||||
chat.signal.org'. No further restarts until the window passes." critical
|
||||
}
|
||||
|
||||
restart_container() {
|
||||
@ -135,6 +196,7 @@ restart_container() {
|
||||
sleep "$SETTLE_SECONDS"
|
||||
if connection_is_alive; then
|
||||
printf '0\n' >"$FAILURE_FILE"
|
||||
clear_alarms
|
||||
alarm "signal-api is back: the connection to Signal is alive again."
|
||||
return 0
|
||||
fi
|
||||
|
||||
@ -33,70 +33,8 @@ ok() {
|
||||
echo " ok: $1"
|
||||
}
|
||||
|
||||
# Rebuild a pristine sandbox: stub binaries, stub answers, empty state.
|
||||
setup() {
|
||||
rm -rf "$WORK"
|
||||
mkdir -p "$WORK/bin" "$WORK/state"
|
||||
printf '200' >"$WORK/bot_code"
|
||||
printf '200' >"$WORK/api_code"
|
||||
printf '0' >"$WORK/fix_on_restart"
|
||||
|
||||
cat >"$WORK/bin/curl" <<EOF
|
||||
#!/bin/bash
|
||||
url="\${*: -1}"
|
||||
case "\$url" in
|
||||
*/healthz) cat "$WORK/bot_code" ;;
|
||||
*) cat "$WORK/api_code" ;;
|
||||
esac
|
||||
EOF
|
||||
|
||||
cat >"$WORK/bin/docker" <<EOF
|
||||
#!/bin/bash
|
||||
echo "\$@" >> "$WORK/docker.log"
|
||||
if [[ "\$(cat "$WORK/fix_on_restart")" == "1" ]]; then
|
||||
printf '200' > "$WORK/bot_code"
|
||||
printf '200' > "$WORK/api_code"
|
||||
fi
|
||||
EOF
|
||||
chmod +x "$WORK/bin/curl" "$WORK/bin/docker"
|
||||
}
|
||||
|
||||
run_watchdog() {
|
||||
PATH="$WORK/bin:$PATH" \
|
||||
PHONE_NUMBER="+15550000000" \
|
||||
STATE_DIRECTORY="$WORK/state" \
|
||||
SETTLE_SECONDS=0 \
|
||||
FAILURES_BEFORE_RESTART=2 \
|
||||
MAX_RESTARTS=2 \
|
||||
RESTART_WINDOW=3600 \
|
||||
"$WATCHDOG" 2>&1
|
||||
}
|
||||
|
||||
expect_contains() {
|
||||
if [[ "$1" == *"$2"* ]]; then
|
||||
ok "$3"
|
||||
else
|
||||
fail "$3 -- got: $1"
|
||||
fi
|
||||
}
|
||||
|
||||
expect_missing() {
|
||||
if [[ "$1" != *"$2"* ]]; then
|
||||
ok "$3"
|
||||
else
|
||||
fail "$3 -- unexpectedly found $2"
|
||||
fi
|
||||
}
|
||||
|
||||
expect_file() {
|
||||
local actual
|
||||
actual="$(cat "$1" 2>/dev/null || echo MISSING)"
|
||||
if [[ "$actual" == "$2" ]]; then
|
||||
ok "$3"
|
||||
else
|
||||
fail "$3 -- $1 is '$actual', expected '$2'"
|
||||
fi
|
||||
}
|
||||
# shellcheck source=scripts/test_watchdog_harness.sh
|
||||
source "${REPO_ROOT}/scripts/test_watchdog_harness.sh"
|
||||
|
||||
test_healthy_is_free() {
|
||||
echo "== a healthy bot needs no restart and no authenticated probe"
|
||||
@ -108,6 +46,7 @@ test_healthy_is_free() {
|
||||
else
|
||||
ok "docker was never called"
|
||||
fi
|
||||
expect_notifications 0 "a healthy check pops nothing"
|
||||
}
|
||||
|
||||
test_one_failure_waits() {
|
||||
@ -138,6 +77,7 @@ test_confirmed_failure_restarts_and_recovers() {
|
||||
expect_contains "$(cat "$WORK/docker.log")" "restart signal-api" "the container was restarted"
|
||||
expect_contains "$out" "is back" "the recovery is announced"
|
||||
expect_file "$WORK/state/consecutive_failures" "0" "the counter was cleared"
|
||||
expect_notifications 0 "the routine restart/recovery pair stays out of the desktop"
|
||||
}
|
||||
|
||||
test_restart_that_does_not_help() {
|
||||
@ -170,6 +110,31 @@ test_gives_up_instead_of_looping() {
|
||||
fi
|
||||
expect_contains "$out" "still deaf" "the give-up message explains what to check"
|
||||
expect_contains "$out" "out.log" "the give-up message names the log to read"
|
||||
# Three firings reach give_up here; the journal got all three (asserted
|
||||
# above, on the LAST run's stdout) and the desktop got one.
|
||||
expect_notifications 1 "a sustained give-up window pops exactly once"
|
||||
}
|
||||
|
||||
test_notify_modes() {
|
||||
echo "== WATCHDOG_NOTIFY decides what reaches the desktop"
|
||||
setup
|
||||
printf '400' >"$WORK/bot_code"
|
||||
printf '400' >"$WORK/api_code"
|
||||
WATCHDOG_NOTIFY=never run_watchdog >/dev/null
|
||||
WATCHDOG_NOTIFY=never run_watchdog >/dev/null
|
||||
expect_notifications 0 "never pops nothing at all"
|
||||
|
||||
setup
|
||||
printf '400' >"$WORK/bot_code"
|
||||
printf '400' >"$WORK/api_code"
|
||||
WATCHDOG_NOTIFY=always run_watchdog >/dev/null
|
||||
WATCHDOG_NOTIFY=always run_watchdog >/dev/null
|
||||
# Two sends -- "restarting" then "still deaf" -- collapsed by --replace-id
|
||||
# into one box on screen. Counting sends is what makes the reuse visible.
|
||||
expect_notifications 2 "always pops the routine pair the default suppresses"
|
||||
expect_contains "$(cat "$WORK/notify.log")" "--replace-id" \
|
||||
"the popup reuses one desktop slot instead of stacking"
|
||||
expect_file "$WORK/state/notify_id" "4242" "the slot id is persisted for the next firing"
|
||||
}
|
||||
|
||||
test_requires_a_number() {
|
||||
@ -216,6 +181,7 @@ main() {
|
||||
test_confirmed_failure_restarts_and_recovers
|
||||
test_restart_that_does_not_help
|
||||
test_gives_up_instead_of_looping
|
||||
test_notify_modes
|
||||
test_requires_a_number
|
||||
|
||||
echo "============================================================"
|
||||
|
||||
106
scripts/test_watchdog_harness.sh
Normal file
106
scripts/test_watchdog_harness.sh
Normal file
@ -0,0 +1,106 @@
|
||||
# shellcheck shell=bash
|
||||
# ============================================================================
|
||||
# The sandbox and the assertions for test_watchdog.sh.
|
||||
#
|
||||
# Split out when the two together crossed the shared 250-line cap. Sourced,
|
||||
# never executed: it defines the stub PATH the suite runs against and the
|
||||
# expect_* helpers, and it owns WORK/FAILURES set by its caller.
|
||||
# ============================================================================
|
||||
|
||||
# Rebuild a pristine sandbox: stub binaries, stub answers, empty state.
|
||||
setup() {
|
||||
rm -rf "$WORK"
|
||||
mkdir -p "$WORK/bin" "$WORK/state"
|
||||
printf '200' >"$WORK/bot_code"
|
||||
printf '200' >"$WORK/api_code"
|
||||
printf '0' >"$WORK/fix_on_restart"
|
||||
|
||||
cat >"$WORK/bin/curl" <<EOF
|
||||
#!/bin/bash
|
||||
url="\${*: -1}"
|
||||
case "\$url" in
|
||||
*/healthz) cat "$WORK/bot_code" ;;
|
||||
*) cat "$WORK/api_code" ;;
|
||||
esac
|
||||
EOF
|
||||
|
||||
cat >"$WORK/bin/docker" <<EOF
|
||||
#!/bin/bash
|
||||
echo "\$@" >> "$WORK/docker.log"
|
||||
if [[ "\$(cat "$WORK/fix_on_restart")" == "1" ]]; then
|
||||
printf '200' > "$WORK/bot_code"
|
||||
printf '200' > "$WORK/api_code"
|
||||
fi
|
||||
EOF
|
||||
|
||||
# Stubbed because they are NOT stubbed by default: notify-send and logger
|
||||
# resolve to the real binaries, so this suite used to fire a dozen genuine
|
||||
# critical popups per run -- and it runs from pre-commit, which made every
|
||||
# commit spam the desktop. notify.log is now also what proves the dedup.
|
||||
cat >"$WORK/bin/notify-send" <<EOF
|
||||
#!/bin/bash
|
||||
echo "\$@" >> "$WORK/notify.log"
|
||||
echo 4242
|
||||
EOF
|
||||
|
||||
cat >"$WORK/bin/logger" <<EOF
|
||||
#!/bin/bash
|
||||
echo "\$@" >> "$WORK/logger.log"
|
||||
EOF
|
||||
chmod +x "$WORK/bin/curl" "$WORK/bin/docker" "$WORK/bin/notify-send" "$WORK/bin/logger"
|
||||
}
|
||||
|
||||
notify_count() {
|
||||
[[ -f "$WORK/notify.log" ]] || {
|
||||
printf '0'
|
||||
return 0
|
||||
}
|
||||
grep -c '' "$WORK/notify.log"
|
||||
}
|
||||
|
||||
expect_notifications() {
|
||||
local actual
|
||||
actual="$(notify_count)"
|
||||
if [[ "$actual" == "$1" ]]; then
|
||||
ok "$2"
|
||||
else
|
||||
fail "$2 -- got $actual popup(s), expected $1"
|
||||
fi
|
||||
}
|
||||
|
||||
run_watchdog() {
|
||||
PATH="$WORK/bin:$PATH" \
|
||||
PHONE_NUMBER="+15550000000" \
|
||||
STATE_DIRECTORY="$WORK/state" \
|
||||
SETTLE_SECONDS=0 \
|
||||
FAILURES_BEFORE_RESTART=2 \
|
||||
MAX_RESTARTS=2 \
|
||||
RESTART_WINDOW=3600 \
|
||||
"$WATCHDOG" 2>&1
|
||||
}
|
||||
|
||||
expect_contains() {
|
||||
if [[ "$1" == *"$2"* ]]; then
|
||||
ok "$3"
|
||||
else
|
||||
fail "$3 -- got: $1"
|
||||
fi
|
||||
}
|
||||
|
||||
expect_missing() {
|
||||
if [[ "$1" != *"$2"* ]]; then
|
||||
ok "$3"
|
||||
else
|
||||
fail "$3 -- unexpectedly found $2"
|
||||
fi
|
||||
}
|
||||
|
||||
expect_file() {
|
||||
local actual
|
||||
actual="$(cat "$1" 2>/dev/null || echo MISSING)"
|
||||
if [[ "$actual" == "$2" ]]; then
|
||||
ok "$3"
|
||||
else
|
||||
fail "$3 -- $1 is '$actual', expected '$2'"
|
||||
fi
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"_comment": "Command trigger words, including the styled Unicode variants people actually type. These live as data, not source, because they are data: adding an alias should never mean touching Python.",
|
||||
"_comment": "Command trigger words, including the styled Unicode variants people actually type. These live as data, not source, because they are data: adding an alias should never mean touching Python. One trap worth knowing before you edit: !poems sits two edits from !pies and passes test_no_two_commands_are_within_reach_of_each_other only because !poem is one edit away and wins the tie. Removing !poem would fail the suite at !poems, which is nowhere near where the change was made.",
|
||||
"cat": [
|
||||
"!kot",
|
||||
"!koty",
|
||||
@ -30,6 +30,40 @@
|
||||
"!geckon",
|
||||
"!geckos"
|
||||
],
|
||||
"trap": [
|
||||
"!pułapka",
|
||||
"!pulapka",
|
||||
"!pułapki",
|
||||
"!pulapki",
|
||||
"!pułapkę",
|
||||
"!pulapke",
|
||||
"!trap",
|
||||
"!traps",
|
||||
"!trapy",
|
||||
"!trapper",
|
||||
"!sidła",
|
||||
"!sidla",
|
||||
"!sidło",
|
||||
"!sidlo",
|
||||
"!wnyk",
|
||||
"!wnyki",
|
||||
"!potrzask",
|
||||
"!zasadzka",
|
||||
"!klatka",
|
||||
"!samołówka",
|
||||
"!samolowka",
|
||||
"!beartrap",
|
||||
"!bear_trap",
|
||||
"!snare",
|
||||
"!snares",
|
||||
"!mousetrap"
|
||||
],
|
||||
"art": [
|
||||
"!sztuka",
|
||||
"!sztukę",
|
||||
"!sztuki",
|
||||
"!art"
|
||||
],
|
||||
"pope": [
|
||||
"!papież",
|
||||
"!papiez",
|
||||
@ -41,6 +75,17 @@
|
||||
"!pontifex",
|
||||
"!𝗽𝗮𝗽𝗶𝗲𝘇"
|
||||
],
|
||||
"poem": [
|
||||
"!wiersz",
|
||||
"!wiersze",
|
||||
"!wiersza",
|
||||
"!wierszyk",
|
||||
"!poem",
|
||||
"!poems",
|
||||
"!poezja",
|
||||
"!poezje",
|
||||
"!poetry"
|
||||
],
|
||||
"stats_today": [
|
||||
"!staty",
|
||||
"!statystyki",
|
||||
@ -83,5 +128,74 @@
|
||||
"!ja",
|
||||
"!moje",
|
||||
"!mnie"
|
||||
],
|
||||
"command_stats": [
|
||||
"!analityka",
|
||||
"!command_stats",
|
||||
"!commandstats",
|
||||
"!analytics",
|
||||
"!uzycie",
|
||||
"!użycie",
|
||||
"!ranking"
|
||||
],
|
||||
"help": [
|
||||
"!pomoc",
|
||||
"!pomocy",
|
||||
"!help",
|
||||
"!komendy",
|
||||
"!commands"
|
||||
],
|
||||
"add_photo": [
|
||||
"!dodaj",
|
||||
"!dodaj_zdjecie",
|
||||
"!dodajzdjecie",
|
||||
"!add_photo",
|
||||
"!addphoto"
|
||||
],
|
||||
"random": [
|
||||
"!losowe",
|
||||
"!losuj",
|
||||
"!random",
|
||||
"!rand"
|
||||
],
|
||||
"suggest": [
|
||||
"!zaproponuj",
|
||||
"!propozycja",
|
||||
"!sugestia",
|
||||
"!suggest",
|
||||
"!pomysl"
|
||||
],
|
||||
"good_bot": [
|
||||
"!good bot",
|
||||
"!dobry bot",
|
||||
"!goodbot",
|
||||
"!dobrybot",
|
||||
"!good_bot",
|
||||
"!dobry_bot"
|
||||
],
|
||||
"food_today": [
|
||||
"!co_jadł",
|
||||
"!co_jadl",
|
||||
"!cojadł",
|
||||
"!cojadl",
|
||||
"!jedzenie",
|
||||
"!dieta"
|
||||
],
|
||||
"workout_today": [
|
||||
"!co_ćwiczył",
|
||||
"!co_cwiczyl",
|
||||
"!coćwiczył",
|
||||
"!cocwiczyl",
|
||||
"!trening",
|
||||
"!workout"
|
||||
],
|
||||
"mute": [
|
||||
"!cisza",
|
||||
"!cicho",
|
||||
"!mute"
|
||||
],
|
||||
"unmute": [
|
||||
"!odcisz",
|
||||
"!wracaj"
|
||||
]
|
||||
}
|
||||
|
||||
10
signal_bot/analytics/__init__.py
Normal file
10
signal_bot/analytics/__init__.py
Normal file
@ -0,0 +1,10 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""The command history: what was run, by whom, when, and whether it worked.
|
||||
|
||||
A sibling of ``stats`` rather than part of it, because the two answer
|
||||
different questions from different tables. ``stats`` is the *message*
|
||||
history -- who said how much -- and is what the nightly report reads.
|
||||
This is the *command* history, and it exists because none of it was kept:
|
||||
the old record was two aggregates written only when a command succeeded,
|
||||
so a command that failed every time looked exactly like one nobody ran.
|
||||
"""
|
||||
146
signal_bot/analytics/adoption.py
Normal file
146
signal_bot/analytics/adoption.py
Normal file
@ -0,0 +1,146 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Family E, the adoption half: who came back, and what a release did.
|
||||
|
||||
Three questions that are about the bot rather than about any one command.
|
||||
|
||||
* **Retention** -- of the people who used it on their first day, how many
|
||||
were still using it N days later. On a history this short this is one or
|
||||
two points, and the panel says so rather than drawing a curve.
|
||||
* **Novelty** -- what a release actually did. ``DOCS-changelog.md`` is the
|
||||
only record of when each version shipped and it is the one the maintainer
|
||||
actually keeps, so the dates come from there rather than from git tags.
|
||||
* **Share of chat** -- what fraction of everything said in the group was a
|
||||
command. This is the only query in the package that reads
|
||||
``message_events``, and it is the number that says whether the bot is a
|
||||
fixture or a curiosity.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from signal_bot.stats.periods import shift
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import sqlite3
|
||||
from collections.abc import Sequence
|
||||
|
||||
from signal_bot.analytics.scope import Scope
|
||||
|
||||
FIRST_DAY_SQL: Final = """
|
||||
SELECT user_uuid, MIN(day) AS first_day
|
||||
FROM command_events
|
||||
WHERE (:group IS NULL OR group_id = :group)
|
||||
AND outcome = 'ok'
|
||||
GROUP BY user_uuid
|
||||
"""
|
||||
|
||||
ACTIVE_DAYS_SQL: Final = """
|
||||
SELECT DISTINCT user_uuid, day
|
||||
FROM command_events
|
||||
WHERE (:group IS NULL OR group_id = :group)
|
||||
AND outcome = 'ok'
|
||||
"""
|
||||
|
||||
SHARE_SQL: Final = """
|
||||
SELECT COUNT(*) AS messages, SUM(is_command) AS commands
|
||||
FROM message_events
|
||||
WHERE (:group IS NULL OR group_id = :group)
|
||||
AND local_hour >= 0
|
||||
"""
|
||||
|
||||
NEW_PEOPLE_SQL: Final = """
|
||||
SELECT day, COUNT(*) AS people
|
||||
FROM (
|
||||
SELECT user_uuid, MIN(day) AS day
|
||||
FROM command_events
|
||||
WHERE (:group IS NULL OR group_id = :group)
|
||||
AND outcome = 'ok'
|
||||
GROUP BY user_uuid
|
||||
)
|
||||
GROUP BY day
|
||||
ORDER BY day
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Share:
|
||||
"""How much of the group's talking is people talking to the bot."""
|
||||
|
||||
messages: int = 0
|
||||
commands: int = 0
|
||||
|
||||
@property
|
||||
def fraction(self) -> float:
|
||||
"""Commands as a share of every counted message."""
|
||||
return self.commands / self.messages if self.messages else 0.0
|
||||
|
||||
|
||||
def retention(conn: sqlite3.Connection, scope: Scope, horizon: int) -> list[tuple[int, int, int]]:
|
||||
"""(day offset, still active, cohort size) for offsets 0..horizon.
|
||||
|
||||
Days are stepped as dates, never as 86_400_000 milliseconds: Warsaw days
|
||||
are not all the same length and the arithmetic version drifts an hour
|
||||
twice a year, which would move somebody between buckets.
|
||||
"""
|
||||
firsts = {
|
||||
str(row["user_uuid"]): str(row["first_day"])
|
||||
for row in conn.execute(FIRST_DAY_SQL, scope.params())
|
||||
}
|
||||
if not firsts:
|
||||
return []
|
||||
active: set[tuple[str, str]] = {
|
||||
(str(row["user_uuid"]), str(row["day"]))
|
||||
for row in conn.execute(ACTIVE_DAYS_SQL, scope.params())
|
||||
}
|
||||
cohort = len(firsts)
|
||||
return [
|
||||
(
|
||||
offset,
|
||||
sum(1 for uuid, first in firsts.items() if (uuid, shift(first, offset)) in active),
|
||||
cohort,
|
||||
)
|
||||
for offset in range(horizon + 1)
|
||||
]
|
||||
|
||||
|
||||
def newcomers(conn: sqlite3.Connection, scope: Scope) -> list[tuple[str, int]]:
|
||||
"""(day, how many people used the bot for the first time)."""
|
||||
return [
|
||||
(str(row["day"]), int(row["people"]))
|
||||
for row in conn.execute(NEW_PEOPLE_SQL, scope.params())
|
||||
]
|
||||
|
||||
|
||||
def share_of_chat(conn: sqlite3.Connection, scope: Scope) -> Share:
|
||||
"""What fraction of the group's messages were commands.
|
||||
|
||||
Excludes the synthetic rows seeded from the pre-0.1.0 counter, which
|
||||
carry UNKNOWN_HOUR and no is_command flag -- counting them would put a
|
||||
whole day's messages in the denominator against no commands at all.
|
||||
"""
|
||||
# An aggregate SELECT returns one row even against an empty table, with
|
||||
# NULLs in it -- so there is no None to guard against, only the NULLs.
|
||||
row = conn.execute(SHARE_SQL, scope.params()).fetchone()
|
||||
return Share(messages=int(row["messages"] or 0), commands=int(row["commands"] or 0))
|
||||
|
||||
|
||||
def around_releases(
|
||||
series: Sequence[tuple[str, int]], releases: Sequence[tuple[str, str]], span: int
|
||||
) -> list[tuple[str, str, int, int]]:
|
||||
"""(version, day, uses just after, uses just before) for each release.
|
||||
|
||||
"Just after" is the release day and the ``span`` days following it;
|
||||
"just before" is the same number of days ending the day before. Equal
|
||||
windows, so the comparison means something even at the edges of a short
|
||||
history -- a release on the first recorded day gets a before-window of
|
||||
zero and reads as such rather than as a spike.
|
||||
"""
|
||||
counts = dict(series)
|
||||
out: list[tuple[str, str, int, int]] = []
|
||||
for version, day in releases:
|
||||
after = sum(counts.get(shift(day, offset), 0) for offset in range(span + 1))
|
||||
before = sum(counts.get(shift(day, -offset), 0) for offset in range(1, span + 1))
|
||||
out.append((version, day, after, before))
|
||||
return out
|
||||
182
signal_bot/analytics/backfill.py
Normal file
182
signal_bot/analytics/backfill.py
Normal file
@ -0,0 +1,182 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Recovering the command history that predates the table holding it.
|
||||
|
||||
Two sources, and they overlap, which is the whole difficulty. Summing them
|
||||
would roughly double every historical count and nothing would fail.
|
||||
|
||||
* **journald** knows *which* command ran and *when*, to the second, but has
|
||||
no group and no uuid -- ``dispatcher.py`` logs a display name because the
|
||||
line was written to be read by a human.
|
||||
* **daily_uses** knows the group, the uuid, the command and the day, and
|
||||
counts them correctly, but has no times of day at all.
|
||||
|
||||
So the join runs over ``message_events``, which already holds the group, the
|
||||
uuid, the real hour and the display name of every counted command -- and
|
||||
lacks only the command's *name*, which is exactly what journald has. Each
|
||||
matched row is a whole truthful record. ``daily_uses`` then fills the
|
||||
**residual**: for each (group, user, command, day), whatever its count says
|
||||
happened beyond what the join could account for. Nothing is counted twice.
|
||||
|
||||
Insert-only, always. There is no delete-then-rebuild pass, because journald
|
||||
has no retention configuration and will rotate: a second run after the log
|
||||
has aged out would delete rows it could no longer replace. Re-runnability
|
||||
comes from the natural key and ``ON CONFLICT DO NOTHING``.
|
||||
|
||||
One inaccuracy, stated rather than hidden. ``dispatcher.py`` logs "running
|
||||
command X" *before* the handler runs, so a journald row proves the command
|
||||
was entered, not that it finished. A handler that then crashed, or a
|
||||
``daily_limit`` or ``vote_gated`` refusal -- neither of which logged
|
||||
anything -- is imported as ``ok`` because nothing in the record says
|
||||
otherwise. Measured against the live database on 2026-08-31 the gap is about
|
||||
4%: 293 reconstructed invocations against 388 successes in ``daily_uses``,
|
||||
overshooting by roughly fifteen rows across all commands. Every such row
|
||||
carries ``source = 'journald'``, so the UI can and does say where it came
|
||||
from; only the ``live`` rows have outcomes that were actually observed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from signal_bot.analytics.backfill_join import Tally, from_journal
|
||||
from signal_bot.analytics.uses import (
|
||||
OUTCOME_OK,
|
||||
SOURCE_DAILY,
|
||||
SOURCE_LIVE,
|
||||
CommandEvent,
|
||||
)
|
||||
from signal_bot.stats.backfill import UNKNOWN_HOUR
|
||||
from signal_bot.stats.periods import MS_PER_SECOND, WARSAW, weekday_of
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
from signal_bot.analytics.journal import JournalLine
|
||||
from signal_bot.store import Store
|
||||
|
||||
# Recorded per group once a pass has run, in the same place and for the same
|
||||
# reason stats/backfill.py records its own marker.
|
||||
BACKFILL_KEY: Final = "command_backfill"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Report:
|
||||
"""What one pass did, and what it refused to guess at."""
|
||||
|
||||
journal_rows: int = 0
|
||||
daily_rows: int = 0
|
||||
unmatched: int = 0
|
||||
ambiguous: int = 0
|
||||
groups: int = 0
|
||||
|
||||
def lines(self) -> tuple[str, ...]:
|
||||
"""The summary a human reads. Never names anybody."""
|
||||
return (
|
||||
f"journald rows: {self.journal_rows}",
|
||||
f"daily_uses rows: {self.daily_rows}",
|
||||
f"dropped, no match: {self.unmatched}",
|
||||
f"dropped, ambiguous: {self.ambiguous}",
|
||||
f"groups marked: {self.groups}",
|
||||
)
|
||||
|
||||
|
||||
def midnight_ms(day: str) -> int:
|
||||
"""The first millisecond of a Warsaw calendar day.
|
||||
|
||||
Built from the date rather than by subtracting 86_400_000 from anything:
|
||||
Warsaw days are not all 24 hours, and the arithmetic spelling lands an
|
||||
hour out twice a year.
|
||||
"""
|
||||
start = dt.datetime.combine(dt.date.fromisoformat(day), dt.time.min, tzinfo=WARSAW)
|
||||
return int(start.timestamp() * MS_PER_SECOND)
|
||||
|
||||
|
||||
def live_floor(store: Store) -> int:
|
||||
"""The earliest live row, or "no live rows yet".
|
||||
|
||||
Every backfilled row must be strictly older than this. Without the
|
||||
cutoff, a second pass run after live recording had started would import
|
||||
journald lines describing invocations that already exist as ``live``
|
||||
rows -- with a different source, so the natural key would not stop them.
|
||||
"""
|
||||
row = store.commands.connection.execute(
|
||||
"SELECT MIN(invoked_at_ms) AS floor FROM command_events WHERE source = ?",
|
||||
(SOURCE_LIVE,),
|
||||
).fetchone()
|
||||
floor = row["floor"]
|
||||
# No live rows yet means nothing is off limits.
|
||||
return int(floor) if floor is not None else 1 << 62
|
||||
|
||||
|
||||
def _residual(store: Store, tally: Tally) -> list[CommandEvent]:
|
||||
"""Fill in whatever daily_uses counted that the join could not explain.
|
||||
|
||||
``uses`` is the truth about how many times something ran that day. The
|
||||
join accounts for some of them exactly; this adds the remainder at day
|
||||
granularity, with no hour and no durations, so the total is right even
|
||||
where the detail is missing.
|
||||
"""
|
||||
events: list[CommandEvent] = []
|
||||
rows = store.commands.connection.execute(
|
||||
"SELECT group_id, user_uuid, command, day, uses FROM daily_uses ORDER BY day, command"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
key = (str(row["group_id"]), str(row["user_uuid"]), str(row["command"]), str(row["day"]))
|
||||
remaining = int(row["uses"]) - tally.matched[key]
|
||||
day = str(row["day"])
|
||||
base = midnight_ms(day)
|
||||
events.extend(
|
||||
CommandEvent(
|
||||
group_id=key[0],
|
||||
user_uuid=key[1],
|
||||
command=key[2],
|
||||
outcome=OUTCOME_OK,
|
||||
# Deterministic, so a second pass produces the identical key
|
||||
# and inserts nothing. The same trick, for the same reason,
|
||||
# as seed_from_counter's sent_at_ms=index + 1.
|
||||
invoked_at_ms=base + index,
|
||||
day=day,
|
||||
local_hour=UNKNOWN_HOUR,
|
||||
weekday=weekday_of(day),
|
||||
source=SOURCE_DAILY,
|
||||
)
|
||||
for index in range(max(0, remaining))
|
||||
)
|
||||
return events
|
||||
|
||||
|
||||
def plan(store: Store, lines: Iterable[JournalLine]) -> tuple[Report, list[CommandEvent]]:
|
||||
"""Work out what would be imported, writing nothing.
|
||||
|
||||
Separate from ``apply`` so that ``--dry-run`` reports the real numbers
|
||||
rather than an estimate of them. Rolling back would not have worked:
|
||||
``CommandEventStore.record`` commits per row, exactly as ``EventStore``
|
||||
does, so by the time a caller could roll back there is nothing left to
|
||||
roll back.
|
||||
"""
|
||||
floor = live_floor(store)
|
||||
tally = from_journal(store, lines, floor)
|
||||
residual = _residual(store, tally)
|
||||
events = [*tally.events, *residual]
|
||||
return (
|
||||
Report(
|
||||
journal_rows=len(tally.events),
|
||||
daily_rows=len(residual),
|
||||
unmatched=tally.unmatched,
|
||||
ambiguous=tally.ambiguous,
|
||||
groups=len({event.group_id for event in events}),
|
||||
),
|
||||
events,
|
||||
)
|
||||
|
||||
|
||||
def apply(store: Store, events: Iterable[CommandEvent]) -> None:
|
||||
"""Write a planned import. Insert-only; a repeat run inserts nothing."""
|
||||
groups = set()
|
||||
for event in events:
|
||||
store.commands.record(event)
|
||||
groups.add(event.group_id)
|
||||
for group_id in sorted(groups):
|
||||
store.set_meta(group_id, BACKFILL_KEY, "done")
|
||||
180
signal_bot/analytics/backfill_join.py
Normal file
180
signal_bot/analytics/backfill_join.py
Normal file
@ -0,0 +1,180 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Attaching a command name from the journal to a message that was one.
|
||||
|
||||
Split out of ``backfill.py`` so both stay under the 250-line cap. This half
|
||||
is the part that can be wrong, so it is the part that refuses to guess.
|
||||
|
||||
The direction matters and is easy to get backwards. Iterating the journal
|
||||
and looking for a message means resolving a display name to a uuid and a
|
||||
group, which the journal does not know. Iterating ``message_events`` instead
|
||||
means every row already carries the group, the uuid, the day and the real
|
||||
hour, and the journal supplies the one field missing -- the command's name.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter, defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from signal_bot.analytics.uses import (
|
||||
OUTCOME_COOLDOWN,
|
||||
OUTCOME_OK,
|
||||
SOURCE_JOURNAL,
|
||||
CommandEvent,
|
||||
)
|
||||
from signal_bot.stats.periods import weekday_of
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import sqlite3
|
||||
from collections.abc import Iterable, Sequence
|
||||
|
||||
from signal_bot.analytics.journal import JournalLine
|
||||
from signal_bot.store import Store
|
||||
|
||||
# How far apart a message and the log line about it may be and still be the
|
||||
# same event.
|
||||
#
|
||||
# SYMMETRIC, and that is not obvious. The log line is written strictly after
|
||||
# the message arrives, so an asymmetric [0, +W] window looks correct -- and
|
||||
# throws away most of the data. Measured against the live database on
|
||||
# 2026-08-31: of 299 counted commands, 194 have a journal line *earlier*
|
||||
# than their own sent_at_ms, by up to 3.2 seconds, median -173 ms.
|
||||
#
|
||||
# The reason is that the two stamps come from two different clocks.
|
||||
# ``sent_at_ms`` is Signal's envelope timestamp, set on the *sender's phone*;
|
||||
# the journal stamp is this host's. Nothing keeps those in step, so their
|
||||
# difference is skew and can have either sign.
|
||||
#
|
||||
# 15 seconds each way is far wider than the 3.2 s of skew actually seen and
|
||||
# still narrower than a person's own repeat rate. Widening it cannot corrupt
|
||||
# anything either way: two different commands inside one window are dropped
|
||||
# as ambiguous rather than guessed at.
|
||||
WINDOW_MS: Final = 15_000
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _Candidate:
|
||||
"""One journal line, and whether the join has already spent it."""
|
||||
|
||||
line: JournalLine
|
||||
used: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Tally:
|
||||
"""The running result of a pass."""
|
||||
|
||||
events: list[CommandEvent] = field(default_factory=list)
|
||||
matched: Counter[tuple[str, str, str, str]] = field(default_factory=Counter)
|
||||
unmatched: int = 0
|
||||
ambiguous: int = 0
|
||||
|
||||
|
||||
def _by_name(lines: Iterable[JournalLine]) -> dict[str, list[_Candidate]]:
|
||||
"""Group journal lines by the display name they were logged for."""
|
||||
grouped: dict[str, list[_Candidate]] = defaultdict(list)
|
||||
for line in lines:
|
||||
grouped[line.display_name].append(_Candidate(line))
|
||||
for candidates in grouped.values():
|
||||
candidates.sort(key=lambda candidate: candidate.line.at_ms)
|
||||
return dict(grouped)
|
||||
|
||||
|
||||
def _assign(
|
||||
rows: Sequence[sqlite3.Row], grouped: dict[str, list[_Candidate]]
|
||||
) -> dict[int, _Candidate]:
|
||||
"""Pair each counted command with the journal line most likely to be it.
|
||||
|
||||
Closest pair first, globally, rather than best-for-each-row in turn.
|
||||
That difference is what makes two commands from one person seconds apart
|
||||
resolvable at all.
|
||||
|
||||
Per-row greedy sees both lines inside the window, cannot tell which is
|
||||
which, and drops both. Global assignment can: the clock skew between a
|
||||
sender's phone and this host is *systematic*, so the true pairs share
|
||||
almost the same offset and are the two smallest distances available.
|
||||
|
||||
Ties break on row order and then line order, so a re-run against the
|
||||
same inputs produces the same pairing. The backfill has to be
|
||||
re-runnable, and "mostly the same" is not that.
|
||||
"""
|
||||
pairs: list[tuple[int, int, int]] = []
|
||||
for index, row in enumerate(rows):
|
||||
candidates = grouped.get(str(row["display_name"]), [])
|
||||
for position, candidate in enumerate(candidates):
|
||||
delta = candidate.line.at_ms - int(row["sent_at_ms"])
|
||||
if abs(delta) <= WINDOW_MS:
|
||||
pairs.append((abs(delta), index, position))
|
||||
pairs.sort()
|
||||
|
||||
taken: dict[int, _Candidate] = {}
|
||||
for _distance, index, position in pairs:
|
||||
if index in taken:
|
||||
continue
|
||||
candidate = grouped[str(rows[index]["display_name"])][position]
|
||||
if candidate.used:
|
||||
continue
|
||||
candidate.used = True
|
||||
taken[index] = candidate
|
||||
return taken
|
||||
|
||||
|
||||
def _in_window(row: sqlite3.Row, grouped: dict[str, list[_Candidate]]) -> bool:
|
||||
"""Whether the journal held any line at all for this message."""
|
||||
sent_at_ms = int(row["sent_at_ms"])
|
||||
return any(
|
||||
abs(candidate.line.at_ms - sent_at_ms) <= WINDOW_MS
|
||||
for candidate in grouped.get(str(row["display_name"]), [])
|
||||
)
|
||||
|
||||
|
||||
def from_journal(store: Store, lines: Iterable[JournalLine], floor: int) -> Tally:
|
||||
"""Walk the counted commands and attach a name to each from the journal."""
|
||||
tally = Tally()
|
||||
grouped = _by_name(lines)
|
||||
rows = store.events.connection.execute(
|
||||
"""
|
||||
SELECT group_id, uuid, display_name, sent_at_ms, day, local_hour
|
||||
FROM message_events
|
||||
WHERE is_command = 1 AND local_hour >= 0 AND sent_at_ms < ?
|
||||
ORDER BY sent_at_ms
|
||||
""",
|
||||
(floor,),
|
||||
).fetchall()
|
||||
taken = _assign(rows, grouped)
|
||||
for index, row in enumerate(rows):
|
||||
chosen = taken.get(index)
|
||||
if chosen is None:
|
||||
# A message the journal cannot explain. Either it never held a
|
||||
# line for it -- vacuumed, or older than the log -- or every line
|
||||
# in its window went to a closer message, which is the honest
|
||||
# reading of "two of these happened and I have one record".
|
||||
# Dropped either way: a wrong attribution is worse than a gap,
|
||||
# because it is indistinguishable from data.
|
||||
if _in_window(row, grouped):
|
||||
tally.ambiguous += 1
|
||||
else:
|
||||
tally.unmatched += 1
|
||||
continue
|
||||
line = chosen.line
|
||||
outcome = OUTCOME_COOLDOWN if line.refused else OUTCOME_OK
|
||||
day = str(row["day"])
|
||||
tally.events.append(
|
||||
CommandEvent(
|
||||
group_id=str(row["group_id"]),
|
||||
user_uuid=str(row["uuid"]),
|
||||
command=line.command,
|
||||
outcome=outcome,
|
||||
invoked_at_ms=int(row["sent_at_ms"]),
|
||||
day=day,
|
||||
local_hour=int(row["local_hour"]),
|
||||
weekday=weekday_of(day),
|
||||
source=SOURCE_JOURNAL,
|
||||
)
|
||||
)
|
||||
if outcome == OUTCOME_OK:
|
||||
# Only successes are in daily_uses, so only successes may be
|
||||
# subtracted from it below.
|
||||
tally.matched[(str(row["group_id"]), str(row["uuid"]), line.command, day)] += 1
|
||||
return tally
|
||||
106
signal_bot/analytics/content.py
Normal file
106
signal_bot/analytics/content.py
Normal file
@ -0,0 +1,106 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Family F: which items get served, and how often the same one comes back.
|
||||
|
||||
Reads ``item_id``, which holds public identifiers only -- a Wikidata QID, a
|
||||
Commons page id, a Wolne Lektury slug, a content-addressed filename. Never a
|
||||
title, a caption or a name. That constraint is what makes this family
|
||||
possible at all: "the pope pool feels repetitive" is an impression, and
|
||||
"106 draws produced 61 distinct popes" is a number.
|
||||
|
||||
The repeat rate is the headline. For a pool of size N drawn uniformly, the
|
||||
expected number of distinct items after k draws is N(1 - (1 - 1/N)^k), so a
|
||||
distinct count well below that means the draw is not uniform -- which is
|
||||
exactly the bug that put one identical photograph into a live group chat
|
||||
when iNaturalist silently ignored ``order_by=random``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import sqlite3
|
||||
|
||||
from signal_bot.analytics.scope import Scope
|
||||
|
||||
ITEMS_SQL: Final = """
|
||||
SELECT command,
|
||||
COUNT(*) AS draws,
|
||||
COUNT(DISTINCT item_id) AS distinct_items
|
||||
FROM command_events
|
||||
WHERE (:group IS NULL OR group_id = :group)
|
||||
AND item_id IS NOT NULL
|
||||
AND outcome = 'ok'
|
||||
GROUP BY command
|
||||
ORDER BY draws DESC, command
|
||||
"""
|
||||
|
||||
TOP_ITEMS_SQL: Final = """
|
||||
SELECT command, item_id, COUNT(*) AS draws
|
||||
FROM command_events
|
||||
WHERE (:group IS NULL OR group_id = :group)
|
||||
AND item_id IS NOT NULL
|
||||
AND outcome = 'ok'
|
||||
GROUP BY command, item_id
|
||||
HAVING draws > 1
|
||||
ORDER BY draws DESC, command, item_id
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Pool:
|
||||
"""One command's draws, and how varied they turned out to be."""
|
||||
|
||||
command: str
|
||||
draws: int
|
||||
distinct: int
|
||||
|
||||
@property
|
||||
def repeat_rate(self) -> float:
|
||||
"""Share of draws that produced something already seen.
|
||||
|
||||
Zero means every draw was new. Approaching one means the pool is
|
||||
exhausted -- or was never as large as it looked.
|
||||
"""
|
||||
if self.draws <= 0:
|
||||
return 0.0
|
||||
return 1.0 - (self.distinct / self.draws)
|
||||
|
||||
|
||||
def pools(conn: sqlite3.Connection, scope: Scope) -> list[Pool]:
|
||||
"""Every command that names what it served, most drawn first."""
|
||||
return [
|
||||
Pool(
|
||||
command=str(row["command"]),
|
||||
draws=int(row["draws"]),
|
||||
distinct=int(row["distinct_items"]),
|
||||
)
|
||||
for row in conn.execute(ITEMS_SQL, scope.params())
|
||||
]
|
||||
|
||||
|
||||
def repeats(conn: sqlite3.Connection, scope: Scope) -> list[tuple[str, str, int]]:
|
||||
"""(command, item, times) for every item served more than once.
|
||||
|
||||
The identifiers are safe to show -- that is the whole reason the column
|
||||
may exist -- but they are only meaningful next to the command, so they
|
||||
are never returned on their own.
|
||||
"""
|
||||
return [
|
||||
(str(row["command"]), str(row["item_id"]), int(row["draws"]))
|
||||
for row in conn.execute(TOP_ITEMS_SQL, scope.params())
|
||||
]
|
||||
|
||||
|
||||
def expected_distinct(pool_size: int, draws: int) -> float:
|
||||
"""How many distinct items a *uniform* draw would have produced.
|
||||
|
||||
The comparison that turns a repeat rate into a verdict. A pool of 266
|
||||
traps drawn 19 times should yield about 18.4 distinct ones; yielding 9
|
||||
would mean the randomness is not what it claims, which has happened in
|
||||
this repository before and reached a live group chat.
|
||||
"""
|
||||
if pool_size <= 0 or draws <= 0:
|
||||
return 0.0
|
||||
return pool_size * (1.0 - ((pool_size - 1) / pool_size) ** draws)
|
||||
102
signal_bot/analytics/demand.py
Normal file
102
signal_bot/analytics/demand.py
Normal file
@ -0,0 +1,102 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Family D: what the group asked for that does not exist.
|
||||
|
||||
The management page's Ideas tab already lists ``unknown_commands`` with the
|
||||
words and the messages they came in. This adds the part it does not have:
|
||||
the same words **grouped by the trigger they nearly hit**, so that "17
|
||||
near-misses on !pułapka" reads as *this command wants another alias* rather
|
||||
than as seventeen unrelated typos.
|
||||
|
||||
The grouping is done here rather than in SQL because the distance function
|
||||
is ``nearest``'s, and it is optimal string alignment rather than a ratio --
|
||||
a transposition costs one edit there and shreds the matching blocks a ratio
|
||||
counts. Reimplementing that in SQL is not possible and approximating it
|
||||
would put a different answer on the page from the one the bot gives in the
|
||||
group.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from signal_bot.nearest import nearest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import sqlite3
|
||||
from collections.abc import Sequence
|
||||
|
||||
from signal_bot.analytics.scope import Scope
|
||||
|
||||
WORDS_SQL: Final = """
|
||||
SELECT word, COUNT(*) AS hits, MAX(sent_at_ms) AS last_at
|
||||
FROM unknown_commands
|
||||
WHERE (:group IS NULL OR group_id = :group)
|
||||
GROUP BY word
|
||||
ORDER BY hits DESC, word
|
||||
"""
|
||||
|
||||
FILED_SQL: Final = """
|
||||
SELECT COUNT(*) AS filed
|
||||
FROM suggestions
|
||||
WHERE (:group IS NULL OR group_id = :group)
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NearMiss:
|
||||
"""A real trigger, and how often somebody nearly typed it."""
|
||||
|
||||
target: str
|
||||
hits: int
|
||||
variants: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Demand:
|
||||
"""What the group tried to type, and what it filed on purpose."""
|
||||
|
||||
near_misses: tuple[NearMiss, ...] = ()
|
||||
unclaimed: int = 0
|
||||
unclaimed_words: int = 0
|
||||
filed: int = 0
|
||||
|
||||
|
||||
def collect(conn: sqlite3.Connection, scope: Scope, triggers: Sequence[str]) -> Demand:
|
||||
"""Group every unrecognised word by the command it was probably meant to be.
|
||||
|
||||
A word that resolves to nothing at all is counted but not attributed:
|
||||
those are the genuinely new ideas, and pretending one of them is a typo
|
||||
of an existing command is how a real request gets filed as noise.
|
||||
|
||||
The words themselves never leave this function. A half-typed name is
|
||||
still a name -- ``check_personal_data`` learns the names it refuses from
|
||||
the live database, so it catches ``anna`` and could never catch
|
||||
``!annna`` -- and the Ideas tab is the one page that already carries
|
||||
them, deliberately, and never writes them into the repository.
|
||||
"""
|
||||
hits: Counter[str] = Counter()
|
||||
variants: Counter[str] = Counter()
|
||||
unclaimed = 0
|
||||
unclaimed_words = 0
|
||||
for row in conn.execute(WORDS_SQL, scope.params()):
|
||||
word = str(row["word"])
|
||||
count = int(row["hits"])
|
||||
target = nearest(word, triggers)
|
||||
if target is None:
|
||||
unclaimed += count
|
||||
unclaimed_words += 1
|
||||
continue
|
||||
hits[target] += count
|
||||
variants[target] += 1
|
||||
filed_row = conn.execute(FILED_SQL, scope.params()).fetchone()
|
||||
return Demand(
|
||||
near_misses=tuple(
|
||||
NearMiss(target=target, hits=count, variants=variants[target])
|
||||
for target, count in sorted(hits.items(), key=lambda pair: (-pair[1], pair[0]))
|
||||
),
|
||||
unclaimed=unclaimed,
|
||||
unclaimed_words=unclaimed_words,
|
||||
filed=int(filed_row["filed"] or 0) if filed_row is not None else 0,
|
||||
)
|
||||
194
signal_bot/analytics/health.py
Normal file
194
signal_bot/analytics/health.py
Normal file
@ -0,0 +1,194 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Family C: which commands are broken, slow, or refusing people.
|
||||
|
||||
This is the family that had *no* data at all before ``command_events``:
|
||||
``store.record_use`` fires only on success, so a command that crashed every
|
||||
time was indistinguishable from one nobody ran.
|
||||
|
||||
Latency narrows to ``live``. A reconstructed row has no duration -- NULL,
|
||||
never 0, because a zero would drag a p50 down and read as a fast command.
|
||||
Every aggregate here therefore says ``handler_ms IS NOT NULL`` rather than
|
||||
relying on the source column, so a future third source with real timings
|
||||
would be included automatically and one without would not.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import sqlite3
|
||||
|
||||
from signal_bot.analytics.scope import Scope
|
||||
|
||||
# Which percentiles the panel shows. The median says what it is usually
|
||||
# like; the 95th says what it is like when somebody complains.
|
||||
P50: Final = 0.50
|
||||
P95: Final = 0.95
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Latency:
|
||||
"""How long one command takes, split the way DOCS-latency.md splits it."""
|
||||
|
||||
command: str
|
||||
samples: int
|
||||
handler_p50: int
|
||||
handler_p95: int
|
||||
send_p50: int
|
||||
upstream_p50: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Upstream:
|
||||
"""One external service, and how well it has been behaving."""
|
||||
|
||||
host: str
|
||||
calls: int
|
||||
failures: int
|
||||
p50_ms: int
|
||||
|
||||
@property
|
||||
def failure_rate(self) -> float:
|
||||
"""Share of calls that were not a 2xx."""
|
||||
return self.failures / self.calls if self.calls else 0.0
|
||||
|
||||
|
||||
GATES_SQL: Final = """
|
||||
SELECT command, outcome, COUNT(*) AS n
|
||||
FROM command_events
|
||||
WHERE (:group IS NULL OR group_id = :group)
|
||||
AND outcome LIKE 'refused_%'
|
||||
GROUP BY command, outcome
|
||||
ORDER BY n DESC, command
|
||||
"""
|
||||
|
||||
ERRORS_SQL: Final = """
|
||||
SELECT command, error_class, COUNT(*) AS n
|
||||
FROM command_events
|
||||
WHERE (:group IS NULL OR group_id = :group)
|
||||
AND outcome = 'error'
|
||||
GROUP BY command, error_class
|
||||
ORDER BY n DESC, command
|
||||
"""
|
||||
|
||||
TIMED_SQL: Final = """
|
||||
SELECT command, handler_ms, send_ms
|
||||
FROM command_events
|
||||
WHERE (:group IS NULL OR group_id = :group)
|
||||
AND handler_ms IS NOT NULL
|
||||
ORDER BY command, handler_ms
|
||||
"""
|
||||
|
||||
UPSTREAM_SQL: Final = """
|
||||
SELECT host,
|
||||
COUNT(*) AS calls,
|
||||
SUM(status < 200 OR status > 299) AS failures
|
||||
FROM upstream_calls
|
||||
WHERE (:group IS NULL OR group_id = :group)
|
||||
GROUP BY host
|
||||
ORDER BY calls DESC, host
|
||||
"""
|
||||
|
||||
UPSTREAM_TIMES_SQL: Final = """
|
||||
SELECT host, duration_ms
|
||||
FROM upstream_calls
|
||||
WHERE (:group IS NULL OR group_id = :group)
|
||||
ORDER BY host, duration_ms
|
||||
"""
|
||||
|
||||
COMMAND_UPSTREAM_SQL: Final = """
|
||||
SELECT command, duration_ms
|
||||
FROM upstream_calls
|
||||
WHERE (:group IS NULL OR group_id = :group)
|
||||
ORDER BY command, duration_ms
|
||||
"""
|
||||
|
||||
|
||||
def percentile(sorted_values: list[int], fraction: float) -> int:
|
||||
"""The value at a fraction of the way through an already-sorted list.
|
||||
|
||||
Nearest-rank: ``ceil(p * n) - 1``, so the answer is always one of the
|
||||
measurements rather than an interpolation between two of them. A p95
|
||||
that nobody actually experienced is a worse number to act on than a
|
||||
real one that is slightly off the theoretical quantile.
|
||||
|
||||
Spelled with ``math.ceil`` rather than ``round`` deliberately. Python
|
||||
rounds halves to even, so ``round(0.5)`` is 0 and ``round(1.5)`` is 2 --
|
||||
which would make the median of a two-element sample depend on the
|
||||
sample's length in a way no reader would predict.
|
||||
|
||||
Computed here rather than in SQL: SQLite has no percentile function
|
||||
without an extension, and adding one would mean a seventh dependency for
|
||||
three lines of arithmetic.
|
||||
"""
|
||||
if not sorted_values:
|
||||
return 0
|
||||
rank = math.ceil(fraction * len(sorted_values)) - 1
|
||||
return sorted_values[max(0, min(len(sorted_values) - 1, rank))]
|
||||
|
||||
|
||||
def latencies(conn: sqlite3.Connection, scope: Scope) -> list[Latency]:
|
||||
"""Per-command timings, from the rows that actually carry them."""
|
||||
handler: dict[str, list[int]] = {}
|
||||
send: dict[str, list[int]] = {}
|
||||
for row in conn.execute(TIMED_SQL, scope.params()):
|
||||
command = str(row["command"])
|
||||
handler.setdefault(command, []).append(int(row["handler_ms"]))
|
||||
if row["send_ms"] is not None:
|
||||
send.setdefault(command, []).append(int(row["send_ms"]))
|
||||
upstream: dict[str, list[int]] = {}
|
||||
for row in conn.execute(COMMAND_UPSTREAM_SQL, scope.params()):
|
||||
upstream.setdefault(str(row["command"]), []).append(int(row["duration_ms"]))
|
||||
for values in (*handler.values(), *send.values(), *upstream.values()):
|
||||
values.sort()
|
||||
return sorted(
|
||||
(
|
||||
Latency(
|
||||
command=command,
|
||||
samples=len(values),
|
||||
handler_p50=percentile(values, P50),
|
||||
handler_p95=percentile(values, P95),
|
||||
send_p50=percentile(send.get(command, []), P50),
|
||||
upstream_p50=percentile(upstream.get(command, []), P50),
|
||||
)
|
||||
for command, values in handler.items()
|
||||
),
|
||||
key=lambda entry: (-entry.handler_p50, entry.command),
|
||||
)
|
||||
|
||||
|
||||
def gates(conn: sqlite3.Connection, scope: Scope) -> list[tuple[str, str, int]]:
|
||||
"""(command, which gate, how often). A high count is mis-tuning."""
|
||||
return [
|
||||
(str(row["command"]), str(row["outcome"]), int(row["n"]))
|
||||
for row in conn.execute(GATES_SQL, scope.params())
|
||||
]
|
||||
|
||||
|
||||
def errors(conn: sqlite3.Connection, scope: Scope) -> list[tuple[str, str, int]]:
|
||||
"""(command, exception class, how often). Never an exception message."""
|
||||
return [
|
||||
(str(row["command"]), str(row["error_class"]), int(row["n"]))
|
||||
for row in conn.execute(ERRORS_SQL, scope.params())
|
||||
]
|
||||
|
||||
|
||||
def upstreams(conn: sqlite3.Connection, scope: Scope) -> list[Upstream]:
|
||||
"""Every external service the bot depends on, and how it is holding up."""
|
||||
times: dict[str, list[int]] = {}
|
||||
for row in conn.execute(UPSTREAM_TIMES_SQL, scope.params()):
|
||||
times.setdefault(str(row["host"]), []).append(int(row["duration_ms"]))
|
||||
for values in times.values():
|
||||
values.sort()
|
||||
return [
|
||||
Upstream(
|
||||
host=str(row["host"]),
|
||||
calls=int(row["calls"]),
|
||||
failures=int(row["failures"] or 0),
|
||||
p50_ms=percentile(times.get(str(row["host"]), []), P50),
|
||||
)
|
||||
for row in conn.execute(UPSTREAM_SQL, scope.params())
|
||||
]
|
||||
149
signal_bot/analytics/hours.py
Normal file
149
signal_bot/analytics/hours.py
Normal file
@ -0,0 +1,149 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Family B, the time-of-day half: when the bot is actually used.
|
||||
|
||||
Every query here filters ``local_hour >= 0``. That is the whole point of the
|
||||
filter: ``daily_uses`` rows carry ``UNKNOWN_HOUR`` because their source knew
|
||||
only a date, and letting them through would file a third of the history
|
||||
under hour -1 -- or, worse, under midnight if anybody later "tidied" the
|
||||
sentinel to 0.
|
||||
|
||||
``journald`` rows *are* included. They were matched to a real message, so
|
||||
their hour was observed even though their duration was not. Excluding them
|
||||
would throw away most of the history for no gain; that distinction is why
|
||||
``scope`` names two predicates rather than one.
|
||||
|
||||
A Warsaw day is not always 24 hours -- 2026-03-29 has 23 and 2026-10-25 has
|
||||
25 -- so callers must not assume the buckets sum to a day, and nothing here
|
||||
divides by 24.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import sqlite3
|
||||
|
||||
from signal_bot.analytics.scope import Scope
|
||||
|
||||
HOURS_IN_DAY: Final = 24
|
||||
DAYS_IN_WEEK: Final = 7
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Peak:
|
||||
"""The busiest and quietest hours, when there is enough to say."""
|
||||
|
||||
busiest: int = -1
|
||||
busiest_uses: int = 0
|
||||
quietest: int = -1
|
||||
quietest_uses: int = 0
|
||||
|
||||
|
||||
BY_HOUR_SQL: Final = """
|
||||
SELECT local_hour AS hour, COUNT(*) AS uses
|
||||
FROM command_events
|
||||
WHERE (:group IS NULL OR group_id = :group)
|
||||
AND local_hour >= 0
|
||||
AND outcome != 'unknown_word'
|
||||
GROUP BY local_hour
|
||||
ORDER BY local_hour
|
||||
"""
|
||||
|
||||
BY_WEEKDAY_HOUR_SQL: Final = """
|
||||
SELECT weekday, local_hour AS hour, COUNT(*) AS uses
|
||||
FROM command_events
|
||||
WHERE (:group IS NULL OR group_id = :group)
|
||||
AND local_hour >= 0
|
||||
AND weekday >= 0
|
||||
AND outcome != 'unknown_word'
|
||||
GROUP BY weekday, local_hour
|
||||
ORDER BY weekday, local_hour
|
||||
"""
|
||||
|
||||
BY_COMMAND_HOUR_SQL: Final = """
|
||||
SELECT command, local_hour AS hour, COUNT(*) AS uses
|
||||
FROM command_events
|
||||
WHERE (:group IS NULL OR group_id = :group)
|
||||
AND local_hour >= 0
|
||||
AND outcome != 'unknown_word'
|
||||
GROUP BY command, local_hour
|
||||
ORDER BY command, local_hour
|
||||
"""
|
||||
|
||||
BY_WEEKDAY_SQL: Final = """
|
||||
SELECT weekday, COUNT(*) AS uses, COUNT(DISTINCT day) AS days
|
||||
FROM command_events
|
||||
WHERE (:group IS NULL OR group_id = :group)
|
||||
AND weekday >= 0
|
||||
AND outcome != 'unknown_word'
|
||||
GROUP BY weekday
|
||||
ORDER BY weekday
|
||||
"""
|
||||
|
||||
|
||||
def by_hour(conn: sqlite3.Connection, scope: Scope) -> list[int]:
|
||||
"""Uses per hour of the day, as 24 buckets with the empty ones present.
|
||||
|
||||
A dense list rather than the rows sqlite returns: a bar chart with a
|
||||
missing 04:00 draws the wrong shape, and "nobody uses it at four in the
|
||||
morning" is one of the things the panel is for.
|
||||
"""
|
||||
# Indexed without a bounds check: local_hour is written by hour_of(),
|
||||
# which cannot return anything outside 0-23, and the query already
|
||||
# excludes the UNKNOWN_HOUR sentinel. A guard here would be dead code
|
||||
# that hides a corrupt row instead of failing on one.
|
||||
buckets = [0] * HOURS_IN_DAY
|
||||
for row in conn.execute(BY_HOUR_SQL, scope.params()):
|
||||
buckets[int(row["hour"])] = int(row["uses"])
|
||||
return buckets
|
||||
|
||||
|
||||
def by_weekday_hour(conn: sqlite3.Connection, scope: Scope) -> list[list[int]]:
|
||||
"""A 7x24 grid, Monday first, matching date.weekday()."""
|
||||
grid = [[0] * HOURS_IN_DAY for _ in range(DAYS_IN_WEEK)]
|
||||
for row in conn.execute(BY_WEEKDAY_HOUR_SQL, scope.params()):
|
||||
grid[int(row["weekday"])][int(row["hour"])] = int(row["uses"])
|
||||
return grid
|
||||
|
||||
|
||||
def by_command_hour(conn: sqlite3.Connection, scope: Scope) -> dict[str, list[int]]:
|
||||
"""One 24-bucket row per command: which commands belong to which hours."""
|
||||
grid: dict[str, list[int]] = {}
|
||||
for row in conn.execute(BY_COMMAND_HOUR_SQL, scope.params()):
|
||||
buckets = grid.setdefault(str(row["command"]), [0] * HOURS_IN_DAY)
|
||||
buckets[int(row["hour"])] = int(row["uses"])
|
||||
return grid
|
||||
|
||||
|
||||
def by_weekday(conn: sqlite3.Connection, scope: Scope) -> list[tuple[int, int, int]]:
|
||||
"""(weekday, uses, days seen) so an average can be taken honestly.
|
||||
|
||||
Dividing by the number of weeks would be wrong on a history this short:
|
||||
a Tuesday that has happened once is not a weekly average.
|
||||
"""
|
||||
return [
|
||||
(int(row["weekday"]), int(row["uses"]), int(row["days"]))
|
||||
for row in conn.execute(BY_WEEKDAY_SQL, scope.params())
|
||||
]
|
||||
|
||||
|
||||
def peak(buckets: list[int]) -> Peak:
|
||||
"""The busiest hour, and the quietest hour that anybody ever used.
|
||||
|
||||
"Quietest" skips hours with nothing at all: at three days old most of
|
||||
the night is empty, and "the deadest hour is 04:00 with zero" would be
|
||||
true of about nine different hours and interesting about none of them.
|
||||
"""
|
||||
if not any(buckets):
|
||||
return Peak()
|
||||
used = [(hour, uses) for hour, uses in enumerate(buckets) if uses]
|
||||
busiest = max(used, key=lambda pair: (pair[1], -pair[0]))
|
||||
quietest = min(used, key=lambda pair: (pair[1], pair[0]))
|
||||
return Peak(
|
||||
busiest=busiest[0],
|
||||
busiest_uses=busiest[1],
|
||||
quietest=quietest[0],
|
||||
quietest_uses=quietest[1],
|
||||
)
|
||||
90
signal_bot/analytics/journal.py
Normal file
90
signal_bot/analytics/journal.py
Normal file
@ -0,0 +1,90 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Reading the bot's own journal back into the command history.
|
||||
|
||||
Pure parsing, no I/O: the lines arrive as an iterable so that the joining
|
||||
logic and its tests never shell out, and so that a captured log file works
|
||||
exactly as well as a live ``journalctl``.
|
||||
|
||||
The journal is the only record of what ran before ``command_events``
|
||||
existed, and it is a poor one. It carries no group id and no uuid -- only a
|
||||
*display name*, because ``dispatcher.py``'s log line was written to be read
|
||||
by a human. That is why the join in ``backfill`` runs the other way round,
|
||||
over ``message_events``, which knows everything except which command it was.
|
||||
|
||||
A display name read here is a lookup key and nothing else. It never reaches
|
||||
a row, a log line or a file: ``group_people`` turns it into a uuid inside
|
||||
the joiner and the string is dropped. ``DOCS-privacy.md`` names the journal
|
||||
as the one place display names live, and ``--vacuum-time=`` as how that is
|
||||
emptied -- so this parser must never be the thing that copies them out.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable, Iterator
|
||||
|
||||
MS_PER_SECOND: Final = 1000
|
||||
|
||||
# `journalctl -o short-iso` prefixes each line with an offset-aware timestamp
|
||||
# -- 2026-08-31T21:28:26+02:00 -- which is the only spelling in the file that
|
||||
# survives a DST boundary. The bot's own asctime, further along the same
|
||||
# line, is naive and would be an hour wrong for half the year.
|
||||
_RUNNING: Final = re.compile(r"^(?P<at>\S+)\s.*?running command (?P<command>\S+) for (?P<who>.+)$")
|
||||
# The one gate that already logged its refusals. daily_limit and vote_gated
|
||||
# logged nothing at all, so those simply cannot be recovered.
|
||||
_REFUSED: Final = re.compile(
|
||||
r"^(?P<at>\S+)\s.*?cooldown refused (?P<command>\S+) for (?P<who>.+?): told them"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class JournalLine:
|
||||
"""One thing the bot said it did, with the name it said it for."""
|
||||
|
||||
at_ms: int
|
||||
command: str
|
||||
display_name: str
|
||||
refused: bool
|
||||
|
||||
|
||||
def _at_ms(stamp: str) -> int | None:
|
||||
"""Parse the journal's own timestamp, or None if this is not one."""
|
||||
try:
|
||||
moment = dt.datetime.fromisoformat(stamp)
|
||||
except ValueError:
|
||||
return None
|
||||
# An offset-naive stamp would silently be read as UTC and land the whole
|
||||
# import two hours out, which is exactly the class of error the hour
|
||||
# columns exist to avoid. Refuse it instead.
|
||||
if moment.tzinfo is None:
|
||||
return None
|
||||
return int(moment.timestamp() * MS_PER_SECOND)
|
||||
|
||||
|
||||
def parse(lines: Iterable[str]) -> Iterator[JournalLine]:
|
||||
"""Yield one record per line the bot wrote about running a command.
|
||||
|
||||
Everything else -- startup banners, tracebacks, envelope shapes, other
|
||||
units -- simply does not match and is skipped. The parser is deliberately
|
||||
permissive about what sits between the timestamp and the phrase, because
|
||||
the journal identifier is ``run.sh[<pid>]`` and the pid changes.
|
||||
"""
|
||||
for line in lines:
|
||||
for pattern, refused in ((_RUNNING, False), (_REFUSED, True)):
|
||||
found = pattern.match(line)
|
||||
if found is None:
|
||||
continue
|
||||
at_ms = _at_ms(found["at"])
|
||||
if at_ms is not None:
|
||||
yield JournalLine(
|
||||
at_ms=at_ms,
|
||||
command=found["command"],
|
||||
display_name=found["who"].strip(),
|
||||
refused=refused,
|
||||
)
|
||||
break
|
||||
131
signal_bot/analytics/probe.py
Normal file
131
signal_bot/analytics/probe.py
Normal file
@ -0,0 +1,131 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""What one running command did, collected without changing any signature.
|
||||
|
||||
Timing a command means knowing things the handler learns and the dispatcher
|
||||
records: how long the upstream fetch took, how long the send took, which
|
||||
item was served. Threading that back would mean a return value or an extra
|
||||
argument on every handler, every fetcher method and every reply helper.
|
||||
|
||||
A ``ContextVar`` avoids all of it. The dispatcher opens a probe around the
|
||||
handler call; anything running underneath finds it and notes what it did.
|
||||
``contextvars`` is stdlib, so the runtime stays at exactly six dependencies.
|
||||
|
||||
``Bot.listen`` runs every frame in a task of its own (``inflight.py``), and
|
||||
a task starts with a *copy* of the context, so frames handled side by side
|
||||
cannot see each other's probe: each ``probing()`` sets the variable in its
|
||||
own copy, and the reset in one task cannot be undone by another. The same
|
||||
holds for a handler that spawns a task of its own -- the copy's notes land
|
||||
in that handler's probe, nowhere else.
|
||||
|
||||
Every ``note_*`` is a no-op when nothing is probing. That branch lives here,
|
||||
once, so no caller needs to ask whether it is being measured -- which is
|
||||
what keeps ``http_client`` and ``context`` free of analytics conditionals.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import time
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
|
||||
# No default sentinel object: None is the whole vocabulary, and the helpers
|
||||
# below are the only readers.
|
||||
CURRENT: ContextVar[Probe | None] = ContextVar("command_probe", default=None)
|
||||
|
||||
# A request that never got an answer at all -- DNS, connect, timeout. Kept
|
||||
# distinct from a 5xx, which is upstream answering and answering badly; the
|
||||
# two mean different things when a command starts failing.
|
||||
NO_RESPONSE: Final = 0
|
||||
|
||||
MS_PER_SECOND: Final = 1000
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UpstreamCall:
|
||||
"""One outbound HTTP request made while serving a command."""
|
||||
|
||||
host: str
|
||||
status: int
|
||||
ms: int
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Probe:
|
||||
"""The mutable notes one invocation accumulates.
|
||||
|
||||
Upstream calls are *buffered* rather than written as they happen: they
|
||||
belong to a ``command_events`` row that does not exist yet, because the
|
||||
outcome and the duration are only known once the handler has returned.
|
||||
"""
|
||||
|
||||
send_ms: int = 0
|
||||
item_id: str | None = None
|
||||
calls: list[UpstreamCall] = field(default_factory=list)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def probing() -> Iterator[Probe]:
|
||||
"""Open a probe for the duration of one command, and always close it."""
|
||||
active = Probe()
|
||||
token = CURRENT.set(active)
|
||||
try:
|
||||
yield active
|
||||
finally:
|
||||
CURRENT.reset(token)
|
||||
|
||||
|
||||
def note_send(ms: int) -> None:
|
||||
"""Add the cost of one outbound Signal send.
|
||||
|
||||
Accumulated rather than assigned: a handler may send twice, and
|
||||
``!pułapka`` does -- a picture and then its description.
|
||||
"""
|
||||
active = CURRENT.get()
|
||||
if active is not None:
|
||||
active.send_ms += ms
|
||||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def timed_send() -> AsyncIterator[None]:
|
||||
"""Time one outbound send and note it, whether or not it succeeds.
|
||||
|
||||
``DOCS-latency.md`` measured ``POST /v2/send`` at 2.65 s of a 3.7 s
|
||||
reply -- 72% of the wall clock, none of it this repository's code -- by
|
||||
reading three log lines by hand, once. This makes that measurement
|
||||
continuous, which is what the document's own rule asks for: an upstream
|
||||
latency claim gets measured before it gets designed around.
|
||||
"""
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
note_send(round((time.perf_counter() - started) * MS_PER_SECOND))
|
||||
|
||||
|
||||
def note_upstream(host: str, status: int, ms: int) -> None:
|
||||
"""Add one upstream request. Host only -- never the URL.
|
||||
|
||||
A URL carries identifiers, and some of them are the ones ``item_id``
|
||||
exists to govern. A hostname cannot carry anything.
|
||||
"""
|
||||
active = CURRENT.get()
|
||||
if active is not None:
|
||||
active.calls.append(UpstreamCall(host=host, status=status, ms=ms))
|
||||
|
||||
|
||||
def note_item(item_id: str) -> None:
|
||||
"""Record which item was served, as a public identifier.
|
||||
|
||||
A Wikidata QID, a Commons page id, a Wolne Lektury slug, a pseudonym
|
||||
key, a content-addressed filename. Never a title, a caption or a name:
|
||||
this is what makes "the pope pool feels repetitive" a number instead of
|
||||
an impression, and an identifier cannot carry a person.
|
||||
"""
|
||||
active = CURRENT.get()
|
||||
if active is not None:
|
||||
active.item_id = item_id
|
||||
196
signal_bot/analytics/recorder.py
Normal file
196
signal_bot/analytics/recorder.py
Normal file
@ -0,0 +1,196 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Turning one attempted command into one row of history.
|
||||
|
||||
This is where the dispatcher's ``try/except`` around a handler now lives, so
|
||||
that the dispatcher gains a ``with`` rather than forty lines and stays under
|
||||
the 250-line cap. It also means the six outcome branches can be tested
|
||||
directly, without driving a whole websocket frame through to reach an
|
||||
exception path.
|
||||
|
||||
The invariant that must survive every future edit to this file: recording a
|
||||
*use* and recording an *attempt* are two different writes with two different
|
||||
rules, and they may never be merged.
|
||||
|
||||
* ``store.record_use`` is success-only and feeds the cooldown and the daily
|
||||
limit. ``CommandRefusedError``'s own docstring records what happened when
|
||||
a refusal reached it: the cooldown window slid forward every time somebody
|
||||
was told to wait, so waiting the stated time never helped.
|
||||
* ``store.commands.record`` takes every attempt, including the refusals and
|
||||
the crashes -- which is the entire point, since a command that fails every
|
||||
time used to be indistinguishable from one nobody ran.
|
||||
|
||||
The temptation this creates is to hoist one call above the other, or to route
|
||||
both through a single store method. ``test_dispatcher_recording`` fires a
|
||||
refused invocation and asserts both that the history grew *and* that
|
||||
``last_used`` did not move, which is the cheapest way to notice.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from signal_bot.analytics.uses import (
|
||||
ARGS_PRESENT,
|
||||
OUTCOME_COOLDOWN,
|
||||
OUTCOME_DAILY,
|
||||
OUTCOME_ERROR,
|
||||
OUTCOME_OFF,
|
||||
OUTCOME_OK,
|
||||
OUTCOME_VOTE,
|
||||
CommandEvent,
|
||||
)
|
||||
from signal_bot.decorators import GATE_COOLDOWN, GATE_DAILY, GATE_OFF, GATE_VOTE
|
||||
from signal_bot.registry import CommandRefusedError
|
||||
from signal_bot.stats.periods import day_of, hour_of, weekday_of
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
from signal_bot.analytics import probe
|
||||
from signal_bot.analytics.uses import CommandEventStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MS_PER_SECOND: Final = 1000
|
||||
|
||||
# A gate that did not name itself. Reachable only from a bare
|
||||
# ``raise CommandRefusedError``, which nothing in this repository does any
|
||||
# more -- but the constructor still allows it, so the mapping must too.
|
||||
OUTCOME_REFUSED: Final = "refused_cooldown"
|
||||
|
||||
_BY_GATE: Final = {
|
||||
GATE_COOLDOWN: OUTCOME_COOLDOWN,
|
||||
GATE_DAILY: OUTCOME_DAILY,
|
||||
GATE_VOTE: OUTCOME_VOTE,
|
||||
GATE_OFF: OUTCOME_OFF,
|
||||
}
|
||||
|
||||
|
||||
def refusal_outcome(gate: str) -> str:
|
||||
"""Name the outcome one gate's refusal should be filed under."""
|
||||
return _BY_GATE.get(gate, OUTCOME_REFUSED)
|
||||
|
||||
|
||||
def args_token(args: tuple[str, ...]) -> str:
|
||||
"""Reduce a command's arguments to something that cannot hold a name.
|
||||
|
||||
The registry has already replaced a photo album's arguments with their
|
||||
pseudonym keys before the dispatcher sees them -- that is what
|
||||
``arg_resolver`` is for, and why it runs at resolution rather than in a
|
||||
handler. Those keys are safe and worth keeping: "which person is asked
|
||||
for most" is a real question.
|
||||
|
||||
Anything else becomes a flag. A column of words people typed is exactly
|
||||
the shape that put real names in this repository once, and the personal
|
||||
data gate learns the names it refuses from the live database -- so it
|
||||
would catch a name typed correctly and could never catch a typo.
|
||||
"""
|
||||
if not args:
|
||||
return ""
|
||||
# One resolved token is a key. Several words are words.
|
||||
if len(args) == 1 and args[0].isalnum():
|
||||
return args[0]
|
||||
return ARGS_PRESENT
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Attempt:
|
||||
"""What the dispatcher learned by trying to run one command."""
|
||||
|
||||
outcome: str = OUTCOME_OK
|
||||
error_class: str = ""
|
||||
handler_ms: int = 0
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
"""True when the handler actually ran to completion.
|
||||
|
||||
The dispatcher asks this rather than comparing to a constant, so
|
||||
"did it work" has one spelling and the *use* -- the write that feeds
|
||||
the cooldown -- cannot accidentally start counting refusals.
|
||||
"""
|
||||
return self.outcome == OUTCOME_OK
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def attempting(command: str) -> Iterator[Attempt]:
|
||||
"""Run one handler, and come out knowing how it went and what it cost.
|
||||
|
||||
Swallows the handler's exception rather than re-raising: the dispatcher
|
||||
has always treated a failed command as an answered frame, because a raise
|
||||
here ends the receive loop and the bot goes silently deaf until the
|
||||
watchdog notices. That behaviour is unchanged -- only now the failure
|
||||
leaves a row behind.
|
||||
"""
|
||||
attempt = Attempt()
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
yield attempt
|
||||
except CommandRefusedError as refusal:
|
||||
attempt.outcome = refusal_outcome(refusal.gate)
|
||||
logger.debug("command %s refused by a gate", command)
|
||||
except Exception as error:
|
||||
# The class name, never str(error): an exception's message carries
|
||||
# whatever it was processing, and here that is somebody's message.
|
||||
# "17 ImageFetchError" is the whole diagnostic value anyway.
|
||||
attempt.outcome = OUTCOME_ERROR
|
||||
attempt.error_class = type(error).__name__
|
||||
logger.exception("command %s failed", command)
|
||||
attempt.handler_ms = round((time.perf_counter() - started) * MS_PER_SECOND)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Invocation:
|
||||
"""Who ran what, where, and when the message that asked was sent.
|
||||
|
||||
Grouped rather than passed as five arguments, because these five always
|
||||
travel together and none of them means anything without the rest.
|
||||
"""
|
||||
|
||||
group_id: str
|
||||
user_uuid: str
|
||||
command: str
|
||||
args: tuple[str, ...]
|
||||
sent_at_ms: int
|
||||
|
||||
|
||||
def record_attempt(
|
||||
store: CommandEventStore,
|
||||
invocation: Invocation,
|
||||
attempt: Attempt,
|
||||
notes: probe.Probe,
|
||||
) -> None:
|
||||
"""Append one attempt, and never let doing so break the bot.
|
||||
|
||||
The whole body is guarded for the reason ``_answer_unknown``'s is: this
|
||||
runs inside the receive loop, and an exception escaping it stops the bot
|
||||
answering anything at all. Analytics are worth a great deal less than
|
||||
that, so a storage failure is logged and the frame carries on.
|
||||
"""
|
||||
sent_at_ms = invocation.sent_at_ms
|
||||
day = day_of(sent_at_ms)
|
||||
try:
|
||||
store.record(
|
||||
CommandEvent(
|
||||
group_id=invocation.group_id,
|
||||
user_uuid=invocation.user_uuid,
|
||||
command=invocation.command,
|
||||
args=args_token(invocation.args),
|
||||
item_id=notes.item_id,
|
||||
outcome=attempt.outcome,
|
||||
error_class=attempt.error_class,
|
||||
invoked_at_ms=sent_at_ms,
|
||||
day=day,
|
||||
local_hour=hour_of(sent_at_ms),
|
||||
weekday=weekday_of(day),
|
||||
handler_ms=attempt.handler_ms,
|
||||
send_ms=notes.send_ms,
|
||||
calls=tuple(notes.calls),
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("could not record the use of %s", invocation.command)
|
||||
199
signal_bot/analytics/render.py
Normal file
199
signal_bot/analytics/render.py
Normal file
@ -0,0 +1,199 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""What !analityka says in the group.
|
||||
|
||||
Twelve lines is a cap, not a quota: a line with nothing to say is dropped
|
||||
rather than printed empty, the same way the nightly report drops its
|
||||
highlights. No ``ljust`` anywhere -- Signal renders a proportional font, so
|
||||
padding lines up in a terminal and on nobody's phone.
|
||||
|
||||
Two rules about people, both inherited from ``DOCS-statistics.md``.
|
||||
|
||||
Only somebody whose display name has actually been seen may be named; the
|
||||
group listing knows everyone else as a raw uuid or a phone number, and
|
||||
printing either into a chat is a leak. And a per-person breakdown of
|
||||
everybody's habits stays on the management page: the group gets a top three
|
||||
and the asker's own favourite, which is what was decided when this was
|
||||
specified.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from signal_bot.analytics import trend as trends
|
||||
from signal_bot.stats import text
|
||||
from signal_bot.stats.periods import pl_day_month
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
||||
from signal_bot.analytics.demand import Demand
|
||||
from signal_bot.analytics.hours import Peak
|
||||
from signal_bot.analytics.scope import Window
|
||||
from signal_bot.analytics.usage import CommandUse, PersonUse
|
||||
|
||||
MAX_LINES: Final = 12
|
||||
TOP_COMMANDS: Final = 3
|
||||
TOP_PEOPLE: Final = 3
|
||||
|
||||
HEADING: Final = "Analityka komend"
|
||||
NOTHING_YET: Final = "Nikt jeszcze nie użył żadnej komendy."
|
||||
|
||||
DIRECTIONS: Final = {
|
||||
trends.RISING: "użycie rośnie",
|
||||
trends.FALLING: "użycie spada",
|
||||
trends.FLAT: "użycie się trzyma",
|
||||
}
|
||||
|
||||
|
||||
def days_phrase(count: int) -> str:
|
||||
"""A day count with its noun agreeing: 3 dni."""
|
||||
return f"{count} {text.plural(count, 'dzień', 'dni', 'dni')}"
|
||||
|
||||
|
||||
def times_phrase(count: int) -> str:
|
||||
"""A count with its noun agreeing: 5 razy."""
|
||||
return f"{count} {text.plural(count, 'raz', 'razy', 'razy')}"
|
||||
|
||||
|
||||
def window_line(measured: Window) -> str:
|
||||
"""What the numbers actually cover. Always printed, always first.
|
||||
|
||||
The bot is days old, not months. A reader who cannot see that is reading
|
||||
a month panel drawn from three days as though it were a month.
|
||||
"""
|
||||
span = days_phrase(measured.days)
|
||||
if measured.first_day == measured.last_day:
|
||||
return f"Okno: {span} ({pl_day_month(measured.first_day)}), {times_phrase(measured.events)}"
|
||||
return (
|
||||
f"Okno: {span} ({pl_day_month(measured.first_day)}"
|
||||
f" — {pl_day_month(measured.last_day)}), {times_phrase(measured.events)}"
|
||||
)
|
||||
|
||||
|
||||
def top_line(uses: Sequence[CommandUse]) -> str:
|
||||
"""The three most used commands, with their counts."""
|
||||
best = ", ".join(f"!{use.command} {use.uses}" for use in uses[:TOP_COMMANDS])
|
||||
return f"Najczęściej: {best}"
|
||||
|
||||
|
||||
def rarest_line(uses: Sequence[CommandUse]) -> str:
|
||||
"""The least used command that anybody has used at all."""
|
||||
quietest = uses[-1]
|
||||
return f"Najrzadziej: !{quietest.command} — {times_phrase(quietest.uses)}"
|
||||
|
||||
|
||||
def hours_line(peak: Peak) -> str:
|
||||
"""When the group actually reaches for the bot."""
|
||||
return f"Najgłośniejsza godzina: {peak.busiest:02d}:00 ({times_phrase(peak.busiest_uses)})"
|
||||
|
||||
|
||||
def trend_line(direction: str) -> str:
|
||||
"""The verdict, or nothing at all when it is too early to have one.
|
||||
|
||||
Returning "" rather than "za mało danych" is deliberate: the window line
|
||||
above already says how many days there are, and repeating it as an
|
||||
apology wastes one of twelve lines.
|
||||
"""
|
||||
return DIRECTIONS.get(direction, "")
|
||||
|
||||
|
||||
def health_line(uses: Sequence[CommandUse]) -> str:
|
||||
"""The command most in need of attention, if any is.
|
||||
|
||||
Refusals count as much as errors here. A command refused half the time
|
||||
is not popular, it is mis-tuned, and from the group's side both read as
|
||||
"it does not work".
|
||||
"""
|
||||
troubled = [use for use in uses if use.errors or use.refused]
|
||||
if not troubled:
|
||||
return ""
|
||||
worst = max(troubled, key=lambda use: (use.errors + use.refused, use.command))
|
||||
# "błędów: 3" rather than "3 błędy": a label and a number needs no
|
||||
# agreement at all, and Polish agreement after a numeral has three forms
|
||||
# whose rule reads the last two digits. Sidestepping it here is free.
|
||||
parts = []
|
||||
if worst.errors:
|
||||
parts.append(f"błędów: {worst.errors}")
|
||||
if worst.refused:
|
||||
parts.append(f"odmów: {worst.refused}")
|
||||
return f"Do poprawki: !{worst.command} ({', '.join(parts)})"
|
||||
|
||||
|
||||
def demand_line(demand: Demand) -> str:
|
||||
"""The trigger people keep almost typing, which wants another alias."""
|
||||
if not demand.near_misses:
|
||||
return ""
|
||||
miss = demand.near_misses[0]
|
||||
return f"Blisko trafień: {miss.target} — {times_phrase(miss.hits)}"
|
||||
|
||||
|
||||
def people_line(people: Sequence[PersonUse], names: Mapping[str, str]) -> str:
|
||||
"""The top three, and only those whose name the bot has actually seen."""
|
||||
named = [(names[person.uuid], person.uses) for person in people if person.uuid in names]
|
||||
if not named:
|
||||
return ""
|
||||
best = ", ".join(f"{name} {uses}" for name, uses in named[:TOP_PEOPLE])
|
||||
return f"Kto najwięcej: {best}"
|
||||
|
||||
|
||||
def mine_line(people: Sequence[PersonUse], uuid: str) -> str:
|
||||
"""The asker's own favourite. Self-scope, so it names nobody else."""
|
||||
for person in people:
|
||||
if person.uuid == uuid and person.favourite:
|
||||
return f"Twoja ulubiona: !{person.favourite} ({times_phrase(person.uses)})"
|
||||
return ""
|
||||
|
||||
|
||||
def share_line(commands: int, messages: int) -> str:
|
||||
"""How much of the group's talking is talking to the bot."""
|
||||
if not messages:
|
||||
return ""
|
||||
percent = round(100 * commands / messages)
|
||||
return f"Komendy to {percent}% wiadomości w tej grupie"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Summary:
|
||||
"""Everything the reply needs, gathered before any of it is worded.
|
||||
|
||||
One object rather than ten arguments, because these always travel
|
||||
together: the command builds it from the query layer and the renderer
|
||||
turns it into Polish, and neither half needs to know the other.
|
||||
"""
|
||||
|
||||
measured: Window
|
||||
uses: Sequence[CommandUse]
|
||||
people: Sequence[PersonUse]
|
||||
names: Mapping[str, str]
|
||||
peak: Peak
|
||||
direction: str
|
||||
demand: Demand
|
||||
asker: str
|
||||
commands: int
|
||||
messages: int
|
||||
|
||||
|
||||
def render(summary: Summary) -> str:
|
||||
"""Build the reply, capped at twelve lines and never padded."""
|
||||
if not summary.uses:
|
||||
return NOTHING_YET
|
||||
lines = [
|
||||
HEADING,
|
||||
window_line(summary.measured),
|
||||
top_line(summary.uses),
|
||||
rarest_line(summary.uses),
|
||||
hours_line(summary.peak),
|
||||
trend_line(summary.direction),
|
||||
share_line(summary.commands, summary.messages),
|
||||
health_line(summary.uses),
|
||||
demand_line(summary.demand),
|
||||
people_line(summary.people, summary.names),
|
||||
mine_line(summary.people, summary.asker),
|
||||
]
|
||||
# Dropped, then capped. A line with nothing to say is not printed empty,
|
||||
# so the cap bites on content rather than on blanks -- and the cap is
|
||||
# what keeps a reply readable on a phone.
|
||||
kept = [line for line in lines if line][:MAX_LINES]
|
||||
return "\n".join(kept)
|
||||
119
signal_bot/analytics/scope.py
Normal file
119
signal_bot/analytics/scope.py
Normal file
@ -0,0 +1,119 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""What a question is being asked *about*, and which rows may answer it.
|
||||
|
||||
Every query in this package is a complete literal string. None of them is
|
||||
built by concatenation or by an f-string, which is the only arrangement ruff
|
||||
S608 accepts and this repository takes no suppressions -- ``forget_group``
|
||||
spells its DELETEs out for the same reason.
|
||||
|
||||
"One group, or all of them" is therefore not a clause that gets appended. It
|
||||
is written into every query once, as::
|
||||
|
||||
WHERE (:group IS NULL OR group_id = :group)
|
||||
|
||||
Binding ``:group`` to None makes the first half true and the filter vanish.
|
||||
So the roll-up and the per-group answer run *the same SQL*, which is what
|
||||
stops them drifting apart -- two queries written to agree eventually will
|
||||
not.
|
||||
|
||||
The other shared decision here is the source filter. Three classes of row
|
||||
live in ``command_events``:
|
||||
|
||||
* ``live`` -- observed as it happened; the only rows with real durations.
|
||||
* ``journald`` -- reconstructed from the log, with a real hour taken from
|
||||
the message it was matched to, but no duration and an outcome that was
|
||||
inferred rather than seen.
|
||||
* ``daily_uses`` -- a count with no hour at all, carrying ``UNKNOWN_HOUR``.
|
||||
|
||||
Counting all three is right for "how often was this used" and wrong for "at
|
||||
what hour" or "how slow is it". Those two predicates are named here, once,
|
||||
so that the exclusion is a shared constant rather than something six modules
|
||||
each have to remember. ``stats/backfill.py`` records what forgetting it
|
||||
costs: a synthetic hour reached ``favourite_hour`` and the answer was
|
||||
quietly wrong.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import sqlite3
|
||||
|
||||
# How long a person may go quiet before their next command starts a new
|
||||
# session. Used only by burstiness and co-occurrence, the two questions that
|
||||
# need "in one sitting" to mean anything.
|
||||
#
|
||||
# Ten minutes: comfortably longer than the pause between two commands
|
||||
# somebody is idly firing off, far shorter than the gap between two separate
|
||||
# times of day they picked their phone up.
|
||||
SESSION_GAP_MS: Final = 10 * 60 * 1000
|
||||
|
||||
# Below this many days, a direction is not stated. The line is still drawn --
|
||||
# the reader can see it -- but no arrow and no "rośnie"/"spada" verdict,
|
||||
# because three points of a new bot's novelty curve are not a trend.
|
||||
TREND_MIN_DAYS: Final = 14
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Scope:
|
||||
"""One group, or all of them."""
|
||||
|
||||
group_id: str | None = None
|
||||
|
||||
def params(self, **extra: object) -> dict[str, object]:
|
||||
"""The bound parameters every query in this package expects."""
|
||||
return {"group": self.group_id, **extra}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Window:
|
||||
"""Which days an answer actually covers, and where its rows came from.
|
||||
|
||||
Carried on every response because this bot is days old, not months. A
|
||||
month panel drawn from three days is not wrong, but a reader who cannot
|
||||
see that it is three days will read it as though it were a month.
|
||||
"""
|
||||
|
||||
first_day: str = ""
|
||||
last_day: str = ""
|
||||
days: int = 0
|
||||
events: int = 0
|
||||
live: int = 0
|
||||
journald: int = 0
|
||||
daily: int = 0
|
||||
|
||||
@property
|
||||
def enough_for_a_trend(self) -> bool:
|
||||
"""Whether a direction may be stated rather than merely plotted."""
|
||||
return self.days >= TREND_MIN_DAYS
|
||||
|
||||
|
||||
WINDOW_SQL: Final = """
|
||||
SELECT MIN(day) AS first_day,
|
||||
MAX(day) AS last_day,
|
||||
COUNT(DISTINCT day) AS days,
|
||||
COUNT(*) AS events,
|
||||
SUM(source = 'live') AS live,
|
||||
SUM(source = 'journald') AS journald,
|
||||
SUM(source = 'daily_uses') AS daily
|
||||
FROM command_events
|
||||
WHERE (:group IS NULL OR group_id = :group)
|
||||
"""
|
||||
|
||||
|
||||
def window(conn: sqlite3.Connection, scope: Scope) -> Window:
|
||||
"""Measure what the data actually covers, before anything is drawn."""
|
||||
row = conn.execute(WINDOW_SQL, scope.params()).fetchone()
|
||||
if row is None or not row["events"]:
|
||||
return Window()
|
||||
return Window(
|
||||
first_day=str(row["first_day"]),
|
||||
last_day=str(row["last_day"]),
|
||||
days=int(row["days"]),
|
||||
events=int(row["events"]),
|
||||
live=int(row["live"] or 0),
|
||||
journald=int(row["journald"] or 0),
|
||||
daily=int(row["daily"] or 0),
|
||||
)
|
||||
113
signal_bot/analytics/sessions.py
Normal file
113
signal_bot/analytics/sessions.py
Normal file
@ -0,0 +1,113 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Family E, the behavioural half: sittings, bursts and what goes together.
|
||||
|
||||
These are the only questions that need a *trustworthy ordering in time*, so
|
||||
they are the only ones restricted to ``live`` rows plus the journald ones
|
||||
that carry a real hour. A ``daily_uses`` row is a count with a synthetic
|
||||
timestamp built from midnight; treating those as a sequence would invent a
|
||||
sitting in which somebody ran forty commands in forty milliseconds.
|
||||
|
||||
A session is one person's run of commands with no gap longer than
|
||||
``SESSION_GAP_MS``. It exists to make two things sayable:
|
||||
|
||||
* **burstiness** -- how many commands a sitting contains. Three or four
|
||||
means somebody is re-rolling for a better photo, which is a fact about
|
||||
the pool rather than about them.
|
||||
* **co-occurrence** -- which commands turn up in the same sitting, which is
|
||||
what says whether two commands are alternatives or a habit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from itertools import combinations
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from signal_bot.analytics.scope import SESSION_GAP_MS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import sqlite3
|
||||
|
||||
from signal_bot.analytics.scope import Scope
|
||||
|
||||
# Only rows whose timestamp is a real moment. daily_uses rows are excluded
|
||||
# by local_hour, which is the one column that distinguishes them.
|
||||
ORDERED_SQL: Final = """
|
||||
SELECT user_uuid, command, invoked_at_ms
|
||||
FROM command_events
|
||||
WHERE (:group IS NULL OR group_id = :group)
|
||||
AND local_hour >= 0
|
||||
AND outcome != 'unknown_word'
|
||||
ORDER BY user_uuid, invoked_at_ms
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Sessions:
|
||||
"""How the group actually uses the bot, sitting by sitting."""
|
||||
|
||||
count: int = 0
|
||||
commands: int = 0
|
||||
longest: int = 0
|
||||
pairs: tuple[tuple[str, str, int], ...] = ()
|
||||
repeats: tuple[tuple[str, int], ...] = ()
|
||||
|
||||
@property
|
||||
def average_length(self) -> float:
|
||||
"""Commands per sitting. One means nobody re-rolls."""
|
||||
return self.commands / self.count if self.count else 0.0
|
||||
|
||||
|
||||
def _split(rows: list[tuple[str, str, int]]) -> list[list[str]]:
|
||||
"""Cut one person's ordered commands into sittings on the idle gap."""
|
||||
sittings: list[list[str]] = []
|
||||
current: list[str] = []
|
||||
last_uuid = ""
|
||||
last_at = 0
|
||||
for uuid, command, at_ms in rows:
|
||||
broken = uuid != last_uuid or at_ms - last_at > SESSION_GAP_MS
|
||||
if broken and current:
|
||||
sittings.append(current)
|
||||
current = []
|
||||
current.append(command)
|
||||
last_uuid = uuid
|
||||
last_at = at_ms
|
||||
if current:
|
||||
sittings.append(current)
|
||||
return sittings
|
||||
|
||||
|
||||
def collect(conn: sqlite3.Connection, scope: Scope) -> Sessions:
|
||||
"""Split the history into sittings and describe what they contain."""
|
||||
rows = [
|
||||
(str(row["user_uuid"]), str(row["command"]), int(row["invoked_at_ms"]))
|
||||
for row in conn.execute(ORDERED_SQL, scope.params())
|
||||
]
|
||||
sittings = _split(rows)
|
||||
if not sittings:
|
||||
return Sessions()
|
||||
|
||||
pairs: Counter[tuple[str, str]] = Counter()
|
||||
repeats: Counter[str] = Counter()
|
||||
for sitting in sittings:
|
||||
# Sorted and de-duplicated, so (cat, dog) and (dog, cat) are one
|
||||
# fact rather than two, and running !kot four times in a sitting
|
||||
# does not make it co-occur with itself.
|
||||
distinct = sorted(set(sitting))
|
||||
for left, right in combinations(distinct, 2):
|
||||
pairs[(left, right)] += 1
|
||||
for command, times in Counter(sitting).items():
|
||||
if times > 1:
|
||||
repeats[command] += times - 1
|
||||
|
||||
return Sessions(
|
||||
count=len(sittings),
|
||||
commands=sum(len(sitting) for sitting in sittings),
|
||||
longest=max(len(sitting) for sitting in sittings),
|
||||
pairs=tuple(
|
||||
(left, right, count)
|
||||
for (left, right), count in sorted(pairs.items(), key=lambda p: (-p[1], p[0]))
|
||||
),
|
||||
repeats=tuple(sorted(repeats.items(), key=lambda pair: (-pair[1], pair[0]))),
|
||||
)
|
||||
187
signal_bot/analytics/trend.py
Normal file
187
signal_bot/analytics/trend.py
Normal file
@ -0,0 +1,187 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Family B, the calendar half: day series, months, and what is dying.
|
||||
|
||||
Day-granular, so every source counts -- a ``daily_uses`` row has no hour but
|
||||
it does know its day, which is exactly what these questions need.
|
||||
|
||||
The direction of a trend is deliberately not always stated. ``Window``
|
||||
carries the day count and ``TREND_MIN_DAYS`` is the threshold; below it the
|
||||
series is returned and drawn but ``direction`` stays ``UNKNOWN``. A bot that
|
||||
is three days old has a novelty curve, not a trend, and an arrow drawn from
|
||||
three points is a claim the data cannot support.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from signal_bot.stats.periods import shift
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import sqlite3
|
||||
from collections.abc import Sequence
|
||||
|
||||
from signal_bot.analytics.scope import Scope, Window
|
||||
|
||||
RISING: Final = "rising"
|
||||
FALLING: Final = "falling"
|
||||
FLAT: Final = "flat"
|
||||
UNKNOWN: Final = "unknown"
|
||||
|
||||
# How much the second half must differ from the first to be called a
|
||||
# direction rather than noise. A tenth: below that, on counts this small,
|
||||
# one busy evening decides the answer.
|
||||
TREND_MARGIN: Final = 0.10
|
||||
|
||||
# A command not used for this many days is called dying rather than merely
|
||||
# quiet. Deliberately short, because the whole history is days long; it is
|
||||
# a relative statement and the panel says so.
|
||||
DYING_AFTER_DAYS: Final = 3
|
||||
|
||||
# A direction needs a before and an after to compare.
|
||||
MIN_POINTS: Final = 2
|
||||
|
||||
BY_DAY_SQL: Final = """
|
||||
SELECT day, COUNT(*) AS uses, COUNT(DISTINCT user_uuid) AS people
|
||||
FROM command_events
|
||||
WHERE (:group IS NULL OR group_id = :group)
|
||||
AND outcome != 'unknown_word'
|
||||
GROUP BY day
|
||||
ORDER BY day
|
||||
"""
|
||||
|
||||
BY_COMMAND_DAY_SQL: Final = """
|
||||
SELECT command, day, COUNT(*) AS uses
|
||||
FROM command_events
|
||||
WHERE (:group IS NULL OR group_id = :group)
|
||||
AND outcome != 'unknown_word'
|
||||
GROUP BY command, day
|
||||
ORDER BY command, day
|
||||
"""
|
||||
|
||||
BY_MONTH_SQL: Final = """
|
||||
SELECT substr(day, 1, 7) AS month, COUNT(*) AS uses, COUNT(DISTINCT day) AS days
|
||||
FROM command_events
|
||||
WHERE (:group IS NULL OR group_id = :group)
|
||||
AND outcome != 'unknown_word'
|
||||
GROUP BY month
|
||||
ORDER BY month
|
||||
"""
|
||||
|
||||
LAST_USED_SQL: Final = """
|
||||
SELECT command, MAX(day) AS last_day, COUNT(*) AS uses
|
||||
FROM command_events
|
||||
WHERE (:group IS NULL OR group_id = :group)
|
||||
AND outcome = 'ok'
|
||||
GROUP BY command
|
||||
ORDER BY last_day, command
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Dying:
|
||||
"""A command that used to be used and has gone quiet."""
|
||||
|
||||
command: str
|
||||
last_day: str
|
||||
idle_days: int
|
||||
uses: int
|
||||
|
||||
|
||||
def by_day(conn: sqlite3.Connection, scope: Scope) -> list[tuple[str, int, int]]:
|
||||
"""(day, uses, people) for every day anything happened."""
|
||||
return [
|
||||
(str(row["day"]), int(row["uses"]), int(row["people"]))
|
||||
for row in conn.execute(BY_DAY_SQL, scope.params())
|
||||
]
|
||||
|
||||
|
||||
def dense_days(series: Sequence[tuple[str, int, int]]) -> list[tuple[str, int]]:
|
||||
"""Fill in the days nothing happened on.
|
||||
|
||||
A sparse series draws a line straight over a silent day, which reads as
|
||||
"steady" when the truth is "nobody used it". Built by stepping the date
|
||||
rather than by adding 86_400 seconds, because Warsaw days are not all
|
||||
the same length.
|
||||
"""
|
||||
if not series:
|
||||
return []
|
||||
filled: list[tuple[str, int]] = []
|
||||
day = series[0][0]
|
||||
last = series[-1][0]
|
||||
counts = {entry[0]: entry[1] for entry in series}
|
||||
while day <= last:
|
||||
filled.append((day, counts.get(day, 0)))
|
||||
day = shift(day, 1)
|
||||
return filled
|
||||
|
||||
|
||||
def by_command_day(conn: sqlite3.Connection, scope: Scope) -> dict[str, dict[str, int]]:
|
||||
"""Per-command day series, for the sparklines."""
|
||||
series: dict[str, dict[str, int]] = {}
|
||||
for row in conn.execute(BY_COMMAND_DAY_SQL, scope.params()):
|
||||
series.setdefault(str(row["command"]), {})[str(row["day"])] = int(row["uses"])
|
||||
return series
|
||||
|
||||
|
||||
def by_month(conn: sqlite3.Connection, scope: Scope) -> list[tuple[str, int, int]]:
|
||||
"""(month, uses, days seen). The days matter: a partial month is not low."""
|
||||
return [
|
||||
(str(row["month"]), int(row["uses"]), int(row["days"]))
|
||||
for row in conn.execute(BY_MONTH_SQL, scope.params())
|
||||
]
|
||||
|
||||
|
||||
def direction(series: Sequence[tuple[str, int]], measured: Window) -> str:
|
||||
"""Whether use is rising, falling, flat -- or too early to say.
|
||||
|
||||
Compares the mean of the second half against the first. Not a
|
||||
least-squares fit: on a dozen points a fit is no more truthful and far
|
||||
harder to explain in a chat message.
|
||||
"""
|
||||
if not measured.enough_for_a_trend or len(series) < MIN_POINTS:
|
||||
return UNKNOWN
|
||||
# MIN_POINTS above guarantees half >= 1, so neither slice is empty and
|
||||
# neither division below is by zero.
|
||||
half = len(series) // 2
|
||||
first = [count for _day, count in series[:half]]
|
||||
second = [count for _day, count in series[half:]]
|
||||
before = sum(first) / len(first)
|
||||
after = sum(second) / len(second)
|
||||
if before == 0:
|
||||
return RISING if after > 0 else FLAT
|
||||
change = (after - before) / before
|
||||
if change > TREND_MARGIN:
|
||||
return RISING
|
||||
if change < -TREND_MARGIN:
|
||||
return FALLING
|
||||
return FLAT
|
||||
|
||||
|
||||
def dying(conn: sqlite3.Connection, scope: Scope, today: str) -> list[Dying]:
|
||||
"""Commands that worked, were used, and then stopped being used."""
|
||||
found: list[Dying] = []
|
||||
for row in conn.execute(LAST_USED_SQL, scope.params()):
|
||||
last_day = str(row["last_day"])
|
||||
idle = _days_between(last_day, today)
|
||||
if idle >= DYING_AFTER_DAYS:
|
||||
found.append(
|
||||
Dying(
|
||||
command=str(row["command"]),
|
||||
last_day=last_day,
|
||||
idle_days=idle,
|
||||
uses=int(row["uses"]),
|
||||
)
|
||||
)
|
||||
return sorted(found, key=lambda entry: (-entry.idle_days, entry.command))
|
||||
|
||||
|
||||
def _days_between(start: str, end: str) -> int:
|
||||
"""Whole calendar days from one day to another, never negative."""
|
||||
days = 0
|
||||
day = start
|
||||
while day < end:
|
||||
day = shift(day, 1)
|
||||
days += 1
|
||||
return days
|
||||
197
signal_bot/analytics/usage.py
Normal file
197
signal_bot/analytics/usage.py
Normal file
@ -0,0 +1,197 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Family A: who uses which commands, how often, and which nobody uses.
|
||||
|
||||
Counts include every source. "How many times was !papież used" is answered
|
||||
by all three classes of row -- a reconstructed invocation still happened.
|
||||
Only the questions that need an hour or a duration narrow to ``live``, and
|
||||
those live in ``hours`` and ``health``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import sqlite3
|
||||
from collections.abc import Sequence
|
||||
|
||||
from signal_bot.analytics.scope import Scope
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CommandUse:
|
||||
"""One command, and everything countable about it."""
|
||||
|
||||
command: str
|
||||
uses: int
|
||||
people: int
|
||||
ok: int
|
||||
refused: int
|
||||
errors: int
|
||||
first_day: str
|
||||
last_day: str
|
||||
|
||||
@property
|
||||
def failure_rate(self) -> float:
|
||||
"""Share of attempts that crashed. Refusals are not failures."""
|
||||
return self.errors / self.uses if self.uses else 0.0
|
||||
|
||||
@property
|
||||
def refusal_rate(self) -> float:
|
||||
"""Share of attempts a gate turned away.
|
||||
|
||||
High is not popular, it is mis-tuned: it means people keep asking
|
||||
and keep being told no.
|
||||
"""
|
||||
return self.refused / self.uses if self.uses else 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PersonUse:
|
||||
"""One person's total, by uuid. Names are attached by the caller."""
|
||||
|
||||
uuid: str
|
||||
uses: int
|
||||
commands: int
|
||||
favourite: str
|
||||
|
||||
|
||||
BY_COMMAND_SQL: Final = """
|
||||
SELECT command,
|
||||
COUNT(*) AS uses,
|
||||
COUNT(DISTINCT user_uuid) AS people,
|
||||
SUM(outcome = 'ok') AS ok,
|
||||
SUM(outcome LIKE 'refused_%') AS refused,
|
||||
SUM(outcome = 'error') AS errors,
|
||||
MIN(day) AS first_day,
|
||||
MAX(day) AS last_day
|
||||
FROM command_events
|
||||
WHERE (:group IS NULL OR group_id = :group)
|
||||
AND outcome != 'unknown_word'
|
||||
GROUP BY command
|
||||
ORDER BY uses DESC, command
|
||||
"""
|
||||
|
||||
BY_PERSON_SQL: Final = """
|
||||
SELECT user_uuid,
|
||||
COUNT(*) AS uses,
|
||||
COUNT(DISTINCT command) AS commands
|
||||
FROM command_events
|
||||
WHERE (:group IS NULL OR group_id = :group)
|
||||
AND outcome = 'ok'
|
||||
GROUP BY user_uuid
|
||||
ORDER BY uses DESC, user_uuid
|
||||
"""
|
||||
|
||||
FAVOURITE_SQL: Final = """
|
||||
SELECT user_uuid, command, COUNT(*) AS uses
|
||||
FROM command_events
|
||||
WHERE (:group IS NULL OR group_id = :group)
|
||||
AND outcome = 'ok'
|
||||
GROUP BY user_uuid, command
|
||||
ORDER BY user_uuid, uses DESC, command
|
||||
"""
|
||||
|
||||
MATRIX_SQL: Final = """
|
||||
SELECT user_uuid, command, COUNT(*) AS uses
|
||||
FROM command_events
|
||||
WHERE (:group IS NULL OR group_id = :group)
|
||||
AND outcome = 'ok'
|
||||
GROUP BY user_uuid, command
|
||||
ORDER BY user_uuid, command
|
||||
"""
|
||||
|
||||
TOTALS_SQL: Final = """
|
||||
SELECT COUNT(*) AS attempts,
|
||||
SUM(outcome = 'ok') AS ok,
|
||||
COUNT(DISTINCT user_uuid) AS people,
|
||||
COUNT(DISTINCT command) AS commands
|
||||
FROM command_events
|
||||
WHERE (:group IS NULL OR group_id = :group)
|
||||
AND outcome != 'unknown_word'
|
||||
"""
|
||||
|
||||
|
||||
def by_command(conn: sqlite3.Connection, scope: Scope) -> list[CommandUse]:
|
||||
"""Every command that has ever been attempted, most used first."""
|
||||
return [
|
||||
CommandUse(
|
||||
command=str(row["command"]),
|
||||
uses=int(row["uses"]),
|
||||
people=int(row["people"]),
|
||||
ok=int(row["ok"] or 0),
|
||||
refused=int(row["refused"] or 0),
|
||||
errors=int(row["errors"] or 0),
|
||||
first_day=str(row["first_day"]),
|
||||
last_day=str(row["last_day"]),
|
||||
)
|
||||
for row in conn.execute(BY_COMMAND_SQL, scope.params())
|
||||
]
|
||||
|
||||
|
||||
def _favourites(conn: sqlite3.Connection, scope: Scope) -> dict[str, str]:
|
||||
"""Each person's most-used command.
|
||||
|
||||
The query orders by uses within each person, so the first row seen for
|
||||
a uuid is the answer and the rest are skipped -- cheaper than a window
|
||||
function and portable to the ancient sqlite a container might carry.
|
||||
"""
|
||||
best: dict[str, str] = {}
|
||||
for row in conn.execute(FAVOURITE_SQL, scope.params()):
|
||||
best.setdefault(str(row["user_uuid"]), str(row["command"]))
|
||||
return best
|
||||
|
||||
|
||||
def by_person(conn: sqlite3.Connection, scope: Scope) -> list[PersonUse]:
|
||||
"""Who uses the bot, by uuid and most active first.
|
||||
|
||||
Deliberately returns uuids. Turning one into a name is the caller's job
|
||||
and a different rule in each place: the group chat may name only people
|
||||
whose display name has been seen, the management page may show
|
||||
everyone, and neither may print a raw uuid or a phone number.
|
||||
"""
|
||||
favourites = _favourites(conn, scope)
|
||||
return [
|
||||
PersonUse(
|
||||
uuid=str(row["user_uuid"]),
|
||||
uses=int(row["uses"]),
|
||||
commands=int(row["commands"]),
|
||||
favourite=favourites.get(str(row["user_uuid"]), ""),
|
||||
)
|
||||
for row in conn.execute(BY_PERSON_SQL, scope.params())
|
||||
]
|
||||
|
||||
|
||||
def matrix(conn: sqlite3.Connection, scope: Scope) -> list[tuple[str, str, int]]:
|
||||
"""Every (person, command, count) triple, for the heatmap on the page.
|
||||
|
||||
Never posted into a group -- a per-person breakdown of everybody's
|
||||
habits is a management-page thing.
|
||||
"""
|
||||
return [
|
||||
(str(row["user_uuid"]), str(row["command"]), int(row["uses"]))
|
||||
for row in conn.execute(MATRIX_SQL, scope.params())
|
||||
]
|
||||
|
||||
|
||||
def totals(conn: sqlite3.Connection, scope: Scope) -> dict[str, int]:
|
||||
"""Attempts, successes, distinct people and distinct commands."""
|
||||
row = conn.execute(TOTALS_SQL, scope.params()).fetchone()
|
||||
return {
|
||||
"attempts": int(row["attempts"] or 0),
|
||||
"ok": int(row["ok"] or 0),
|
||||
"people": int(row["people"] or 0),
|
||||
"commands": int(row["commands"] or 0),
|
||||
}
|
||||
|
||||
|
||||
def never_used(known: Sequence[str], used: Sequence[CommandUse]) -> list[str]:
|
||||
"""Registered commands with no recorded attempt at all.
|
||||
|
||||
Takes the registry's list rather than reading it, because which commands
|
||||
exist depends on the group -- the album's are registered only where the
|
||||
album is -- and this module must not know that.
|
||||
"""
|
||||
seen = {use.command for use in used}
|
||||
return sorted(name for name in known if name not in seen)
|
||||
150
signal_bot/analytics/uses.py
Normal file
150
signal_bot/analytics/uses.py
Normal file
@ -0,0 +1,150 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Writing the command history: one row per attempt, and what it cost.
|
||||
|
||||
Shares the ``Store`` connection exactly as ``EventStore`` does, so a command
|
||||
and the message that triggered it are committed by the same connection in
|
||||
the same order they happened.
|
||||
|
||||
The column set is pinned by ``test_command_events`` for the reason
|
||||
``message_events``' is: this table sits beside a public repository that
|
||||
leaked real names once, and a column added quietly is how the next one would
|
||||
arrive. Nothing here holds free text. ``command`` is a registry name,
|
||||
``args`` is a resolved token, ``item_id`` is a public identifier and
|
||||
``error_class`` is an exception's class name.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import sqlite3
|
||||
from collections.abc import Sequence
|
||||
|
||||
from signal_bot.analytics.probe import UpstreamCall
|
||||
|
||||
OUTCOME_OK: Final = "ok"
|
||||
OUTCOME_COOLDOWN: Final = "refused_cooldown"
|
||||
OUTCOME_DAILY: Final = "refused_daily"
|
||||
OUTCOME_VOTE: Final = "refused_vote"
|
||||
OUTCOME_OFF: Final = "refused_off"
|
||||
OUTCOME_ERROR: Final = "error"
|
||||
OUTCOME_UNKNOWN: Final = "unknown_word"
|
||||
|
||||
SOURCE_LIVE: Final = "live"
|
||||
SOURCE_JOURNAL: Final = "journald"
|
||||
SOURCE_DAILY: Final = "daily_uses"
|
||||
|
||||
# What ``args`` holds when a command took arguments that resolve to nothing
|
||||
# nameable. Never the words themselves: a user-typed argument column is the
|
||||
# exact shape that put real names in this repository once.
|
||||
ARGS_PRESENT: Final = "+args"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CommandEvent:
|
||||
"""One attempt to run one command, whether or not it ran."""
|
||||
|
||||
group_id: str
|
||||
user_uuid: str
|
||||
command: str
|
||||
outcome: str
|
||||
invoked_at_ms: int
|
||||
day: str
|
||||
local_hour: int
|
||||
weekday: int
|
||||
args: str = ""
|
||||
item_id: str | None = None
|
||||
error_class: str = ""
|
||||
# None rather than 0: a backfilled row has no duration, and zero
|
||||
# milliseconds is a lie that reads as data and would drag a p50 down.
|
||||
# The lesson is already written into backfill.UNKNOWN_HOUR's comment.
|
||||
handler_ms: int | None = None
|
||||
send_ms: int | None = None
|
||||
source: str = SOURCE_LIVE
|
||||
calls: Sequence[UpstreamCall] = field(default_factory=tuple)
|
||||
|
||||
|
||||
class CommandEventStore:
|
||||
"""Reads and writes the command history. Shares the Store's connection."""
|
||||
|
||||
def __init__(self, conn: sqlite3.Connection) -> None:
|
||||
"""Wrap an open connection whose schema has already been applied."""
|
||||
self._conn = conn
|
||||
|
||||
@property
|
||||
def connection(self) -> sqlite3.Connection:
|
||||
"""The shared connection, for the query modules in this package."""
|
||||
return self._conn
|
||||
|
||||
def record(self, event: CommandEvent) -> None:
|
||||
"""Append one attempt and the upstream calls it made.
|
||||
|
||||
``ON CONFLICT DO NOTHING`` against the natural key is what makes a
|
||||
signal-cli redelivery harmless, the same property ``message_events``
|
||||
gets from its primary key -- ``invoked_at_ms`` is the envelope's own
|
||||
timestamp, so a replayed frame collides with the row it already
|
||||
wrote instead of adding a second one.
|
||||
|
||||
It is also what makes the backfill re-runnable, which is why the
|
||||
backfill needs no delete-then-rebuild pass and cannot lose rows to
|
||||
a journal that has since been vacuumed.
|
||||
"""
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO command_events (
|
||||
group_id, user_uuid, command, args, item_id, outcome,
|
||||
error_class, invoked_at_ms, day, local_hour, weekday,
|
||||
handler_ms, send_ms, source
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(group_id, user_uuid, command, invoked_at_ms, source)
|
||||
DO NOTHING
|
||||
""",
|
||||
(
|
||||
event.group_id,
|
||||
event.user_uuid,
|
||||
event.command,
|
||||
event.args,
|
||||
event.item_id,
|
||||
event.outcome,
|
||||
event.error_class,
|
||||
event.invoked_at_ms,
|
||||
event.day,
|
||||
event.local_hour,
|
||||
event.weekday,
|
||||
event.handler_ms,
|
||||
event.send_ms,
|
||||
event.source,
|
||||
),
|
||||
)
|
||||
self._record_calls(event)
|
||||
self._conn.commit()
|
||||
|
||||
def _record_calls(self, event: CommandEvent) -> None:
|
||||
"""Append the upstream calls one invocation made.
|
||||
|
||||
Keyed on (group, timestamp, seq) rather than on the parent's rowid.
|
||||
``lastrowid`` is unusable here: ``ON CONFLICT DO NOTHING`` leaves it
|
||||
holding whatever the previous successful insert returned, so a
|
||||
replayed frame would file its calls under an unrelated invocation.
|
||||
"""
|
||||
for seq, call in enumerate(event.calls):
|
||||
self._conn.execute(
|
||||
"""
|
||||
INSERT INTO upstream_calls (
|
||||
group_id, invoked_at_ms, command, host, status,
|
||||
duration_ms, seq
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(group_id, invoked_at_ms, seq) DO NOTHING
|
||||
""",
|
||||
(
|
||||
event.group_id,
|
||||
event.invoked_at_ms,
|
||||
event.command,
|
||||
call.host,
|
||||
call.status,
|
||||
call.ms,
|
||||
seq,
|
||||
),
|
||||
)
|
||||
96
signal_bot/analytics_command.py
Normal file
96
signal_bot/analytics_command.py
Normal file
@ -0,0 +1,96 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""!analityka -- what the group has actually been asking the bot for.
|
||||
|
||||
Registered here rather than in ``stats/commands.py``, which is a few lines
|
||||
under the 250 cap already, and because this reads a different table:
|
||||
``stats`` is the *message* history, this is the *command* history.
|
||||
|
||||
The reply is text only, in Polish, capped at twelve lines. The charts live
|
||||
on the management page; a rendered image in the chat would mean a new
|
||||
dependency and, per ``DOCS-latency.md``, would turn a 250 ms text reply into
|
||||
a three-second one for a picture nobody can zoom into.
|
||||
|
||||
Like ``!pomoc``, the registry is read when the command *runs* rather than
|
||||
when it is registered -- at registration time the album's commands do not
|
||||
exist yet, and those are exactly the triggers a near miss might be against.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from signal_bot.analytics import adoption, demand, hours, render, trend, usage
|
||||
from signal_bot.analytics.scope import Scope, window
|
||||
from signal_bot.animals import load_aliases
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from signal_bot.context import CommandContext
|
||||
from signal_bot.decorators import Decorator
|
||||
from signal_bot.registry import Command, CommandRegistry, Handler
|
||||
|
||||
COMMAND_NAME: Final = "command_stats"
|
||||
DESCRIPTION: Final = "kto ile używa bota i które komendy żyją"
|
||||
|
||||
|
||||
def triggers_for(commands: Sequence[Command], group_id: str) -> tuple[str, ...]:
|
||||
"""Every trigger this group could plausibly have meant.
|
||||
|
||||
Scoped to the group, and that scoping is the point: the album's triggers
|
||||
are real people's names and exist only where the album does. Reporting a
|
||||
near miss against one outside its group would announce that the album
|
||||
exists, which is the same leak the dispatcher refuses at resolution.
|
||||
"""
|
||||
return tuple(
|
||||
trigger
|
||||
for command in commands
|
||||
if command.allowed_in(group_id)
|
||||
for trigger in command.triggers
|
||||
)
|
||||
|
||||
|
||||
def summarise(ctx: CommandContext, commands: Sequence[Command]) -> render.Summary:
|
||||
"""Gather every number the reply needs, from the group that asked.
|
||||
|
||||
Scoped to one group like every other statistic here: a test group's
|
||||
numbers must never turn up in the production group's answer.
|
||||
"""
|
||||
conn = ctx.store.commands.connection
|
||||
scope = Scope(ctx.group_id)
|
||||
measured = window(conn, scope)
|
||||
series = trend.dense_days(trend.by_day(conn, scope))
|
||||
share = adoption.share_of_chat(conn, scope)
|
||||
return render.Summary(
|
||||
measured=measured,
|
||||
uses=usage.by_command(conn, scope),
|
||||
people=usage.by_person(conn, scope),
|
||||
# Only people the bot has actually seen a name for. Everyone else is
|
||||
# counted and left unnamed: the roster knows them as a raw uuid or a
|
||||
# phone number, and printing either into a chat is a leak.
|
||||
names={
|
||||
person.uuid: person.display_name for person in ctx.store.events.people(ctx.group_id)
|
||||
},
|
||||
peak=hours.peak(hours.by_hour(conn, scope)),
|
||||
direction=trend.direction(series, measured),
|
||||
demand=demand.collect(conn, scope, triggers_for(commands, ctx.group_id)),
|
||||
asker=ctx.message.source_uuid,
|
||||
commands=share.commands,
|
||||
messages=share.messages,
|
||||
)
|
||||
|
||||
|
||||
def register_analytics_command(
|
||||
registry: CommandRegistry,
|
||||
*,
|
||||
decorate: Decorator | None = None,
|
||||
aliases: dict[str, list[str]] | None = None,
|
||||
) -> None:
|
||||
"""Register !analityka under every trigger aliases.json gives it."""
|
||||
table = load_aliases() if aliases is None else aliases
|
||||
|
||||
async def handler(ctx: CommandContext) -> None:
|
||||
await ctx.reply(render.render(summarise(ctx, registry.commands)))
|
||||
|
||||
wrapped: Handler = decorate(handler) if decorate else handler
|
||||
registry.register(COMMAND_NAME, table[COMMAND_NAME], wrapped, DESCRIPTION)
|
||||
@ -14,9 +14,10 @@ from signal_bot.config import DEFAULT_CAT_API_URL, DEFAULT_DOG_API_URL, DEFAULT_
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from signal_bot.context import CommandContext
|
||||
from signal_bot.decorators import Decorator
|
||||
from signal_bot.http_client import ImageFetcher
|
||||
from signal_bot.registry import CommandContext, CommandRegistry, Handler
|
||||
from signal_bot.registry import CommandRegistry, Handler
|
||||
|
||||
ALIAS_FILE: Final = Path(__file__).with_name("aliases.json")
|
||||
|
||||
@ -27,6 +28,8 @@ class ImageSource:
|
||||
|
||||
api_url: str
|
||||
url_path: tuple[str | int, ...]
|
||||
# What !pomoc says this command does, in the group's language.
|
||||
description: str
|
||||
rewrite: tuple[str, str] | None = None
|
||||
page_limit: int | None = None
|
||||
|
||||
@ -41,11 +44,12 @@ INATURALIST_PAGE_LIMIT: Final = 10_000
|
||||
# ignoring it -- five requests in a row returned the same gecko -- so the
|
||||
# randomness has to come from asking for a random page instead.
|
||||
SOURCES: Final[dict[str, ImageSource]] = {
|
||||
"cat": ImageSource(DEFAULT_CAT_API_URL, (0, "url")),
|
||||
"dog": ImageSource(DEFAULT_DOG_API_URL, ("message",)),
|
||||
"cat": ImageSource(DEFAULT_CAT_API_URL, (0, "url"), "losowe zdjęcie kota"),
|
||||
"dog": ImageSource(DEFAULT_DOG_API_URL, ("message",), "losowe zdjęcie psa"),
|
||||
"gecko": ImageSource(
|
||||
DEFAULT_GECKO_API_URL,
|
||||
("results", 0, "photos", 0, "url"),
|
||||
"losowe zdjęcie gekona",
|
||||
("/square.", "/large."),
|
||||
INATURALIST_PAGE_LIMIT,
|
||||
),
|
||||
@ -120,4 +124,4 @@ def register_animal_commands(
|
||||
)
|
||||
if decorate is not None:
|
||||
handler = decorate(handler)
|
||||
registry.register(name, table[name], handler)
|
||||
registry.register(name, table[name], handler, source.description)
|
||||
|
||||
@ -3,17 +3,24 @@
|
||||
|
||||
A group is told about every release it has not seen, and a group that has
|
||||
never been told anything has not seen any of them -- so the first
|
||||
announcement starts at 0.0.0 "Swiety Piotr Apostol" rather than skipping
|
||||
straight to whatever is current. Watching the ladder start at Peter is the
|
||||
point of naming releases after popes at all.
|
||||
announcement starts at 0.0.0 "Petrus" rather than skipping straight to
|
||||
whatever is current. Watching the ladder start at Peter is the point of
|
||||
naming releases after popes at all.
|
||||
|
||||
That does mean a group added years from now would be handed the whole
|
||||
backlog, so the message is capped at MAX_RELEASES and says how many older
|
||||
versions it left out.
|
||||
|
||||
The announcement carries the same three things `!papiez` does: the pope's
|
||||
portrait, his Latin name and his Polish Wikipedia article. Only for the pope
|
||||
in the header -- ten attachments is the wall MAX_RELEASES exists to avoid.
|
||||
|
||||
The version is recorded *before* the message is sent. `Restart=always` with
|
||||
`RestartSec=5` means a crash between the two would otherwise re-announce on
|
||||
every restart, and a missed announcement is far cheaper than a looping one.
|
||||
That is also why a portrait that will not download costs the photo and not
|
||||
the announcement: the marker is already written, so a Commons outage at boot
|
||||
would otherwise eat that group's release notes permanently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -22,13 +29,16 @@ import logging
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from signal_bot.changelog import since
|
||||
from signal_bot.versioning import article_url, latin_name, pope_line, version_for
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
from signal_bot.changelog import Release
|
||||
from signal_bot.portraits import PortraitCache
|
||||
from signal_bot.store import Store
|
||||
from signal_bot.transport_base import Transport
|
||||
from signal_bot.versioning import Pope
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -38,14 +48,17 @@ ANNOUNCED_KEY: Final = "announced_version"
|
||||
MAX_RELEASES: Final = 10
|
||||
|
||||
|
||||
def render(version: str, pope: str, releases: Iterable[Release]) -> str:
|
||||
def render(pope: Pope, releases: Iterable[Release]) -> str:
|
||||
"""Return the message announcing a new version, in Polish.
|
||||
|
||||
One release is rendered flat, because the header already names it. Several
|
||||
get a block each, so the group can see the ladder it is climbing.
|
||||
get a block each, so the group can see the ladder it is climbing. Pure on
|
||||
purpose: whether this text travels as a caption or as a plain message is
|
||||
announce_to's call, not this function's.
|
||||
"""
|
||||
shown = list(releases)
|
||||
header = f"Wersja {version} \u00ab{pope}\u00bb wydana"
|
||||
header = f"Wersja {version_for(pope.ordinal)} \u00ab{latin_name(pope)}\u00bb wydana"
|
||||
header = f"{header}\n{pope_line(pope)}\n{article_url(pope)}"
|
||||
omitted = max(0, len(shown) - MAX_RELEASES)
|
||||
shown = shown[-MAX_RELEASES:]
|
||||
if len(shown) == 1:
|
||||
@ -53,7 +66,11 @@ def render(version: str, pope: str, releases: Iterable[Release]) -> str:
|
||||
return f"{header}\nChangelog:\n{body}"
|
||||
blocks = [
|
||||
"\n".join(
|
||||
[f"{release.version} \u00ab{release.pope}\u00bb", *(f"- {x}" for x in release.lines)]
|
||||
[
|
||||
f"{release.version} \u00ab{latin_name(release.pope_entry)}\u00bb",
|
||||
pope_line(release.pope_entry),
|
||||
*(f"- {x}" for x in release.lines),
|
||||
]
|
||||
)
|
||||
for release in shown
|
||||
]
|
||||
@ -66,22 +83,35 @@ async def announce_to(
|
||||
store: Store,
|
||||
group_id: str,
|
||||
releases: tuple[Release, ...],
|
||||
version: str,
|
||||
pope: str,
|
||||
pope: Pope,
|
||||
portraits: PortraitCache,
|
||||
) -> None:
|
||||
"""Announce the current version to one group, if it has not seen it."""
|
||||
version = version_for(pope.ordinal)
|
||||
previous = store.meta(group_id, ANNOUNCED_KEY)
|
||||
if previous == version:
|
||||
return
|
||||
# No record at all means the group has seen nothing, not that it should be
|
||||
# skipped: that is how Swiety Piotr Apostol gets announced rather than
|
||||
# silently assumed.
|
||||
# skipped: that is how Petrus gets announced rather than silently assumed.
|
||||
pending = releases if previous is None else since(releases, previous)
|
||||
store.set_meta(group_id, ANNOUNCED_KEY, version)
|
||||
if not pending:
|
||||
logger.info("group %s recorded at %s with nothing to announce", group_id, version)
|
||||
return
|
||||
await transport.send_text(render(version, pope, pending), group_id)
|
||||
text = render(pope, pending)
|
||||
try:
|
||||
image = await portraits.get_base64(pope)
|
||||
await transport.send_image(image, group_id, caption=text)
|
||||
except Exception:
|
||||
# Both the download and the send are inside this: the marker is
|
||||
# already written, so either failing would otherwise cost the group
|
||||
# its release notes permanently. The attachment turns a 2 KB text
|
||||
# post into a megabyte of base64, which signal-cli can refuse on its
|
||||
# own terms while it is perfectly able to take the text -- and a
|
||||
# duplicate post, if the send failed after delivery, is far cheaper
|
||||
# than release notes nobody ever sees.
|
||||
logger.exception("photo announcement of %s failed for %s; sending text", version, group_id)
|
||||
await transport.send_text(text, group_id)
|
||||
|
||||
|
||||
async def announce_all(
|
||||
@ -89,12 +119,12 @@ async def announce_all(
|
||||
store: Store,
|
||||
group_ids: Iterable[str],
|
||||
releases: tuple[Release, ...],
|
||||
version: str,
|
||||
pope: str,
|
||||
pope: Pope,
|
||||
portraits: PortraitCache,
|
||||
) -> None:
|
||||
"""Announce to every watched group; one failure must not silence the rest."""
|
||||
for group_id in group_ids:
|
||||
try:
|
||||
await announce_to(transport, store, group_id, releases, version, pope)
|
||||
await announce_to(transport, store, group_id, releases, pope, portraits)
|
||||
except Exception:
|
||||
logger.exception("release announcement failed for group %s", group_id)
|
||||
|
||||
@ -12,18 +12,19 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, Response, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from signal_bot.app_commands import router as commands_router
|
||||
from signal_bot.config_env import load_config
|
||||
from signal_bot.selftest import SelfTestError
|
||||
from signal_bot.selftest import run as run_selftest
|
||||
from signal_bot.startup_retry import Starter
|
||||
from signal_bot.store import Store
|
||||
from signal_bot.versioning import POPE, __version__
|
||||
from signal_bot.versioning import POPE, __version__, latin_name, pope_line
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
@ -74,13 +75,23 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
|
||||
|
||||
class SelfTestRequest(BaseModel):
|
||||
"""One command to type at the bot, and where."""
|
||||
"""One command to type at the bot, where, and what it carries.
|
||||
|
||||
``attachments`` are ids the container's own attachment store already
|
||||
holds. That is how !add_photo gets verified without a second human: the
|
||||
handler fetches them back over the real API, so the ingest path exercised
|
||||
is the live one rather than a hand-fed buffer.
|
||||
"""
|
||||
|
||||
group: str
|
||||
text: str
|
||||
attachments: list[str] = []
|
||||
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
# The per-command switchboard, in its own module so this one stays about
|
||||
# serving and connecting.
|
||||
app.include_router(commands_router)
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
@ -94,7 +105,13 @@ async def healthz(response: Response) -> dict[str, str]:
|
||||
like, and is not the same thing as one that has gone deaf.
|
||||
"""
|
||||
starter: Starter = app.state.starter
|
||||
build = {"version": __version__, "pope": POPE.name}
|
||||
build = {
|
||||
"version": __version__,
|
||||
"pope": latin_name(POPE),
|
||||
# The same sentence the group is shown, so "which release is this?"
|
||||
# has one answer whether it is asked here or in Signal.
|
||||
"papacy": pope_line(POPE),
|
||||
}
|
||||
runtime = starter.runtime
|
||||
if runtime is None:
|
||||
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
@ -107,11 +124,14 @@ async def healthz(response: Response) -> dict[str, str]:
|
||||
|
||||
|
||||
@app.post("/_selftest")
|
||||
async def selftest(request: SelfTestRequest, response: Response) -> dict[str, str]:
|
||||
async def selftest(request: SelfTestRequest, response: Response) -> dict[str, Any]:
|
||||
"""Type a command at the bot on its own behalf, for verification.
|
||||
|
||||
Bound to loopback with the rest of the app, and refuses any group not
|
||||
named in SELFTEST_GROUPS. See signal_bot/selftest.py for why this exists.
|
||||
When the text was a command, ``served`` carries what it cost, split into
|
||||
the bot's own time and the time spent waiting on Commons and signal-cli
|
||||
(``served.py``); ``tool/latency.py`` reads it to hold the bot to a budget.
|
||||
"""
|
||||
starter: Starter = app.state.starter
|
||||
runtime = starter.runtime
|
||||
@ -119,8 +139,39 @@ async def selftest(request: SelfTestRequest, response: Response) -> dict[str, st
|
||||
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
return {"status": "connecting", "reason": starter.reason}
|
||||
try:
|
||||
group_id = await run_selftest(runtime.bot, app.state.client, request.group, request.text)
|
||||
group_id, served = await run_selftest(
|
||||
runtime.bot,
|
||||
app.state.client,
|
||||
request.group,
|
||||
request.text,
|
||||
attachments=request.attachments,
|
||||
)
|
||||
except SelfTestError as refusal:
|
||||
response.status_code = status.HTTP_403_FORBIDDEN
|
||||
return {"status": "refused", "reason": str(refusal)}
|
||||
return {"status": "delivered", "group": group_id, "text": request.text}
|
||||
answer: dict[str, Any] = {"status": "delivered", "group": group_id, "text": request.text}
|
||||
if served is not None:
|
||||
answer["served"] = served.as_dict()
|
||||
return answer
|
||||
|
||||
|
||||
@app.post("/_reload")
|
||||
async def reload_commands(response: Response) -> dict[str, str | int]:
|
||||
"""Rebuild the command table from the data on disk, without a restart.
|
||||
|
||||
The answer is what the bot is serving *now*: after a rejected reload
|
||||
that is still the previous table, and ``reason`` says what was wrong
|
||||
with the new one. 409 rather than 500, because the data was bad and the
|
||||
bot is fine. Loopback only, like everything else on this port.
|
||||
"""
|
||||
starter: Starter = app.state.starter
|
||||
runtime = starter.runtime
|
||||
if runtime is None:
|
||||
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
return {"status": "connecting", "reason": starter.reason}
|
||||
bot = runtime.bot
|
||||
if bot.reload_commands():
|
||||
return {"status": "reloaded", "commands": bot.command_count}
|
||||
response.status_code = status.HTTP_409_CONFLICT
|
||||
holder = bot.dispatcher.holder
|
||||
return {"status": "kept", "commands": bot.command_count, "reason": holder.last_error}
|
||||
|
||||
91
signal_bot/app_commands.py
Normal file
91
signal_bot/app_commands.py
Normal file
@ -0,0 +1,91 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""The switchboard: what the management page reads and writes per command.
|
||||
|
||||
Loopback only, like everything on this port; the management app proxies it.
|
||||
The list is the *live* table -- the one the dispatcher is resolving against
|
||||
this instant -- joined with the store's switches, so a command the album
|
||||
added on the last reload shows up, and one it dropped is gone. A PUT writes
|
||||
the store and nothing else: the dispatcher reads it on the next attempt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from signal_bot.command_settings import CommandSetting
|
||||
from signal_bot.wiring import UNGATED
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from signal_bot.bot import Bot
|
||||
from signal_bot.registry import Command
|
||||
from signal_bot.startup_retry import Starter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SettingRequest(BaseModel):
|
||||
"""The three things the page may decide about one command."""
|
||||
|
||||
enabled: bool = True
|
||||
disabled_until: float | None = None
|
||||
cooldown_seconds: float | None = None
|
||||
|
||||
|
||||
def _bot(request: Request) -> Bot:
|
||||
"""The connected bot, or the same 503 every other route gives while connecting."""
|
||||
starter: Starter = request.app.state.starter
|
||||
runtime = starter.runtime
|
||||
if runtime is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail={"status": "connecting", "reason": starter.reason},
|
||||
)
|
||||
return runtime.bot
|
||||
|
||||
|
||||
def _row(bot: Bot, command: Command) -> dict[str, Any]:
|
||||
"""One command as the page sees it: identity, switches, and the default."""
|
||||
setting = bot.store.command_settings.get(command.name)
|
||||
return {
|
||||
"name": command.name,
|
||||
"trigger": command.primary,
|
||||
"description": command.description,
|
||||
"enabled": setting.enabled,
|
||||
"disabled_until": setting.disabled_until,
|
||||
"cooldown_seconds": setting.cooldown_seconds,
|
||||
# What the cooldown falls back to when the override is cleared, or
|
||||
# null for the commands wiring registers without one -- a number
|
||||
# there would be an input that does nothing.
|
||||
"default_cooldown_seconds": (
|
||||
None if command.name in UNGATED else bot.config.cooldown_seconds
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/_commands")
|
||||
async def list_commands(request: Request) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Every live command with its switches, by name."""
|
||||
bot = _bot(request)
|
||||
commands = sorted(bot.dispatcher.registry.commands, key=lambda c: c.name)
|
||||
return {"commands": [_row(bot, command) for command in commands]}
|
||||
|
||||
|
||||
@router.put("/_commands/{name}")
|
||||
async def put_command(name: str, body: SettingRequest, request: Request) -> dict[str, Any]:
|
||||
"""Store one command's switches and answer with the row as it now stands."""
|
||||
bot = _bot(request)
|
||||
command = next((c for c in bot.dispatcher.registry.commands if c.name == name), None)
|
||||
if command is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, f"no command named {name!r}")
|
||||
bot.store.command_settings.put(
|
||||
CommandSetting(
|
||||
command=name,
|
||||
enabled=body.enabled,
|
||||
disabled_until=body.disabled_until,
|
||||
cooldown_seconds=body.cooldown_seconds,
|
||||
)
|
||||
)
|
||||
return _row(bot, command)
|
||||
79211
signal_bot/art.json
Normal file
79211
signal_bot/art.json
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user