mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:27:20 +01:00
da016e1b94
9 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| 3a348a550c |
fix: the watchdog stops burying the desktop in popups nobody asked for
Some checks are pending
CI / check (push) Waiting to run
Three defects, and the loudest one was self-inflicted. test_watchdog.sh stubbed only curl and docker, so notify-send resolved to the real binary and the suite fired a dozen genuine critical popups every run -- from a pre-commit hook. On 2026-08-31 the timer emitted zero messages and the desktop still collected 297, all of them from committing. The stubs now live in the harness, and the suite asserts the counts. give_up() had no once-only flag, so a six-hour window announced itself every two minutes -- up to 180 times -- while its own text said "no further restarts". A stamp in the state dir now gates the popup, and only the popup: journald still gets every line, which is why the existing stdout assertions are untouched. 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 one slot. WATCHDOG_NOTIFY picks how much reaches the desktop at all; it defaults to escalation, so the routine restart/recovery pair is journald-only from here. test_watchdog.sh crossed the 250-line cap, so the sandbox and the assertions moved to test_watchdog_harness.sh, and the hook that runs the suite now watches all three files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01416TegJ3sa8xL5qGGgrygP |
|||
| 9d546fef9a |
feat: import a folder of photos without importing it twice
tool/tagger.py did not run at all:
|
|||
| 37a27c6dff |
feat: refuse to commit personal data, learned from outside the repository
The leak this repository had was not carelessness with a secret. It was somebody reaching for a real name because it was the example in front of them -- a fixture built from a live database, an envelope pasted out of a running container, a help text written while looking at the actual group. Three different people-shaped mistakes, none of which a rule about "be careful" would have stopped. So it is a gate. The hard part is that the thing being checked must not contain the list of what to refuse, or the check *is* the leak. It reads the names at run time from outside: the photo pack's manifest, the message database, the local .env. It never prints the word it matched, because this output reaches CI logs. On a machine with none of that private data it says "shapes only" out loud rather than passing quietly, and CI enforces the half that needs nothing -- phone numbers, uuids and Signal group ids, compared against an allowlist of values invented for the fixtures. It runs on staged files, on the commit message -- where two of the leaks actually travelled -- and over the whole tree in CI. It paid for itself twice before it was wired up. The live test group's id was in tests/test_config.py in two spellings, and truncated in three more files, after four rounds of grepping by hand had pronounced the tree clean -- nobody thinks to grep for base64. Then it refused the commit that introduced it, because DOCS-privacy.md had used a real name as the example of a name. An allowlist line may be scoped to one file, which is how the given names that are also popes stay legal in popes.json and refused in every fixture that has no business carrying them. The threshold is three characters, because one of this group's given names is three characters long. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017w1HZ3cZ7neL3rFdQ6VMBA |
|||
| 0f824c441e |
feat: block binaries from a public repo, and write down the real attachment frame
Two halves of the same ask: the message database is private and must stay that way. The database was already gitignored, and the event table is pinned by a test that asserts its exact column set so no message body can be quietly added. What was missing was any defence against a binary reaching this *public* repo at all -- and the proof it was missing was sitting in the tree: three `bottles/**/indexeddb.sqlite` files, committed in "register the bot as its own Signal account". Their contents turned out to be harmless (one row, 120 bytes, a userPreferences store from developer.android.com), but the path into history was real. So the shared gate from ~/utils is wired in, the three files are untracked, and .gitignore now blocks *.sqlite3 along with its -wal and -shm companions rather than only the one filename. The second half is the attachment envelope, captured from the live API rather than assumed -- which is what 0.0.3 taught. A caption-less PNG posted in the real group confirmed `attachments[].contentType`, and settled something that was not guessable: a photo with no caption sends `message: null`. That is exactly the frame `should_count` used to value at zero. Verified end to end on production data, not a fixture: the frame at 19:31:16 was recorded as media_kind=image, media_count=1, char_len=0, filed under 2026-08-30 hour 19. The doc also records the fields nothing reads yet, and one open question left deliberately open: `caption` is a field on the attachment and was null here, so whether a captioned photo puts its text in `message` or in `attachments[].caption` has not been observed. If it is the latter, char_len is recording 0 for those. DEBUG stays on until a `caption: str` settles it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom |
|||
| 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
|
|||
| 7984c65f8a |
feat: register the bot as its own Signal account, no phone app needed
Shelter cannot work on this phone, and it would not have helped anyway.
It cannot work: setting a Device Owner makes Android enable
no_add_managed_profile, no_add_private_profile and no_add_clone_profile on
every user, which kills Shelter, Island, Private Space and app cloning
alike. Verified on-device rather than inferred -- `pm create-user
--profileOf 0 --managed` returns "Cannot add user: no_add_managed_profile is
enabled". This is the Android 11 COMP deprecation, enforced in three places
in DevicePolicyManagerService and unchanged through Android 16.
It would not have helped: a second Signal *app* is not a second Signal
*account*. Every account needs its own phone number, so the second number
was always the real requirement -- and once you have one, signal-cli can
register it as a PRIMARY device with no phone app, no QR scan and no work
profile at all. That is strictly better than both the linked-device setup
and the Shelter plan: the bot cannot read personal DMs and never posts as
the account owner.
scripts/register_bot.sh drives that flow. It gates on signal-cli >= 0.14.7
rather than hoping: stable container tag 0.100 bundles 0.14.5, and on
2026-03-06 Signal made the post-quantum ratchet mandatory and de-registered
every account whose client lacked it -- a stale client is how a bot dies
silently months later. Verified that the recommended CI tag really does
bundle 0.14.7 instead of assuming it.
Rehearsed offline, not written blind. fake_signal grew /v1/accounts,
/v1/register/{number}, /v1/register/{number}/verify/{token} and
/v1/groups/{number}, so the whole runbook including every error path
(missing captcha, stray verify, wrong code, unregistered number, empty group
list, out-of-range and non-numeric picks) runs against the fake. The picker
was proven to resolve the group the user actually pointed at.
Prompts now read from $SIGNAL_TTY instead of /dev/tty directly. A hard-coded
/dev/tty made the interactive path untestable without a real terminal; the
seam is the same idea as the injected clock and transport on the Python side,
and is what let the picker be rehearsed at all.
live_smoke.sh and register_bot.sh share scripts/lib_signal.sh so the two
cannot drift, and shellcheck now runs with -x so the sourced lib is actually
linted from its callers.
201 tests, 100% branch coverage, all 23 gates green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yaBT45KhHqxTKVCTA87iy
|
|||
| 79649737a1 |
feat: gate suppressions mechanically instead of relying on discipline
This repo has zero `# noqa` and zero `# type: ignore`, and the only reason it stays that way is a gate. Nothing was enforcing it: testsAndMisc has a no-noqa pre-commit hook and signal-bot did not, so the rule lived entirely in whoever was editing. During this rewrite a suppression was reflexively typed three times before being caught by hand, which is exactly the failure a hook makes impossible. Blocked in both places: a pygrep pre-commit hook, and a CI step mirroring it so a commit made without hooks installed cannot land one on main. Verified by canary: a file containing `# noqa: F401` exits 1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yaBT45KhHqxTKVCTA87iy |
|||
| c9061b27a8 |
fix: make shellcheck agree between local and CI, and fix the group picker
CI failed on shellcheck where the local hook passed: the runner's shellcheck reports SC2015 (`A && B || C` is not if-then-else) and mine did not. Both now run with --severity=style so a version skew cannot pass locally and fail in CI. The cleanup traps are plain ifs, which is what the finding was about -- `kill` on an already-dead pid makes B fail and C run while A is true. live_smoke.sh choose_group had two bugs that only surface when it is actually run, neither reachable from the offline suite: - it fetched /v1/groups twice, once to display and once to resolve the picked index, so an unstable order would hand back a different group than the one shown. Fetched once into a variable now. - `read -r -p` wrote its prompt to stdout, which the caller captures with $(...), so the prompt text would have been swallowed into the group id. Prompt and listing both go to stderr; stdout carries only the id. Also validates that the pick is a number and in range. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013yaBT45KhHqxTKVCTA87iy |
|||
| 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
|