Commit Graph

13 Commits

Author SHA1 Message Date
0717df646a Rebuild the command table on demand, and survive a tag whose file is gone
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
The registry used to be built once, inline in build_bot, so every change to
the data behind it -- a person tagged in the web UI, an alias, soon a command
switched off from the management page -- meant restarting the unit. That is
cheap and lossless, but it is a deploy step somebody has to remember, and a
21:37 report can land in the middle of it.

Now the registration sequence lives in wiring.build_registry, and
RegistryHolder runs it again on POST /_reload: the new table is built whole
and swapped in with one assignment, never patched in place, so a duplicate
trigger is found before anything the dispatcher reads has changed. Bad data
(ConfigError, DuplicateTriggerError) keeps the previous table and answers
409 with the reason; a programming error still surfaces as a 500.
scripts/reload.sh wraps it, with --code for the restart a Python change
needs, and an optional command to type in testbot so "reloaded" means
"answered". No Python hot-reload on purpose.

The loader change is what makes deploying this safe: two rows in the live
tags.json name files that are no longer in media/, and load_pack refused
them, so the next restart would have looped on ConfigError. A stale tag is
drift in a file a page edits mid-session, not a broken album -- it is now
skipped with a warning naming it. A tag that reaches outside the album is
still refused. pack_load.py split at the 250-line cap; pack_tags.py is the
half whose checks are not all fatal.

Proven offline in scripts/e2e.sh: !delta is unknown, a fifth person is
tagged into the synthetic pack, scripts/reload.sh runs, and the same
process posts delta's photo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-04 10:14:39 +02:00
405541be9b release: 0.2.6 «Stephanus I» — dodano !analityka
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
One row per command attempt, not two aggregates written only on success.

command_uses holds last-use only and daily_uses counts successes; both are
rate-limiter state and both are written by store.record_use, which the
dispatcher calls only when a handler returns cleanly. A gate refusal and a
crash returned before it, so a command that failed every time looked exactly
like one nobody ran, and nothing knew which command a counted message was.

command_events now takes every attempt with its outcome, its duration and
the item it served; upstream_calls takes every outbound request made while
serving one. The two writes stay separate on purpose -- recording a refusal
as a use is what slid the cooldown window forward every time somebody was
told to wait -- and test_dispatcher_outcomes asserts last_used does not move.

Backfilled from journald joined against message_events, with daily_uses
filling the residual so the two sources cannot double-count. Two things were
wrong on the first attempt and both were found by measuring rather than by
reasoning: the window has to be symmetric, because sent_at_ms comes from the
sender's phone and the journal stamp from this host (194 of 299 lines landed
*before* their own message, by up to 3.2 s); and matching has to be a global
closest-pair assignment, because per-row greedy cannot separate two commands
seconds apart. Together those took the dropped rows from 137 to zero, and
recovered 418 invocations back to 29 August.

Also: !analityka in the group, twelve Polish lines, naming at most three
people and only ones whose display name has been seen; an Analytics tab with
Nivo charts; and continuous latency measurement, which answers the open
question in DOCS-latency.md -- a text send is 235 ms and an image send about
2.2 s, so the upload really is where the time goes.

EPHEMERAL_TABLES was doing two unrelated jobs and is now two tuples: what a
schema bump rebuilds, and what leaving a group clears. daily_uses left the
first (it is a backfill source now) and stayed in the second, and
_drop_reshaped took over the repair a bump used to do for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4QTp3h5Q3MvfrwGV8xqPf
2026-08-31 23:19:51 +02:00
6ec746fad2 feat: a word the bot does not know gets an answer, and gets kept
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
2026-08-31 16:48:26 +02:00
48957a105d feat: serve several photo albums at once, each scoped to its own groups
Some checks are pending
CI / check (push) Waiting to run
You asked for !add_photo in testbot without the real album being reachable
there. One album cannot do that: its people.json "groups" list *is* the
privacy guarantee, so making the commands exist in a second group necessarily
makes the real photos postable there. So the bot now loads several albums.

PHOTO_PACK_DIR takes a comma-separated list, the spelling GROUP_IDS and
TRUSTED_INVITERS already use. Each album carries its own group scope, its own
people, its own tags and its own custom labels; they share nothing but the
process they run in.

That makes trigger collisions between albums legal for the first time, and the
registry had to learn it. Two albums are two different sets of people and both
may reasonably call somebody "!alfa" -- no message can ever mean both, because
a command a group is not scoped to is not a command there at all. So a trigger
may be claimed more than once, and DuplicateTriggerError now fires only where
the group sets *overlap*. Overlapping albums are refused at startup rather than
resolved by registration order: a coin toss about which private album receives
a photo is the outcome this whole design exists to prevent.

Two consequences worth naming:

- registry.resolve() takes an optional group. Without one it still answers
  "does anything claim this word", which is what pack_add's mint check wants;
  with one it answers "what runs here", which is what the dispatcher wants.
  resolve_with_args no longer resolves-then-checks, because with two claimants
  the first one found may be the wrong group's.
- CommandContext moved to signal_bot/context.py to keep registry.py under the
  250-line cap. A filing change: that file is "which word runs what", this one
  is "what the thing that runs may do".

DOCS-add-photo.md is likewise a split, not new prose -- DOCS-photo-pack.md had
grown past the cap and the ingest path is its own subject now.

Verified live: the unit serves both albums, the real group resolves its 13
album commands and testbot its 7, and !losowe / !alfa / !dodaj all fire in
testbot through the live dispatcher. 997 tests, 100% branch coverage, 28 e2e
scenarios.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqqE22jp94B1ypwxppdr1o
2026-08-31 11:58:50 +02:00
35102be863 release: 0.2.0 «Urbanus I» — a private photo album, and a tagger for it
Per-person photo commands, where the mechanism is public and the album is
not. Nothing identifying is in this repository: who the people are, what
fires their photos and which file shows whom all live in one directory
outside it, and the tests, the docs and the offline e2e run use three
invented people instead.

Several names in one message intersect -- "!alfa beta" is a photo with both
-- and where an entry offers a choice the bot picks among the readings that
actually have a photo, so a question the album could answer is not refused.
A combination nobody photographed gets one line rather than silence, which
from the group's side is indistinguishable from a broken bot.

Scoping is enforced in the dispatcher, not in the handler: a command the
group is not allowed resolves to nothing, so it is not answered, not counted
as a command, and never named in a log line outside its own groups. A
handler that quietly returned would leak all three. Commands are named by
pseudonym (pack:p1), so the journal and command_uses say somebody asked for
a photo and nothing about whose.

Video works: the attachment goes out as data:video/mp4;filename=...;base64,
which is the container's own spelling, read off its /swagger/doc.json.
Told it is a photo, Signal renders a clip as an unplayable file.

tool/tagger.py serves one page on loopback to fill the album in -- names are
typed into the page, never as arguments, and it prints how many photos each
command would have when it stops, because a command with zero is dead on
arrival.

Also renames the real people who were still sitting in the stats test
fixtures. They remain in the history; the point is that nothing new is
added to it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017w1HZ3cZ7neL3rFdQ6VMBA
2026-08-30 22:35:41 +02:00
116671236a fix: a refused command no longer re-arms its own cooldown
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 20:39:14 +02:00
50f6a587c2 feat: the bot can describe an envelope without quoting anybody
DOCS-signal-envelopes.md exists because this wire format is not obvious
and guessing at it has cost real bugs -- the sticker read off the wrong
nesting level, and 0.0.3's !gekon shipping green on an assumption nobody
had checked against the live API. The recurring cost is that answering
"what does a frame with an attachment actually look like?" needed a code
change, a restart, and somebody sending one at the right moment.

So at DEBUG the dispatcher now logs each frame's *shape*: keys and value
types, never values. Two fields keep their values because they are wire
format rather than anything a person said -- a MIME type and a groupInfo
type. A message body, a name, a number, a uuid, quoted text and a
sticker id all come out as "str". An album collapses to one element and
a count instead of printing the same dict twenty times.

Verified against a live frame: the body, the quoted text, the uuid and
the phone number do not survive, and contentType does.

This also had to fix DEBUG being unusable. LOG_LEVEL=DEBUG set the root
logger, which turns on httpx, httpcore and asyncio; the bot polls a
group listing every 30 seconds, so the one interesting line arrived
buried and journald started dropping. Libraries are now held at WARNING
whatever the bot is set to -- at INFO httpx was already emitting an
"HTTP Request: GET ... 200 OK" line every thirty seconds, forever.
Measured on the running unit: 17 library lines per minute down to 2.

Two bugs in that setup found by its own tests rather than by review:
basicConfig is a documented no-op once the root logger has a handler, so
the level silently did not apply under pytest; and the floor belongs at
INFO too, not only below it.

A self-send does not produce a capturable attachment envelope, by the
way -- signal-cli emits no syncMessage for a send made over the same
connection. Two probe images returned HTTP 201 and no frame came back,
so the inbound shape still needs a human to post one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
2026-08-30 19:29:06 +02:00
dc1995b558 feat: the message event log, and the nightly report built on it
The tally was one integer per person, deleted after each post. Nothing
dated survived it, so weeks, months, records, streaks and weekday
averages were all unanswerable. This replaces it with an append-only
per-message log holding metadata only -- never a message body -- and
rebuilds the nightly report on top.

What changes for the group: a photo posted without a caption now counts
(it was worth zero), stickers count, the bot's own messages no longer
do, people who said nothing appear with a 0, and the report covers the
previous full calendar day in Europe/Warsaw instead of "since the last
post" -- which used to merge two days whenever a group went quiet.

Notable decisions, each with the trap it avoids:

- Events and people are PERMANENT_TABLES: neither forget_group nor a
  future SCHEMA_VERSION bump may drop them. Both paths used to cost a
  day's counter and would now cost the entire history, and forget_group
  fires on a listing that merely omits members.
- is_own comes from which envelope branch carried the payload, not from
  comparing identities. The account is spelled as a uuid on some frames
  and a +E.164 number on others, so any comparison misses one of them.
- The roster is stashed in refresh(), not _adopt(), which returns early
  once the watched set stops changing -- i.e. always, in the steady
  state.
- The roster matches on uuid OR number because /v1/groups spells kuhy as
  +48123456789 while his messages carry an ACI uuid. Joining on uuid
  alone would have listed him twice every night, once with 0.
- Members whose display name we have never seen are counted, not named:
  their only spellings are a uuid or a phone number.
- (group, uuid, sent_at_ms) is the primary key, so a websocket
  redelivery after a reconnect cannot inflate the counts.
- Days come from a stored Warsaw date string, never a timestamp range:
  2026-03-29 is 23 hours long and 2026-10-25 is 25.
- Polish number agreement is a real rule with a 12-14 exception, so
  "44 znaki" and "45 znaków" are both spelled correctly.

config.py and startup.py were two lines under the 250-line cap and both
had to grow, so config_env and startup_retry come out of them first, as
pure moves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
2026-08-30 19:14:43 +02:00
d5c63b423b feat: refuse to look healthy while deaf, and heal the container by itself
Today the bot was up, healthy and answering nothing for three hours. A host
firewall change (fixed in testsAndMisc 334c0cd9) cut every Docker container
off the internet; signal-cli lost Signal and reconnected in a loop from
15:32:50 onward. Nothing noticed:

  - the container's own healthcheck answered 204
  - /v1/groups answered 200 out of local state, so discovery saw no change
  - the bot's WebSocket to the container stayed OPEN, so the transport's
    reconnect logic never fired
  - /healthz answered {"status": "ok"} the entire time

The only symptom was silence, which is also what a quiet evening looks like.

signal_bot/liveness.py asks the one question that cannot be answered from a
cache -- GET /v1/devices, which needs a live round trip -- but only after
SILENCE_SECONDS (120) with no frame of any kind, so a busy group costs
nothing and an idle one costs one request per window. Three consecutive
failures and /healthz answers 503 with the reason. Verified end to end in
scripts/e2e.sh against the real process: 503 after 84s with the fake's
connection cut, back to 200 on its own 30s after restoring it.

scripts/signal_api_watchdog.sh + a two-minute user timer do the recovery the
bot cannot do for itself, since a wedged bot cannot restart the container it
depends on. Two tiers, so the steady state costs no Signal round trip at
all: the bot's own /healthz first, the authenticated probe only when that is
not 200. It stops after 3 restarts in 6 hours and escalates instead -- a
deregistered account or a blocked network is not fixable by restarting, and
a restart loop would hide it. Its own bash suite stubs curl and docker and
drives every branch including the give-up cap.

signal_bot/trust.py answers the other half: TRUSTED_INVITERS decides who may
put this bot in a group. The gate sits in GroupDirectory._permitted, which
both _join_pending and _adopt already route through -- gating only invites
would gate the door nobody uses, since prime_profile.sh exists precisely so
that an add becomes a direct join. Evidence is either the group-update
envelope, which is the only frame naming an inviter, or a trusted identity
being a full member in the listing. A refusal is logged once, with that
group's members, so the operator can see which string to add.

verify_allowlist moved to membership.py to keep discovery.py under the
250-line gate; it was already the module that owns MembershipError.

Verified live, by stopping the container by hand and touching nothing else:

  18:54:35  docker stop signal-api
  18:56:36  liveness: failure 1 of 3 after 123s of silence
  18:57:36  failure 3 of 3 -> /healthz answers 503 from 18:57:45
  18:58:18  watchdog: probe failed (1/2)
  19:00:28  watchdog: probe failed (2/2) -> docker restart signal-api
  19:00:36  liveness: receive path recovered after 8 failed probe(s)
  19:01:13  watchdog: signal-api is back

Six minutes, unattended, against three hours of silence this afternoon. All
four real groups are still adopted with TRUSTED_INVITERS=+48123456789 set,
and no group was refused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbjfWdAyHbCubac7wJKJZW
2026-08-29 19:01:55 +02:00
0c4b259c17 feat: discover groups from the account, so adding the bot is the whole setup
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 15:30:08 +02:00
5d76adf4db feat: watch several groups, and answer only where you were asked
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 15:03:24 +02:00
b48c945d89 feat: make the bot actually reply on Signal, and gate every way it went silent
First live reply, as its own registered account. `!cat` and `!dog` from the
phone return images -- verified in the production shape (the bot *invited to
an already-existing group*) and under a systemd user unit.

Four failures, each of which left the bot looking perfectly healthy:

- Invited is not joined. Signal only adds an account outright if the adder
  holds its profile key; with no profile the add becomes a pending invite,
  and signal-cli cannot accept one addressed to a phone number ("Cannot find
  service ID for self"). Group listed, websocket up, /healthz OK, zero
  messages. register_bot.sh now sets the profile *before* the add, and
  startup refuses on `member: false`.
- MODE=json-rpc-native is a GraalVM build with no AWT: text sends work,
  every attachment 400s with "Can't load library: awt". Both mode guards now
  demand exactly json-rpc, and start_container replaces a wrong-mode
  container instead of reusing it.
- The API spells one group two ways -- /v1/groups returns `group.<base64>`,
  envelopes carry what that decodes to -- so the scoping gate rejected the
  bot's own group. e2e.sh now models both spellings; reverting the fix fails
  4 scenarios, which is why the offline suite missed it before.
- BIND_PORT in .env was dead: __main__ read os.getenv before anything loaded
  a .env. The bind address comes from Config now.

A taken port used to be uvicorn logging the collision and exiting 0, which
every supervisor reads as success. portcheck.py refuses to start, exits
non-zero, and names the holder by reading /proc directly -- no subprocess, no
PATH assumptions, testable against a fake proc tree. Default port is 8137:
not 8000, and below the ephemeral range so the kernel cannot take it.

The bot now runs as signal-bot.service (Restart=always, linger checked rather
than assumed, StartLimitBurst so a config error fails visibly instead of
looping). Verified by SIGKILL and by re-running a command under the unit.

100% branch coverage, 230 tests, all gates green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yaBT45KhHqxTKVCTA87iy
2026-08-29 14:37:27 +02:00
6bde3493a4 feat: rewrite the bot so it runs, and gate it like the other repos
The bot did not work. `run.sh` started a FastAPI app whose
`@app.on_event("startup")` awaited `asyncio.gather()` on two infinite
tasks, so uvicorn never finished starting up and no HTTP was ever served;
the readme's `python main.py` was a no-op with no `__main__` block and no
`uvicorn.run()` call. All three CI workflows were red, one referencing an
`environment.yml` that does not exist.

Rewritten as a package behind an injectable transport seam, which is what
makes the offline environment possible.

Bugs fixed:
- startup deadlock: lifespan spawns the loops instead of awaiting them
- sticker filter read dataMessage.sticker even on syncMessage envelopes, so
  the account's own stickers were never filtered (e038772, 53028cc, 07fa90d,
  2416cef all fought this symptom)
- no websocket reconnect: a container restart killed the bot silently
- blocking `requests` inside async functions stalled the event loop
- every counted message DMed the whole tally to the bot itself
- images were written to CWD under a filename taken from the remote URL
- one global 10s cooldown shared across all users and commands
- dead code: RECEIVE_URL, get_attachments (no call site), USER_MESSAGE_COUNT
- .env.sample declared PRIMARY/SECONDARY_PHONE_NUMBER and MODE, none read

Scoping is now an enforced invariant. A linked device receives every DM and
every group on the account; GROUP_ID was the only thing narrowing that and
defaulted to "". Config refuses to start unscoped, direct messages are
dropped explicitly rather than by a falsy check, and a scenario asserts that
DMs and other groups produce zero outbound messages.

Offline environment: fake_signal/ serves the real protocol surface, so the
unmodified bot connects to it by pointing API_BASE_URL at it. scripts/e2e.sh
boots both, replays 7 scenarios and exits non-zero on mismatch. A local
Signal server is not achievable (signal-cli compiles its endpoints and trust
store in; Signal-Server needs FoundationDB), so faking this boundary is the
only way to test every feature without an account.

Transport pinned to bbernhard/signal-cli-rest-api:0.100 with
MODE=json-rpc-native. The readme pinned 0.167-dev; the dev tag series counts
separately, so that was older than 0.100, not newer. /v1/receive is only a
WebSocket in a json-rpc mode, so the mode is verified at startup.

Repo standards: ruff select=ALL, mypy --strict, 189 tests at 100% branch
coverage, 250-line cap, exact pins at newest stable, shared gates delegated
to ~/utils, one CI workflow replacing three red ones. No noqa, no
type: ignore, no coverage omits anywhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yaBT45KhHqxTKVCTA87iy
2026-08-29 13:05:42 +02:00