An unrecognised "!word" was dropped in silence, which made a typo
indistinguishable from the bot being down and threw away the best available
list of commands worth writing. Three things now happen at the one place in
dispatcher.py that used to be a bare `return`.
A near miss is corrected -- "!cta" asks whether you meant "!kot". The
distance is optimal string alignment rather than difflib's ratio: a
transposition is the typo people actually make, it costs one edit here, and
it shreds the matching blocks a ratio counts ("!cta"/"!cat" scores 0.75 but
"!cta"/"!kot" scores 0.50, so no cutoff separates them on principle).
A word with no near miss points at !zaproponuj, which files an idea from
inside the group. Either way the word and the message are recorded, and the
management page grows an Ideas tab that lists them.
Three things stay silent, each for its own reason. "!kot ladny" is a real
trigger with words after it and always has been silent. "!!" is punctuation.
And a command claimed in some *other* group is neither answered nor recorded
-- the album's triggers are real people's names, and pack_scoping.yaml says
it out loud: a refusal would itself announce that the album exists. The
existence check is therefore global, which is what pack_add already asks.
Measured rather than guessed, against the shipped aliases.json:
- a minimum candidate length, because "!ja" is one edit from ~40 of the 676
two-letter words somebody could type; 69 of them got a suggestion before
the rule and 18 after.
- the two-edit budget starts at six characters, not five. At five it put
four pairs of *different* commands within reach of each other --
!pies/!popes, !days/!cats, !dzis/!dni, !mnie/!dni -- and costs nothing to
give up, because every real typo this was written for is one edit away.
test_no_two_commands_are_within_reach_of_each_other fails if a future
alias reopens the gap.
Both new tables are keyed on the envelope's own sent_at_ms, never the wall
clock: signal-cli replays undelivered frames on reconnect, and a clock-keyed
table collects a duplicate row per reconnect instead of ignoring one.
The branch is gated on should_count -- which excludes the bot's own envelope,
and that is the loop guard, since handle() checks is_own nowhere else -- and
wrapped in its own try/except, because it runs above the one that wraps a
handler and Bot.listen has no exception handling of its own.
Suggestions live in SQLite, not in a tracked file, and the export goes to the
state directory. check_personal_data learns the names it refuses from the
live database, so it catches a name somebody has typed and cannot catch a
misspelling of one -- and an unrecognised-command corpus is made of
misspellings. A group-writable file in a public tree is both a leak and a
wedge. The export therefore carries
deliberate !zaproponuj notes in full and unrecognised words as counts only;
bodies stay on the loopback-only page.
Verified on the live bot: restarted, both tables created with every existing
row count unchanged, !cta and !zaproponuj !pogoda delivered through
/_selftest, both rows read back out of the live database, and the journal
names neither the word nor the body.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01416TegJ3sa8xL5qGGgrygP
10 KiB
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.
./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>at127.0.0.1:8140without reading the reply. A form can only sendapplication/x-www-form-urlencoded,multipart/form-dataortext/plain, so every mutating request must declareapplication/json— which a form cannot. Anything else is 415. - DNS rebinding. A hostname the attacker controls is re-pointed at
127.0.0.1after the page loads, which makes the browser treat this app as same-origin. The rebound request still carries the attacker's hostname, so aHostthat 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.
- 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. - 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. - Only then is anything written. A rejected edit leaves
.envbyte-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, but only while /api/groups
has actually returned something. parse_group_ids checks syntax; startup
additionally requires each listed group to be one the bot really joined,
judged against a listing that was really read. So with the container down, a
syntactically valid write can name a group the next startup rejects — turning
a scoping mistake into a unit that will not start. The page says so and leaves
the other keys 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_GROUPSis editable and worth understanding before you widen it. It is what keepsPOST /_selftestfail-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, exactly as the
tagger decided it.
- No
people.jsonyet → 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, because a key deleted by hand must not be reissued and inherit somebody else's photos. That mirrorspack_people.next_key, which is the authority. Names are typed into the page and never passed as arguments: an argument lands in the 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.
--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.
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 a file, and the file goes to the state
directory — $STATE_DIRECTORY, else $XDG_DATA_HOME/signal-bot/ — never
into this repository. Two reasons, and both are 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
future 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 possibly
catch !annna, which is exactly what an unrecognised-command corpus is full
of. The export therefore 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/suggestions |
Filed ideas and unrecognised-word tallies, bodies included. |
| 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.
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 rather than the usual JSONC: this
repository's check-json hook is generic and tsconfig being commentable is a
convention, not a requirement. The two things 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, including
noUncheckedIndexedAccess and exactOptionalPropertyTypes.
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.