mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:07:29 +01:00
feat: add a photo to the album from the chat, tagged, stripped and verified
Some checks are pending
CI / check (push) Waiting to run
Some checks are pending
CI / check (push) Waiting to run
Send a photo captioned "!add_photo alfa wakacje" in one of the album's own groups and it lands in the pack tagged with both, retrievable through !alfa and the new !losowe immediately -- no restart, and no tagger session on the PC. A label matching nobody becomes a custom tag, and !losowe is the only door to one, which is what keeps a live swap a *data* swap: it can never register a trigger or widen a command's groups. No typed label travels. Labels become pseudonym keys in a new registry arg_resolver, at the dispatcher, before the line that logs the command name -- so no handler, journal entry, command_uses row or reply body ever holds a word somebody typed. The reply is counts and pseudonyms: "dodano · p1: 4 · t1: 1". Stripping is exiftool with a *positive* check on the output, never an exit code. ffmpeg was tried first and fails silently on photos: given a JPEG with a real APP1 Exif segment it returned a byte-identical file, GPS intact, exit 0. The surviving-tag allowlist is per tag *name* rather than per group, because measurement showed a group-level "must be empty" rule rejects every format except JPEG -- a valid PNG always reports its IHDR, an MP4 some fifty QuickTime tags that are the container itself. Codec configuration (CompressorName, VendorID, the HEVC parameter set) is on the list by decision: CompressorName lives in the avc1 sample description and exiftool cannot write it at all, so the alternative was refusing every video and every iPhone HEIC. Location and identity tags are still refused, and the e2e run re-reads the stored files to prove a QuickTime geotag did not survive. .webm and .svg stay on the format allowlist and are stored *unstripped*: exiftool 13.55 can write neither, and its SVG reader does not even surface <metadata>. That is a deliberate, documented hole, not an oversight. Two things this had to defuse along the way: - personal_data_sources now reads custom.json, so "!add_photo kot <photo>" would have outlawed the word "kot" and refused every future commit, fixable only by hand-editing an album the committer cannot see. Minting now refuses a word that is already a command, and the gate subtracts the repo's own alias vocabulary from custom labels only -- curated people.json names are never excused. - signal_bot/external.py is now the single subprocess site in the repository, so ruff's S603 needs exactly one scoped per-file-ignore instead of one per module that shells out. A test asserts nothing else imports subprocess. A failed add changes nothing: media and the three manifests are written, the album is reloaded, and only a load that succeeds is published; a load that fails rolls all four back and keeps serving the pack that was already working, without raising -- Restart=always would turn a ConfigError into a loop. 977 tests, 100% branch coverage, no omits and no suppressions. scripts/e2e.sh gains 8 scenarios driven against the real exiftool on media generated at run time, and asserts the invariants a reply cannot: that a refused upload is not stored, that a message with no attachment leaves custom.json byte-identical, and that signal-cli's own copy of every ingested attachment is gone. Closes TODO-add-photo.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JqqE22jp94B1ypwxppdr1o
This commit is contained in:
parent
e070fd5425
commit
aefffb2fd9
@ -26,10 +26,18 @@ This file is the trigger reference — every spelling that resolves, which
|
||||
| stats_weekdays | `!dni` `!days` |
|
||||
| stats_me | `!ja` `!moje` `!mnie` |
|
||||
| help | `!pomoc` `!pomocy` `!help` `!komendy` `!commands` |
|
||||
| add_photo | `!dodaj` `!dodaj_zdjecie` `!dodajzdjecie` `!add_photo` `!addphoto` |
|
||||
| random | `!losowe` `!losuj` `!random` `!rand` |
|
||||
|
||||
Aliases live in `signal_bot/aliases.json`, not in source, because they are
|
||||
data: adding one should never mean touching Python.
|
||||
|
||||
`add_photo` and `random` exist **only in the photo album's own groups**, and
|
||||
only on an installation that has an album at all — everywhere else they are
|
||||
not commands, not refused and never logged. Both take 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`.
|
||||
|
||||
## Why `!pomoc` shows the first alias and not a trigger
|
||||
|
||||
`Command.triggers` is a `frozenset` of **folded skeletons** — `folding.py`
|
||||
|
||||
@ -82,6 +82,123 @@ Videos work. The attachment goes out as
|
||||
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}}
|
||||
```
|
||||
|
||||
Who added which file. **Nothing reads it** — `pack_load` ignores the file
|
||||
entirely — and it exists only so `!undo_photo` can be built later without a
|
||||
gap in its history; the adder has to be recorded at add time or not at all.
|
||||
It lives inside the album, which is `0700` and outside this repository, so it
|
||||
is handled exactly like the photos.
|
||||
|
||||
## Adding from the chat: !add_photo
|
||||
|
||||
Send a photo captioned `!add_photo alfa wakacje2019` in one of the album's
|
||||
groups. `alfa` resolves to that person's key; `wakacje2019` matches nobody and
|
||||
is minted as a custom tag. The photo is retrievable through `!alfa` and
|
||||
`!random wakacje2019` **immediately**, with no restart.
|
||||
|
||||
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. The reply is counts and pseudonyms — `dodano · p1: 4 · t1: 1`.
|
||||
- **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 reported and skipped; a mixed batch stores the
|
||||
new files and says how many were already there.
|
||||
- **Nothing is written until it works.** Media, `tags.json`, `custom.json` and
|
||||
`adds.json` are written, the album is reloaded, and only a load that
|
||||
succeeds is published. A load that fails rolls all four back and keeps
|
||||
serving the album that was already working.
|
||||
- **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 stored, and also when the upload was refused.
|
||||
|
||||
### 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= -o <dst> <src> # never --overwrite_original on the temp
|
||||
exiftool -G0 -s -all <dst> # then read it back
|
||||
```
|
||||
|
||||
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`.
|
||||
|
||||
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.
|
||||
|
||||
## Filling it in: tool/tagger.py
|
||||
|
||||
```sh
|
||||
@ -106,4 +223,5 @@ the group tries it.
|
||||
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.
|
||||
sends. It also drives `!add_photo` against the real exiftool, on media it
|
||||
generates at run time, so no photo enters this repository.
|
||||
|
||||
@ -1,207 +0,0 @@
|
||||
# TODO: `!add_photo` — add an attachment to the pack, tagged
|
||||
|
||||
REMOVE ME AFTER FINISH
|
||||
|
||||
Planned 2026-08-30, not implemented. Every decision below was settled with kuhy
|
||||
during planning; they are answers, not suggestions. Do not re-litigate them.
|
||||
|
||||
## Context
|
||||
|
||||
signal-bot has a private photo album (the "pack") outside the repo at
|
||||
`~/.local/share/signal-bot/pack/`. Today the only way to put a photo into it is
|
||||
`tool/tagger.py`, a browser tool run by hand on the PC — so every new photo means
|
||||
leaving Signal, moving the file to the machine, and running a tagger session.
|
||||
|
||||
This change lets a member of the pack's group add a photo *from the chat*: send a
|
||||
photo captioned `!add_photo alfa beta` and it lands in the album tagged with
|
||||
both, immediately retrievable through each of their existing commands. A label
|
||||
matching nobody becomes a **custom tag**, reachable through a new `!random <label>`.
|
||||
|
||||
The constraint is unchanged and absolute: **no name and no photo may reach the
|
||||
repository, the journal, the SQLite database, a reply body, or any group outside
|
||||
the pack's own.** `DOCS-photo-pack.md` and the personal-data pre-commit gate exist
|
||||
because that invariant was broken once already.
|
||||
|
||||
## Decisions
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| New people | `!add_photo` never writes a person into `people.json`. An unmatched label becomes a custom tag. |
|
||||
| Photo source | Attachment on the **same** message. Never a quote/reply — `quote` carries its own `attachments` array. |
|
||||
| Who may run it | Anyone in the pack's groups. No operator concept. |
|
||||
| Scrubbing | Labels resolve to keys **at the dispatcher**; the raw remainder never travels further. |
|
||||
| Availability | Immediate: atomic write + validated hot-swap. No restart. |
|
||||
| Formats | Explicit allowlist, **magic-byte sniffed**: jpg, jpeg, png, gif, mp4 + heic, heif, webp, mov, webm, m4v, avif, svg. |
|
||||
| Stripping | `exiftool`, with a **positive** post-check. See below. |
|
||||
| Filename | Content hash. The original filename is discarded. |
|
||||
| Reply | Counts and pseudonyms only: `dodano · p1: 4 · t3: 1`. On a first custom-tag mint the reply also explains, generically, that the new tag is reached via `!random` — without echoing the label. |
|
||||
| Limits | **None** on `!add_photo` — no size cap, no daily cap, no cooldown. |
|
||||
| Undo | Out of scope. See "Deferred" below. |
|
||||
| Unit | `ReadWritePaths=%h/.local/share/signal-bot` added. The pack does not move. |
|
||||
|
||||
### Why exiftool and not ffmpeg
|
||||
|
||||
The first choice was `ffmpeg -map_metadata -1 -c copy`. It was tested and **fails
|
||||
silently on photos**: given a JPEG carrying a real APP1 Exif segment it produced a
|
||||
byte-identical file (292 → 292 bytes, GPS marker intact) and exited 0. `-c copy`
|
||||
copies the compressed frame as an opaque packet and JPEG's Exif lives inside it.
|
||||
That ffmpeg also has no HEIF/HEIC demuxer (`ffmpeg -demuxers | grep -ci heif` → 0).
|
||||
**Do not reintroduce ffmpeg for this.**
|
||||
|
||||
Use `perl-image-exiftool` (Arch `extra`, 13.55-1), which covers the whole allowlist
|
||||
losslessly. The gate is a **positive check on the output**, not an exit code — an
|
||||
exit code is exactly what lied:
|
||||
|
||||
```
|
||||
exiftool -all= -o <dst> <src> # never --overwrite_original on the temp
|
||||
exiftool -G0 -s -all <dst>
|
||||
reject unless the surviving tag set is empty apart from the computed/filesystem
|
||||
pseudo-groups File, Composite, ExifTool
|
||||
```
|
||||
|
||||
**Allowlist, not blocklist.** The obvious gate — grepping `-Exif:all -GPS:all
|
||||
-XMP:all` — is blind to exactly the case that matters most: an MP4/MOV geotag lives
|
||||
in the QuickTime `udta`/`©xyz` atom and is reported under group **`QuickTime`**, not
|
||||
`GPS`. Enumerating groups to look for is how the next one gets missed; requiring the
|
||||
set to be empty cannot be. Verify at implementation time that exiftool's MP4 writer
|
||||
honours `-all=` on this version — if it does not, video is rejected rather than
|
||||
stored.
|
||||
|
||||
SVG has no magic bytes, so sniffing special-cases a leading `<?xml` / `<svg` prefix.
|
||||
It is **not** exempt from the check: SVG can carry `<metadata>` RDF and can embed a
|
||||
whole raster (`<image xlink:href="data:image/jpeg;base64,…">`) with its EXIF intact.
|
||||
exiftool has an SVG reader, so SVG runs the same strip-and-verify as everything else.
|
||||
|
||||
**Temp files go in `/tmp`**, which the unit already makes per-service via
|
||||
`PrivateTmp=true`. Staging next to `media/` would let a crash mid-strip leave an
|
||||
unstripped original inside the album permanently, where nothing in `pack_load` would
|
||||
ever flag it.
|
||||
|
||||
### The second copy
|
||||
|
||||
signal-cli keeps its own copy of every inbound attachment in the container's
|
||||
attachment store, outside the album's `0700` permissions. That is pre-existing for
|
||||
every photo the group sends, but this feature is the first path that ingests one
|
||||
deliberately, so it is in scope for "inaccessible from outside":
|
||||
`Transport.delete_attachment` already exists and is called nowhere in production —
|
||||
`!add_photo` calls it once the file is safely stored, whether the upload was accepted
|
||||
or rejected.
|
||||
|
||||
## Implementation
|
||||
|
||||
### New modules
|
||||
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| `signal_bot/pack_custom.py` | `custom.json` load/mint/write. Labels folded like person triggers; keys `t1`, `t2`, …; atomic temp-then-`Path.replace` as `tool/tagger.py::Album.write` already does. |
|
||||
| `signal_bot/pack_add.py` | The `!add_photo` handler: fetch → sniff → strip → verify → hash-name → write `media/` → merge `tags.json` → hot-swap → reply. |
|
||||
| `signal_bot/pack_random.py` | The `!random` handler and its non-minting resolver. |
|
||||
| `signal_bot/media_sniff.py` | Magic-byte detection over the allowlist + the SVG prefix case; maps to suffix and MIME. |
|
||||
| `signal_bot/media_strip.py` | `Stripper` protocol and the real exiftool implementation. |
|
||||
| `signal_bot/pack_holder.py` | The mutable holder (below). |
|
||||
|
||||
`pack_load.py` is at 243 of the 250-line cap, so its custom-tag validation moves
|
||||
into `pack_custom.py` rather than growing it. `pack.py` is at 155.
|
||||
|
||||
### The scrubbing seam (load-bearing)
|
||||
|
||||
`registry.resolve_with_args` currently returns `Resolved(head, tuple(words[1:]))` —
|
||||
raw words straight into `CommandContext.args`. Add an optional per-command
|
||||
`arg_resolver` set at registration; `resolve_with_args` applies it and yields keys
|
||||
only, so no handler, log line or `command_uses` row ever sees a typed label.
|
||||
|
||||
Two resolvers, deliberately different:
|
||||
|
||||
- **`pack:add`** — minting. Known label → the person's key; unknown label → a *pending*
|
||||
`t`-key held in memory only. Nothing is written until the upload succeeds; a
|
||||
rejected upload or a missing attachment discards the pending mint and leaves
|
||||
`custom.json` untouched. `!add_photo dog` with no attachment must change nothing
|
||||
on disk.
|
||||
- **`pack:random`** — non-minting. Unknown label → `None` → the existing `NO_PHOTO`
|
||||
line. If it shared the minting resolver every typo on `!random` would mint a key.
|
||||
|
||||
### `!random` semantics
|
||||
|
||||
No arguments: a random photo from the whole album. Two or more labels: **intersection**
|
||||
(reuse `Pack.matching`, which is already a superset test), ceiling `MAX_NAMES = 6`.
|
||||
`!random` resolves **custom tags only, never people** — `!alfa` already exists for
|
||||
that, and a second entrance with an unscoped argument list is one too many.
|
||||
|
||||
### Hot-swap
|
||||
|
||||
`pack_command.pack_handler` closes over the frozen `Pack` (`pack_command.py:62-78`),
|
||||
so a newly-tagged photo would stay invisible to the existing `pack:p1` handler.
|
||||
Introduce a `PackHolder` with a mutable `.pack` that **every** pack handler reads at
|
||||
call time. `bot.py::build_bot` constructs the holder once and passes it where it
|
||||
passes `pack` today.
|
||||
|
||||
Swap order: write media atomically → write `tags.json`/`custom.json` atomically →
|
||||
re-run `pack_load.load` → on success assign `holder.pack`; **on failure roll back the
|
||||
files and keep serving the old pack.** Never raise: `Restart=always` would loop.
|
||||
|
||||
Custom tags never become commands, so a swap only ever replaces *Pack data* — it
|
||||
never re-registers commands or recomputes `Command.groups`. That is what makes
|
||||
immediate availability cheap, and it is the property to preserve.
|
||||
|
||||
### Gaps that must be filled
|
||||
|
||||
- `models.py::_attachments` keeps only `contentType` and discards the attachment
|
||||
`id`. It must carry the id without breaking `counting.media_kind` / `should_count`.
|
||||
- `Transport` (`transport_base.py:58-62`) has `list_attachments` / `delete_attachment`
|
||||
but no fetch. Add `fetch_attachment(attachment_id) -> bytes` to the Protocol,
|
||||
`RestTransport` (`GET /v1/attachments/{attachment}`, verified present on the live
|
||||
container's `/swagger/doc.json`) and `FakeTransport`. `tests/test_transport_base.py`
|
||||
asserts the Protocol's member names and must be updated.
|
||||
- `fake_signal` cannot inject an inbound attachment at all: no GET-by-id route,
|
||||
`state.py` stores no bytes, and `scenario.py::Injection` has no attachment field
|
||||
(`_build` rejects unknown keys). All three need extending or `scripts/e2e.sh`
|
||||
cannot prove any of this offline.
|
||||
- `.svg` → `image/svg+xml` must join `pack.MEDIA_TYPES`, because
|
||||
`pack_load._media_file` validates the suffix against it and a stored SVG would
|
||||
otherwise fail the *next* load and take startup down.
|
||||
- `scripts/personal_data_sources.py::_pack_names` must read `custom.json` labels
|
||||
alongside `people.json` triggers — a label can be a name (`!add_photo alfa_wedding`).
|
||||
- Any new SQLite table uses `CREATE TABLE IF NOT EXISTS` and must **not** bump
|
||||
`SCHEMA_VERSION` (a bump DROPs the ephemeral tables).
|
||||
- `scripts/install_service.sh` gains the `perl-image-exiftool` dependency (there is
|
||||
no `install.sh`; that script is the installer).
|
||||
- `DOCS-photo-pack.md` and `DOCS-commands.md` both need updating.
|
||||
|
||||
### Assumption to confirm with kuhy before coding
|
||||
|
||||
`!random` is a new command and inherits the standard pack cooldown decorator;
|
||||
`!add_photo` gets no decorator at all, per "NO CAP, NO LIMITS".
|
||||
|
||||
## Verification
|
||||
|
||||
1. **Unit** — `pytest`, which enforces 100% branch coverage with no omits and no
|
||||
suppressions. The stripper **must** be an injected `Stripper` seam so unit tests
|
||||
use a fake: `tests/pack_fixture.py` builds packs from a 1×1 PNG and a *fake* mp4
|
||||
(`CLIP`) that real exiftool would reject. New tests assert, explicitly, that a
|
||||
typed label appears in **no log record, no `command_uses` row and no reply body**.
|
||||
2. **End-to-end** — new `scripts/e2e.sh` scenarios against the extended `fake_signal`:
|
||||
add tags an existing person and `!<name>` returns it; add mints a custom tag and
|
||||
`!random <label>` returns it; two labels tag both; `!add_photo` with no attachment
|
||||
changes nothing on disk; a rejected file is not stored; the same commands in another
|
||||
group produce **zero** sends.
|
||||
3. **Real exiftool** — exercised only in `e2e.sh`, against generated media, covering
|
||||
all three regressions this plan came from:
|
||||
- a JPEG with a real APP1 Exif segment comes out with the marker gone;
|
||||
- an MP4 written with `exiftool -GPSCoordinates=…` comes out with **no** surviving
|
||||
`QuickTime` group tag (the case a naive `-GPS:all` gate would pass);
|
||||
- an SVG with `<metadata>` and an embedded base64 JPEG is rejected or cleaned.
|
||||
4. **The second copy** — after an add, assert the attachment is gone from the fake's
|
||||
store, so "inaccessible from outside" is tested and not just asserted.
|
||||
5. **Live** — `pre-commit run --all-files`, restart the unit, then in the real group
|
||||
send one photo captioned `!add_photo <existing person>` and confirm the reply is
|
||||
counts-only and the person's own command returns the new photo without a restart.
|
||||
Confirm with `journalctl --user -u signal-bot` that no label appears in the log.
|
||||
|
||||
## Deferred (kuhy's Q15: "no functionality like this please — store it in todo")
|
||||
|
||||
`!undo_photo` — removing the most recent photo added by the calling user, within a
|
||||
short window. Not being built. It matters because anyone in the group may add and
|
||||
there is no removal path, so a bad upload is permanent until `tags.json` is edited by
|
||||
hand; `tool/tagger.py` is the escape hatch meanwhile. Implementing it later needs the
|
||||
adder's id recorded at add time, so consider recording it now even though nothing
|
||||
reads it yet.
|
||||
@ -57,21 +57,41 @@ def build_api_router() -> APIRouter:
|
||||
_state(request).record_send(payload)
|
||||
return JSONResponse({"timestamp": 1719177917717}, status_code=201)
|
||||
|
||||
_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)
|
||||
|
||||
@ -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:
|
||||
|
||||
9
fake_signal/scenarios/pack_add_custom.yaml
Normal file
9
fake_signal/scenarios/pack_add_custom.yaml
Normal file
@ -0,0 +1,9 @@
|
||||
# A label matching nobody becomes a custom tag, and !losowe is the only door
|
||||
# to it. The typed label must appear in neither reply.
|
||||
name: "!dodaj mints a custom tag and !losowe finds it"
|
||||
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: "NOWA ETYKIETA" }
|
||||
- { kind: image, to: reply }
|
||||
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 stored file to prove.
|
||||
name: "a geotagged heic is accepted and comes out 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.
|
||||
expect:
|
||||
- { kind: text, to: reply, contains: "dodano" }
|
||||
8
fake_signal/scenarios/pack_add_no_attachment.yaml
Normal file
8
fake_signal/scenarios/pack_add_no_attachment.yaml
Normal file
@ -0,0 +1,8 @@
|
||||
# The pending mint is held in memory and never reaches custom.json: this is
|
||||
# the case that must leave the album byte-identical. scripts/e2e.sh checks
|
||||
# the file itself, 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" }
|
||||
12
fake_signal/scenarios/pack_add_person.yaml
Normal file
12
fake_signal/scenarios/pack_add_person.yaml
Normal file
@ -0,0 +1,12 @@
|
||||
# The feature working, in the shape the group will actually use it: a photo
|
||||
# captioned with somebody's name, retrievable through their own command with
|
||||
# no restart in between. The attachment carries a real APP1 Exif segment
|
||||
# (tool/e2e_media.py puts GPS, a Make and an XMP creator in it); the reply
|
||||
# must still be counts and pseudonyms only.
|
||||
name: "!dodaj tags an existing person and !alfa returns it without a restart"
|
||||
inject:
|
||||
- { text: "!dodaj alfa", uuid: uuid-add-person, name: AddPerson, attachment: att-person }
|
||||
- { text: "!alfa", uuid: uuid-add-person-b, name: AddPersonB }
|
||||
expect:
|
||||
- { kind: text, to: reply, contains: "dodano · p1:" }
|
||||
- { kind: image, to: reply }
|
||||
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 stored"
|
||||
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 stored, 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 stored 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 stored 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: "dodano" }
|
||||
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 stored file and fails the run if the atom survived.
|
||||
name: "a geotagged mp4 is stripped of its QuickTime atom and stored"
|
||||
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.
|
||||
expect:
|
||||
- { kind: text, to: reply, contains: "dodano" }
|
||||
@ -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
|
||||
|
||||
@ -61,6 +61,12 @@ fixable = ["ALL"]
|
||||
# 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.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"]
|
||||
"tests/**/*.py" = [
|
||||
"ARG", # fixture and mock params exist for their side effect
|
||||
"D", # test names are the documentation
|
||||
|
||||
@ -147,6 +147,9 @@ main() {
|
||||
wait_for_url "http://127.0.0.1:${BOT_PORT}/healthz" "bot"
|
||||
wait_for_subscription
|
||||
|
||||
echo "== generating media for !add_photo (real exif, never committed)"
|
||||
"$PYTHON" "$REPO_ROOT/tool/e2e_media.py" --upload "$FAKE_URL"
|
||||
|
||||
echo "== replaying scenarios"
|
||||
"$PYTHON" -m fake_signal.cli \
|
||||
"$REPO_ROOT"/fake_signal/scenarios/*.yaml \
|
||||
@ -161,9 +164,51 @@ main() {
|
||||
--group "fourth_reply=${FOURTH_GROUP}" \
|
||||
--group "other=${OTHER_GROUP}"
|
||||
|
||||
prove_the_add_photo_invariants
|
||||
prove_the_deafness_gate
|
||||
}
|
||||
|
||||
# The three claims a reply cannot make on its own: nothing was written when
|
||||
# nothing was stored, nothing kept its metadata, and no second copy survived.
|
||||
prove_the_add_photo_invariants() {
|
||||
local pack="${TEMP_DIR}/pack"
|
||||
|
||||
echo "== checking every stored file was actually stripped"
|
||||
"$PYTHON" "$REPO_ROOT/tool/e2e_media.py" --verify-pack "$pack"
|
||||
|
||||
echo "== checking a typed label never reached custom.json as a key"
|
||||
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
|
||||
|
||||
echo "== checking !dodaj with no attachment minted nothing"
|
||||
if grep -q "piesek" "${pack}/custom.json"; then
|
||||
echo "Error: a mint survived a message that carried no attachment" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== checking the refused uploads were not stored"
|
||||
if grep -q "att-junk" "${pack}/tags.json"; then
|
||||
echo "Error: a refused file reached tags.json" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== checking signal-cli's own copies were deleted"
|
||||
local left
|
||||
left="$(curl -sf "${FAKE_URL}/_control/attachments")"
|
||||
if [[ "$left" == *"att-person"* || "$left" == *"att-custom"* ]]; then
|
||||
echo "Error: an ingested attachment is still in the container store: ${left}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== checking the adder was recorded for a future !undo_photo"
|
||||
if [[ ! -s "${pack}/adds.json" ]]; then
|
||||
echo "Error: adds.json was never written" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 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.
|
||||
|
||||
@ -47,8 +47,23 @@ 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
|
||||
return 0
|
||||
fi
|
||||
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
|
||||
}
|
||||
|
||||
validate_requirements() {
|
||||
command -v systemctl >/dev/null || die "systemctl not found; this needs systemd"
|
||||
ensure_exiftool
|
||||
[[ -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"
|
||||
|
||||
@ -18,7 +18,7 @@ import json
|
||||
import os
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
from typing import Any, Final
|
||||
|
||||
REPO: Final = Path(__file__).resolve().parent.parent
|
||||
|
||||
@ -36,13 +36,7 @@ PREFIX: Final = 8
|
||||
|
||||
def _pack_names(directory: Path) -> set[str]:
|
||||
"""Every trigger word in a photo pack manifest -- i.e. every name."""
|
||||
manifest = directory / "people.json"
|
||||
if not manifest.is_file():
|
||||
return set()
|
||||
try:
|
||||
document = json.loads(manifest.read_text(encoding="utf-8"))
|
||||
except OSError, json.JSONDecodeError:
|
||||
return set()
|
||||
document = _pack_document(directory / "people.json")
|
||||
entries = [*document.get("people", []), *document.get("combos", [])]
|
||||
return {
|
||||
str(trigger).lstrip("!").strip()
|
||||
@ -51,6 +45,33 @@ def _pack_names(directory: Path) -> set[str]:
|
||||
}
|
||||
|
||||
|
||||
def _custom_labels(directory: Path) -> set[str]:
|
||||
"""Every label !add_photo minted -- 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.
|
||||
"""
|
||||
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}
|
||||
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():
|
||||
@ -152,6 +173,7 @@ def forbidden(permitted: set[str]) -> tuple[set[str], 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")),
|
||||
@ -162,3 +184,29 @@ def forbidden(permitted: set[str]) -> tuple[set[str], list[str]]:
|
||||
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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -90,5 +90,18 @@
|
||||
"!help",
|
||||
"!komendy",
|
||||
"!commands"
|
||||
],
|
||||
"add_photo": [
|
||||
"!dodaj",
|
||||
"!dodaj_zdjecie",
|
||||
"!dodajzdjecie",
|
||||
"!add_photo",
|
||||
"!addphoto"
|
||||
],
|
||||
"random": [
|
||||
"!losowe",
|
||||
"!losuj",
|
||||
"!random",
|
||||
"!rand"
|
||||
]
|
||||
}
|
||||
|
||||
@ -14,15 +14,19 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from signal_bot.animals import register_animal_commands
|
||||
from signal_bot.animals import load_aliases, register_animal_commands
|
||||
from signal_bot.announce import announce_all
|
||||
from signal_bot.changelog import load as load_changelog
|
||||
from signal_bot.decorators import cooldown
|
||||
from signal_bot.dispatcher import Dispatcher
|
||||
from signal_bot.help_command import register_help_command
|
||||
from signal_bot.http_client import ImageFetcher
|
||||
from signal_bot.media_strip import ExiftoolStripper
|
||||
from signal_bot.pack_add import register_add_command
|
||||
from signal_bot.pack_command import register_pack_commands
|
||||
from signal_bot.pack_holder import PackHolder
|
||||
from signal_bot.pack_load import load as load_pack
|
||||
from signal_bot.pack_random import register_random_command
|
||||
from signal_bot.popes_command import register_pope_command
|
||||
from signal_bot.portraits import PortraitCache
|
||||
from signal_bot.registry import CommandRegistry
|
||||
@ -33,9 +37,12 @@ from signal_bot.stats.nightly import days_to_report, mark_reported
|
||||
from signal_bot.versioning import POPE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from signal_bot.config import Config
|
||||
from signal_bot.decorators import Decorator
|
||||
from signal_bot.discovery import GroupDirectory
|
||||
from signal_bot.liveness import Liveness
|
||||
from signal_bot.store import Store
|
||||
@ -155,6 +162,20 @@ class Bot:
|
||||
await self.dispatcher.handle(frame)
|
||||
|
||||
|
||||
def _register_album(registry: CommandRegistry, directory: Path, rate_limit: Decorator) -> None:
|
||||
"""Wire the album's commands, all of them reading one mutable holder.
|
||||
|
||||
!random takes the same cooldown as every other photo command; !add_photo
|
||||
takes no decorator at all, per "NO CAP, NO LIMITS" -- no cooldown, no
|
||||
size cap, no daily limit.
|
||||
"""
|
||||
aliases = load_aliases()
|
||||
holder = PackHolder(load_pack(directory))
|
||||
register_pack_commands(registry, holder, decorate=rate_limit)
|
||||
register_random_command(registry, holder, tuple(aliases["random"]), decorate=rate_limit)
|
||||
register_add_command(registry, holder, ExiftoolStripper(), tuple(aliases["add_photo"]))
|
||||
|
||||
|
||||
def build_bot(
|
||||
config: Config,
|
||||
client: httpx.AsyncClient,
|
||||
@ -181,7 +202,7 @@ def build_bot(
|
||||
# album's own groups when it does -- so on every other machine, and in
|
||||
# every other group, these commands simply do not exist.
|
||||
if config.photo_pack_dir is not None:
|
||||
register_pack_commands(registry, load_pack(config.photo_pack_dir), decorate=rate_limit)
|
||||
_register_album(registry, config.photo_pack_dir, rate_limit)
|
||||
# Last, so !pomoc trails the list it prints; it reads the registry
|
||||
# when it runs, so the order is presentation only.
|
||||
register_help_command(registry, decorate=rate_limit)
|
||||
|
||||
79
signal_bot/external.py
Normal file
79
signal_bot/external.py
Normal file
@ -0,0 +1,79 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""The one place in this repository that starts a subprocess.
|
||||
|
||||
Deliberately one place. Ruff's ``ALL`` set flags every ``subprocess`` call
|
||||
with ``S603`` ("check for execution of untrusted input") and there is no
|
||||
code-level answer to it: exiftool is a binary, and the pure-Python EXIF
|
||||
libraries handle neither HEIC nor MP4, which is most of what the album
|
||||
ingests. So the rule is suppressed exactly once, for this file, in
|
||||
``pyproject.toml`` -- a scoped and reviewable entry rather than an inline
|
||||
per-line suppression, which this repository's own pre-commit hook blocks
|
||||
outright, and which would be free to spread file by file.
|
||||
|
||||
Everything that needs an external tool imports ``run`` from here. If a second
|
||||
file ever calls ``subprocess`` directly, the gate fails and says so, which is
|
||||
the property the single entry buys.
|
||||
|
||||
``run`` never raises and never lets a caller mistake failure for success:
|
||||
a non-zero exit, a missing binary and a timeout all come back as
|
||||
``ok=False``, because the one thing this must not do is look like it worked.
|
||||
An exit code that lied is what put ffmpeg in this codebase's history.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Long enough for exiftool to rewrite a phone video's container, short enough
|
||||
# that a wedged binary cannot hold a command handler open indefinitely.
|
||||
DEFAULT_TIMEOUT: Final = 60.0
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Result:
|
||||
"""What one external command did, with failure spelled out rather than raised."""
|
||||
|
||||
ok: bool
|
||||
stdout: str
|
||||
stderr: str
|
||||
|
||||
|
||||
def run(command: Sequence[str], *, timeout: float = DEFAULT_TIMEOUT) -> Result:
|
||||
"""Run one external command and report what happened.
|
||||
|
||||
Args:
|
||||
command: The argv, already split. Never a shell string: nothing here
|
||||
interpolates into a shell, and every element is built by the
|
||||
caller rather than taken from a message.
|
||||
timeout: Seconds before the command is killed and reported failed.
|
||||
|
||||
Returns:
|
||||
The command's output, and whether it actually succeeded.
|
||||
"""
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
list(command),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as error:
|
||||
# A missing binary, a permissions problem, or a timeout. The command
|
||||
# is logged, never its output: exiftool prints tag values on failure.
|
||||
logger.warning("could not run %s: %s", command[0], error)
|
||||
return Result(ok=False, stdout="", stderr=str(error))
|
||||
if completed.returncode != 0:
|
||||
logger.warning(
|
||||
"%s exited %d: %s", command[0], completed.returncode, completed.stderr.strip()
|
||||
)
|
||||
return Result(ok=False, stdout=completed.stdout, stderr=completed.stderr)
|
||||
return Result(ok=True, stdout=completed.stdout, stderr=completed.stderr)
|
||||
@ -67,6 +67,13 @@ SPELLINGS: Final = "Każda komenda ma też inne pisownie, np. !cat, !stats, !wee
|
||||
PACK_HEADING: Final = "Zdjęcia"
|
||||
PACK_HINT: Final = "Kilka naraz ({first} {second}) = zdjęcie, na którym są wszyscy."
|
||||
|
||||
# The two commands that act on the album rather than posting one person from
|
||||
# it. Their own block, so the "kilka naraz" hint above keeps describing the
|
||||
# people entries it was written for. Spelled from NAME_PREFIX rather than
|
||||
# imported, so !pomoc does not pull the ingest path in just to print a line.
|
||||
ALBUM_HEADING: Final = "Album"
|
||||
ALBUM_NAMES: Final = (f"{NAME_PREFIX}add", f"{NAME_PREFIX}random")
|
||||
|
||||
|
||||
def _line(command: Command) -> str:
|
||||
"""One command as !pomoc prints it."""
|
||||
@ -80,7 +87,7 @@ def _pack_block(commands: Sequence[Command]) -> str:
|
||||
entries are private data: this file cannot know how many there are or
|
||||
what they are called, and must not need to.
|
||||
"""
|
||||
entries = [c for c in commands if c.name.startswith(NAME_PREFIX)]
|
||||
entries = [c for c in commands if c.name.startswith(NAME_PREFIX) and c.name not in ALBUM_NAMES]
|
||||
if not entries:
|
||||
return ""
|
||||
lines = [PACK_HEADING, *(_line(command) for command in entries)]
|
||||
@ -89,6 +96,14 @@ def _pack_block(commands: Sequence[Command]) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _album_block(commands: Sequence[Command]) -> str:
|
||||
"""The !add_photo / !random block, or "" where there is no album."""
|
||||
entries = [c for c in commands if c.name in ALBUM_NAMES]
|
||||
if not entries:
|
||||
return ""
|
||||
return "\n".join([ALBUM_HEADING, *(_line(command) for command in entries)])
|
||||
|
||||
|
||||
def render_help(commands: Sequence[Command], group_id: str) -> str:
|
||||
"""Render the command list for one group, grouped, in registration order.
|
||||
|
||||
@ -105,9 +120,7 @@ def render_help(commands: Sequence[Command], group_id: str) -> str:
|
||||
blocks = [HEADING]
|
||||
for title, names in GROUPS:
|
||||
blocks.append("\n".join([title, *(_line(by_name[name]) for name in names)]))
|
||||
pack = _pack_block(visible)
|
||||
if pack:
|
||||
blocks.append(pack)
|
||||
blocks.extend(block for block in (_pack_block(visible), _album_block(visible)) if block)
|
||||
blocks.append(
|
||||
"\n".join(
|
||||
_line(c)
|
||||
|
||||
164
signal_bot/media_sniff.py
Normal file
164
signal_bot/media_sniff.py
Normal file
@ -0,0 +1,164 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""What an uploaded file actually is, decided from its bytes.
|
||||
|
||||
The filename a phone sends is not evidence: it is attacker-chosen, it is
|
||||
discarded on the way in (the stored name is a content hash), and ".jpg" on a
|
||||
file that is not a JPEG would be stored, served, and then refused by
|
||||
``pack_load`` on the *next* start -- taking the bot down for a photo that
|
||||
looked fine when it arrived. So the suffix is derived here instead, from the
|
||||
magic bytes, and anything not on the allowlist never reaches the album.
|
||||
|
||||
The ISO base-media formats (mp4, mov, m4v, heic, heif, avif) share one
|
||||
container and are told apart by the brand in the ``ftyp`` box rather than by
|
||||
extension, which is the only thing that distinguishes an iPhone photo from an
|
||||
iPhone video at the byte level.
|
||||
|
||||
SVG is the one member with no magic number at all -- it is XML -- so it is
|
||||
matched on a leading prefix instead, and it is the one format where that is
|
||||
the whole test.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
# How much of a file the sniffer ever looks at. The longest test is the
|
||||
# ISO-BMFF brand, which ends at byte 12; the SVG prefix scan wants a little
|
||||
# room for leading whitespace and a byte-order mark.
|
||||
HEADER_BYTES: Final = 64
|
||||
|
||||
# A byte-order mark, which a phone's XML writer may emit ahead of "<?xml".
|
||||
_BOM: Final = b"\xef\xbb\xbf"
|
||||
|
||||
_SVG_PREFIXES: Final = (b"<?xml", b"<svg")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MediaKind:
|
||||
"""One accepted format: how it is stored and how it is served."""
|
||||
|
||||
# The suffix the stored file gets, always lowercase and always with a dot.
|
||||
suffix: str
|
||||
# What Signal is told the file is when it is posted back.
|
||||
media_type: str
|
||||
# Whether exiftool 13.55 can write this container at all. False means the
|
||||
# file is stored exactly as it arrived -- see media_strip.strip_bytes and
|
||||
# the warning in DOCS-photo-pack.md.
|
||||
strippable: bool = True
|
||||
|
||||
|
||||
JPEG: Final = MediaKind(".jpg", "image/jpeg")
|
||||
PNG: Final = MediaKind(".png", "image/png")
|
||||
GIF: Final = MediaKind(".gif", "image/gif")
|
||||
WEBP: Final = MediaKind(".webp", "image/webp")
|
||||
MP4: Final = MediaKind(".mp4", "video/mp4")
|
||||
MOV: Final = MediaKind(".mov", "video/quicktime")
|
||||
M4V: Final = MediaKind(".m4v", "video/x-m4v")
|
||||
HEIC: Final = MediaKind(".heic", "image/heic")
|
||||
HEIF: Final = MediaKind(".heif", "image/heif")
|
||||
AVIF: Final = MediaKind(".avif", "image/avif")
|
||||
# The two exiftool 13.55 cannot write. Kept on the allowlist by kuhy's
|
||||
# decision of 2026-08-31; they are stored with their metadata intact.
|
||||
WEBM: Final = MediaKind(".webm", "video/webm", strippable=False)
|
||||
SVG: Final = MediaKind(".svg", "image/svg+xml", strippable=False)
|
||||
|
||||
# Every kind the album accepts, which is also every kind pack.MEDIA_TYPES has
|
||||
# to know about: a file stored here and not there is refused by the *next*
|
||||
# load, long after the upload that caused it.
|
||||
KINDS: Final[tuple[MediaKind, ...]] = (
|
||||
JPEG,
|
||||
PNG,
|
||||
GIF,
|
||||
WEBP,
|
||||
MP4,
|
||||
MOV,
|
||||
M4V,
|
||||
HEIC,
|
||||
HEIF,
|
||||
AVIF,
|
||||
WEBM,
|
||||
SVG,
|
||||
)
|
||||
|
||||
# Leading-byte signatures, longest-first so no prefix shadows another.
|
||||
_SIGNATURES: Final[tuple[tuple[bytes, MediaKind], ...]] = (
|
||||
(b"\x89PNG\r\n\x1a\n", PNG),
|
||||
(b"\xff\xd8\xff", JPEG),
|
||||
(b"GIF87a", GIF),
|
||||
(b"GIF89a", GIF),
|
||||
(b"\x1a\x45\xdf\xa3", WEBM),
|
||||
)
|
||||
|
||||
# The ``ftyp`` brand at bytes 8..12, lowercased. Everything ISO-BMFF that is
|
||||
# not listed is treated as plain mp4, which is what the brand table's own
|
||||
# "iso*"/"mp4*" families all are.
|
||||
_BRANDS: Final[dict[bytes, MediaKind]] = {
|
||||
b"heic": HEIC,
|
||||
b"heix": HEIC,
|
||||
b"hevc": HEIC,
|
||||
b"hevx": HEIC,
|
||||
b"heim": HEIC,
|
||||
b"heis": HEIC,
|
||||
b"mif1": HEIF,
|
||||
b"msf1": HEIF,
|
||||
b"heif": HEIF,
|
||||
b"avif": AVIF,
|
||||
b"avis": AVIF,
|
||||
b"qt ": MOV,
|
||||
b"m4v ": M4V,
|
||||
}
|
||||
|
||||
|
||||
def sniff(payload: bytes) -> MediaKind | None:
|
||||
"""Identify one upload, or None when it is not a format we accept.
|
||||
|
||||
Args:
|
||||
payload: The file exactly as it arrived.
|
||||
|
||||
Returns:
|
||||
The matching kind, or None for anything off the allowlist.
|
||||
"""
|
||||
header = payload[:HEADER_BYTES]
|
||||
for signature, kind in _SIGNATURES:
|
||||
if header.startswith(signature):
|
||||
return kind
|
||||
if _is_riff_webp(header):
|
||||
return WEBP
|
||||
brand = _iso_brand(header)
|
||||
if brand is not None:
|
||||
return _BRANDS.get(brand, MP4)
|
||||
return SVG if _is_svg(header) else None
|
||||
|
||||
|
||||
def _is_riff_webp(header: bytes) -> bool:
|
||||
"""True for a RIFF container whose form type is WEBP.
|
||||
|
||||
Both halves are checked: RIFF also carries wav and avi, and a bare
|
||||
"RIFF" test would file either of them as a picture.
|
||||
"""
|
||||
return header.startswith(b"RIFF") and header[8:12] == b"WEBP"
|
||||
|
||||
|
||||
def _iso_brand(header: bytes) -> bytes | None:
|
||||
"""The lowercased ISO base-media brand, or None if this is not one.
|
||||
|
||||
The box length at bytes 0..4 is deliberately not validated. It varies
|
||||
between writers and is not what identifies the format; "ftyp" at byte 4
|
||||
is, and every file in this family has it.
|
||||
"""
|
||||
if header[4:8] != b"ftyp":
|
||||
return None
|
||||
return header[8:12].lower()
|
||||
|
||||
|
||||
def _is_svg(header: bytes) -> bool:
|
||||
"""True for something that opens like an SVG document.
|
||||
|
||||
A prefix test rather than a parse: this decides *which* allowlist entry a
|
||||
file belongs to, not whether it is well-formed, and a document that opens
|
||||
with "<?xml" and turns out to be some other XML is stored as an .svg that
|
||||
Signal simply will not render. That is a bad photo, not a leak.
|
||||
"""
|
||||
text = header.removeprefix(_BOM).lstrip()
|
||||
return text.startswith(_SVG_PREFIXES)
|
||||
136
signal_bot/media_strip.py
Normal file
136
signal_bot/media_strip.py
Normal file
@ -0,0 +1,136 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Removing metadata from an upload, and *proving* it is gone.
|
||||
|
||||
The gate is a positive check on the output, never an exit code, because an
|
||||
exit code is exactly what lied the first time. ``ffmpeg -map_metadata -1 -c
|
||||
copy`` was tested against a JPEG carrying a real APP1 Exif segment and
|
||||
produced a byte-identical file -- 292 bytes in, 292 bytes out, GPS marker
|
||||
still there -- and exited 0. ``-c copy`` hands the compressed frame through
|
||||
as an opaque packet, and a JPEG's Exif lives inside it. Do not reintroduce
|
||||
ffmpeg here.
|
||||
|
||||
So: strip with exiftool, then read the result back and require every
|
||||
surviving tag to be structural (media_tags). A metadata atom nobody
|
||||
anticipated shows up as a tag that is not on the allowlist, and the upload is
|
||||
refused rather than stored.
|
||||
|
||||
Temp files go in /tmp, which the unit makes per-service with PrivateTmp=true.
|
||||
Staging beside ``media/`` would let a crash mid-strip leave an unstripped
|
||||
original inside the album permanently, where nothing in pack_load would ever
|
||||
flag it.
|
||||
|
||||
The ``Stripper`` protocol is the seam: unit tests inject a fake so they never
|
||||
shell out, and ``scripts/e2e.sh`` runs the real one against generated media.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Final, Protocol
|
||||
|
||||
from signal_bot.external import run
|
||||
from signal_bot.media_tags import is_allowed
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from signal_bot.media_sniff import MediaKind
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EXIFTOOL: Final = "exiftool"
|
||||
|
||||
# How long either exiftool call may take. A phone video is a few tens of
|
||||
# megabytes and exiftool rewrites the container, not the frames.
|
||||
TIMEOUT_SECONDS: Final = 60
|
||||
|
||||
# "[Group] TagName : value", which is what -G0 -s prints.
|
||||
_TAG_LINE: Final = re.compile(r"^\[(\w+)\]\s+(\S+)\s*:\s?(.*)$")
|
||||
|
||||
|
||||
class Stripper(Protocol):
|
||||
"""Removes metadata from one upload, or refuses it."""
|
||||
|
||||
def strip(self, payload: bytes, kind: MediaKind) -> bytes | None:
|
||||
"""Return the cleaned file, or None when it cannot be proven clean."""
|
||||
|
||||
|
||||
class ExiftoolStripper:
|
||||
"""The real stripper: exiftool 13.55, plus a positive post-check."""
|
||||
|
||||
def __init__(self, executable: str | None = None) -> None:
|
||||
"""Locate exiftool once, by absolute path.
|
||||
|
||||
Args:
|
||||
executable: Override for the binary, used by the e2e script.
|
||||
"""
|
||||
self._exiftool = executable or shutil.which(EXIFTOOL)
|
||||
|
||||
def strip(self, payload: bytes, kind: MediaKind) -> bytes | None:
|
||||
"""Strip one upload and verify the result, or refuse it.
|
||||
|
||||
Args:
|
||||
payload: The file exactly as it arrived.
|
||||
kind: What the sniffer decided it is.
|
||||
|
||||
Returns:
|
||||
The cleaned bytes, or None when the strip failed or the output
|
||||
still carries a tag that is not structural.
|
||||
"""
|
||||
if not kind.strippable:
|
||||
# kuhy's decision of 2026-08-31: .webm and .svg stay on the
|
||||
# allowlist even though exiftool 13.55 can write neither, so they
|
||||
# are stored exactly as they arrived, metadata included. Flagged
|
||||
# at the time and recorded in DOCS-photo-pack.md.
|
||||
logger.warning("storing %s unstripped: exiftool cannot write it", kind.suffix)
|
||||
return payload
|
||||
if self._exiftool is None:
|
||||
logger.error("exiftool is not installed; refusing to store %s", kind.suffix)
|
||||
return None
|
||||
with tempfile.TemporaryDirectory(prefix="signal-bot-strip-") as workspace:
|
||||
return self._run(Path(workspace), payload, kind)
|
||||
|
||||
def _run(self, workspace: Path, payload: bytes, kind: MediaKind) -> bytes | None:
|
||||
"""Do the strip inside a temp directory that is removed either way."""
|
||||
source = workspace / f"in{kind.suffix}"
|
||||
destination = workspace / f"out{kind.suffix}"
|
||||
source.write_bytes(payload)
|
||||
# Never --overwrite_original: writing in place would leave a partial
|
||||
# file where the original was if exiftool died halfway.
|
||||
if not self._exiftool_ok(["-all=", "-o", str(destination), str(source)]):
|
||||
return None
|
||||
if not destination.is_file():
|
||||
logger.warning("exiftool produced no output for %s", kind.suffix)
|
||||
return None
|
||||
report = self._exiftool_output(["-G0", "-s", "-all", str(destination)])
|
||||
if report is None or not self._verify(report, kind):
|
||||
return None
|
||||
return destination.read_bytes()
|
||||
|
||||
def _verify(self, report: str, kind: MediaKind) -> bool:
|
||||
"""True when every tag the stripped file still reports is structural."""
|
||||
for line in report.splitlines():
|
||||
match = _TAG_LINE.match(line)
|
||||
if match is None:
|
||||
continue
|
||||
group, tag, value = match.group(1), match.group(2), match.group(3)
|
||||
if not is_allowed(group, tag, value):
|
||||
# The tag name, never its value: the value is the metadata.
|
||||
logger.warning("refusing %s: %s:%s survived the strip", kind.suffix, group, tag)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _exiftool_ok(self, arguments: list[str]) -> bool:
|
||||
"""Run exiftool for its effect, reporting whether it succeeded."""
|
||||
return self._exiftool_output(arguments) is not None
|
||||
|
||||
def _exiftool_output(self, arguments: list[str]) -> str | None:
|
||||
"""Run exiftool and return its stdout, or None if it failed at all.
|
||||
|
||||
Every subprocess in this repository goes through signal_bot.external;
|
||||
see that module for why there is exactly one.
|
||||
"""
|
||||
result = run([str(self._exiftool), *arguments], timeout=TIMEOUT_SECONDS)
|
||||
return result.stdout if result.ok else None
|
||||
230
signal_bot/media_tags.py
Normal file
230
signal_bot/media_tags.py
Normal file
@ -0,0 +1,230 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""The tags a stripped file is allowed to still report, by container.
|
||||
|
||||
An **allowlist**, and the reason is the one the plan gives for preferring one
|
||||
over a blocklist: enumerating the groups to look *for* is how the next one
|
||||
gets missed. An MP4 geotag does not live under ``GPS`` -- it is a QuickTime
|
||||
``udta``/``©xyz`` atom reported under ``QuickTime`` -- so a gate written as
|
||||
"-Exif:all -GPS:all -XMP:all" passes it straight through.
|
||||
|
||||
The plan's first draft demanded the surviving set be *empty* apart from the
|
||||
computed pseudo-groups. Measured against exiftool 13.55 that rejects every
|
||||
format except JPEG: a valid PNG always reports its IHDR under ``PNG``, a GIF
|
||||
its screen descriptor under ``GIF``, a WebP its VP8 header under ``RIFF``, and
|
||||
an MP4 some fifty ``QuickTime`` tags that are the container itself. So the
|
||||
allowlist moved one level finer, from group to **tag name**, and kept the
|
||||
property that mattered: a metadata atom nobody anticipated arrives as a name
|
||||
that is not in this file, and the upload is refused.
|
||||
|
||||
Adding a name here is therefore a deliberate act. It is the only place where
|
||||
"this cannot carry personal data" is asserted rather than proven, so a new
|
||||
entry wants the same scrutiny as the album itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
# Groups exiftool computes rather than reads: they describe the file on disk
|
||||
# (name, size, permissions), the reader's own version, or values derived from
|
||||
# other tags. None of them survive in the bytes.
|
||||
COMPUTED_GROUPS: Final = frozenset({"File", "Composite", "ExifTool"})
|
||||
|
||||
# Date tags that ISO base-media files always carry structurally. `-all=` does
|
||||
# not delete them, it zeroes them, so they are allowed *by value* rather than
|
||||
# by name -- a surviving real timestamp is a rejection.
|
||||
ZEROED_DATE_TAGS: Final = frozenset(
|
||||
{
|
||||
"CreateDate",
|
||||
"ModifyDate",
|
||||
"TrackCreateDate",
|
||||
"TrackModifyDate",
|
||||
"MediaCreateDate",
|
||||
"MediaModifyDate",
|
||||
}
|
||||
)
|
||||
|
||||
# What exiftool prints for a date field that has been zeroed out.
|
||||
ZERO_DATE: Final = "0000:00:00 00:00:00"
|
||||
|
||||
_PNG: Final = frozenset(
|
||||
{
|
||||
"ImageWidth",
|
||||
"ImageHeight",
|
||||
"BitDepth",
|
||||
"ColorType",
|
||||
"Compression",
|
||||
"Filter",
|
||||
"Interlace",
|
||||
"SRGBRendering",
|
||||
"Gamma",
|
||||
"PixelsPerUnitX",
|
||||
"PixelsPerUnitY",
|
||||
"PixelUnits",
|
||||
}
|
||||
)
|
||||
|
||||
_GIF: Final = frozenset(
|
||||
{
|
||||
"GIFVersion",
|
||||
"ImageWidth",
|
||||
"ImageHeight",
|
||||
"HasColorMap",
|
||||
"ColorResolutionDepth",
|
||||
"BitsPerPixel",
|
||||
"BackgroundColor",
|
||||
"PixelAspectRatio",
|
||||
"AnimationIterations",
|
||||
"Duration",
|
||||
"FrameCount",
|
||||
"TransparentColor",
|
||||
}
|
||||
)
|
||||
|
||||
_RIFF: Final = frozenset(
|
||||
{
|
||||
"VP8Version",
|
||||
"ImageWidth",
|
||||
"ImageHeight",
|
||||
"HorizontalScale",
|
||||
"VerticalScale",
|
||||
"AlphaIsUsed",
|
||||
"AnimationIterations",
|
||||
"BackgroundColor",
|
||||
"Duration",
|
||||
"VP8Bitstream",
|
||||
}
|
||||
)
|
||||
|
||||
# The ISO base-media family: mp4, mov, m4v, heic, heif and avif all report
|
||||
# here.
|
||||
#
|
||||
# The second block is the codec's own configuration -- the HEVC parameter set
|
||||
# an iPhone photo carries and the encoder name inside an mp4's avc1 sample
|
||||
# description. They are here because measurement forced the question: with
|
||||
# them off the list, exiftool 13.55 refused *every* HEIC (16 surviving HEVC
|
||||
# config tags) and every mp4/mov/m4v, because CompressorName lives inside the
|
||||
# sample description and exiftool reports it as not writable at all --
|
||||
# "Sorry, QuickTime:CompressorName doesn't exist or isn't writable". So the
|
||||
# choice was codec parameters or no video and no iPhone photos, and kuhy took
|
||||
# the parameters on 2026-08-31. They describe how the picture is encoded, not
|
||||
# where it was taken or who took it; the geotag removal is unaffected, and
|
||||
# scripts/e2e.sh still proves a QuickTime GPS atom does not survive.
|
||||
_QUICKTIME: Final = frozenset(
|
||||
{
|
||||
"MajorBrand",
|
||||
"MinorVersion",
|
||||
"CompatibleBrands",
|
||||
"MovieHeaderVersion",
|
||||
"TimeScale",
|
||||
"Duration",
|
||||
"PreferredRate",
|
||||
"PreferredVolume",
|
||||
"PreviewTime",
|
||||
"PreviewDuration",
|
||||
"PosterTime",
|
||||
"SelectionTime",
|
||||
"SelectionDuration",
|
||||
"CurrentTime",
|
||||
"NextTrackID",
|
||||
"TrackHeaderVersion",
|
||||
"TrackID",
|
||||
"TrackDuration",
|
||||
"TrackLayer",
|
||||
"TrackVolume",
|
||||
"MatrixStructure",
|
||||
"ImageWidth",
|
||||
"ImageHeight",
|
||||
"MediaHeaderVersion",
|
||||
"MediaTimeScale",
|
||||
"MediaDuration",
|
||||
"MediaLanguageCode",
|
||||
"HandlerClass",
|
||||
"HandlerType",
|
||||
"HandlerDescription",
|
||||
"GraphicsMode",
|
||||
"OpColor",
|
||||
"CompressorID",
|
||||
"SourceImageWidth",
|
||||
"SourceImageHeight",
|
||||
"XResolution",
|
||||
"YResolution",
|
||||
"BitDepth",
|
||||
"PixelAspectRatio",
|
||||
"BufferSize",
|
||||
"MaxBitrate",
|
||||
"AverageBitrate",
|
||||
"VideoFrameRate",
|
||||
"MediaDataSize",
|
||||
"MediaDataOffset",
|
||||
"PrimaryItemReference",
|
||||
# Codec configuration. See the note above the set.
|
||||
"VendorID",
|
||||
"CompressorName",
|
||||
"HEVCConfigurationVersion",
|
||||
"GeneralProfileSpace",
|
||||
"GeneralTierFlag",
|
||||
"GeneralProfileIDC",
|
||||
"GenProfileCompatibilityFlags",
|
||||
"ConstraintIndicatorFlags",
|
||||
"GeneralLevelIDC",
|
||||
"MinSpatialSegmentationIDC",
|
||||
"ParallelismType",
|
||||
"BitDepthLuma",
|
||||
"BitDepthChroma",
|
||||
"AverageFrameRate",
|
||||
"ConstantFrameRate",
|
||||
"NumTemporalLayers",
|
||||
"TemporalIDNested",
|
||||
"CleanAperture",
|
||||
"ImageSpatialExtent",
|
||||
"ImagePixelDepth",
|
||||
"AV1ConfigurationVersion",
|
||||
"ChromaFormat",
|
||||
"ChromaSamplePosition",
|
||||
"ColorProfiles",
|
||||
"ColorPrimaries",
|
||||
"TransferCharacteristics",
|
||||
"MatrixCoefficients",
|
||||
"VideoFullRangeFlag",
|
||||
"Balance",
|
||||
"AudioFormat",
|
||||
"AudioChannels",
|
||||
"AudioBitsPerSample",
|
||||
"AudioSampleRate",
|
||||
}
|
||||
)
|
||||
|
||||
# Group name to the tag names that group may still report after a strip.
|
||||
STRUCTURAL: Final[dict[str, frozenset[str]]] = {
|
||||
"PNG": _PNG,
|
||||
"GIF": _GIF,
|
||||
"RIFF": _RIFF,
|
||||
"QuickTime": _QUICKTIME,
|
||||
}
|
||||
|
||||
|
||||
def is_allowed(group: str, tag: str, value: str) -> bool:
|
||||
"""Whether one surviving tag is structural rather than metadata.
|
||||
|
||||
Args:
|
||||
group: The family-0 group exiftool filed the tag under.
|
||||
tag: The tag name.
|
||||
value: What exiftool printed for it, used only for the date check.
|
||||
|
||||
Returns:
|
||||
True when the tag may remain in a file the album stores.
|
||||
"""
|
||||
if group in COMPUTED_GROUPS:
|
||||
return True
|
||||
known = STRUCTURAL.get(group)
|
||||
if known is None:
|
||||
# A group this file has never listed. Refused rather than inspected:
|
||||
# not knowing what a group can carry is exactly the case the
|
||||
# allowlist exists for.
|
||||
return False
|
||||
if tag in ZEROED_DATE_TAGS:
|
||||
# By value: `-all=` zeroes these rather than removing them, so a
|
||||
# real timestamp here is metadata that survived the strip.
|
||||
return value.strip() == ZERO_DATE
|
||||
return tag in known
|
||||
@ -37,6 +37,12 @@ class IncomingMessage:
|
||||
# contentType of each attachment, in the order Signal listed them.
|
||||
# Empty for a text-only message.
|
||||
attachment_types: tuple[str, ...] = ()
|
||||
# The server-side id of each attachment. A parallel tuple
|
||||
# rather than a richer attachment_types, so counting.media_kind,
|
||||
# counting.should_count and stats.recorder keep reading exactly what they
|
||||
# always read. !add_photo is the only thing that needs the id: it is what
|
||||
# GET /v1/attachments/{id} fetches and what DELETE removes afterwards.
|
||||
attachment_ids: tuple[str, ...] = ()
|
||||
# True when this message quotes another one.
|
||||
is_reply: bool = False
|
||||
# True when the bot's own account sent this. Taken from *which* envelope
|
||||
@ -88,12 +94,33 @@ def _payload(envelope: dict[str, Any]) -> tuple[dict[str, Any], bool] | None:
|
||||
return None
|
||||
|
||||
|
||||
def _attachments(payload: dict[str, Any]) -> tuple[str, ...]:
|
||||
"""Return each attachment's contentType, in the order Signal listed them."""
|
||||
def _attachment_items(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""The attachment objects on this payload, read off the top level only.
|
||||
|
||||
Never out of ``quote``: a reply carries the *quoted* message's media in
|
||||
``quote.attachments``, so a search for "attachments" anywhere in the
|
||||
payload would let "!add_photo" steal a photo somebody else posted.
|
||||
"""
|
||||
items = payload.get("attachments")
|
||||
if not isinstance(items, list):
|
||||
return ()
|
||||
return tuple(str(item.get("contentType", "")) for item in items if isinstance(item, dict))
|
||||
return []
|
||||
return [item for item in items if isinstance(item, dict)]
|
||||
|
||||
|
||||
def _attachments(payload: dict[str, Any]) -> tuple[str, ...]:
|
||||
"""Return each attachment's contentType, in the order Signal listed them."""
|
||||
return tuple(str(item.get("contentType", "")) for item in _attachment_items(payload))
|
||||
|
||||
|
||||
def _attachment_ids(payload: dict[str, Any]) -> tuple[str, ...]:
|
||||
"""Return the server-side id of every attachment that has one.
|
||||
|
||||
An attachment with no id is dropped rather than carried as "": the id is
|
||||
only ever used to fetch bytes, and a blank one is a request that cannot
|
||||
succeed. That means this tuple can be shorter than attachment_types, so
|
||||
the two are not safe to zip -- nothing does, and nothing should.
|
||||
"""
|
||||
return tuple(str(item["id"]) for item in _attachment_items(payload) if str(item.get("id", "")))
|
||||
|
||||
|
||||
def _group_id(payload: dict[str, Any]) -> str | None:
|
||||
@ -164,6 +191,7 @@ def parse_envelope(raw: str | bytes) -> IncomingMessage | None:
|
||||
is_group_update=_group_type(payload) == "UPDATE",
|
||||
timestamp=_timestamp(payload, envelope),
|
||||
attachment_types=_attachments(payload),
|
||||
attachment_ids=_attachment_ids(payload),
|
||||
is_reply=isinstance(payload.get("quote"), dict),
|
||||
is_own=is_own,
|
||||
)
|
||||
|
||||
@ -23,6 +23,7 @@ config.py: this half is the shape, that half is how it is read.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import itertools
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
@ -30,6 +31,7 @@ from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from signal_bot.folding import fold
|
||||
from signal_bot.pack_custom import CustomTags
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping, Sequence
|
||||
@ -47,6 +49,17 @@ MEDIA_TYPES: Final[dict[str, str]] = {
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".mp4": "video/mp4",
|
||||
# Everything !add_photo can ingest has to be here too, or a file that
|
||||
# uploaded cleanly is refused by the *next* load and takes startup down
|
||||
# with it. Kept in step with media_sniff.KINDS, which the tests assert.
|
||||
".webp": "image/webp",
|
||||
".mov": "video/quicktime",
|
||||
".m4v": "video/x-m4v",
|
||||
".heic": "image/heic",
|
||||
".heif": "image/heif",
|
||||
".avif": "image/avif",
|
||||
".webm": "video/webm",
|
||||
".svg": "image/svg+xml",
|
||||
}
|
||||
|
||||
# How many names one message may combine, the head included. A ceiling
|
||||
@ -83,6 +96,10 @@ class Pack:
|
||||
# Folded trigger to entry, so a tail token resolves exactly the way the
|
||||
# command in front of it did.
|
||||
by_trigger: Mapping[str, Entry]
|
||||
# Labels !add_photo minted that match no person. These define no
|
||||
# commands -- !random is the only way to one -- which is what keeps a
|
||||
# hot-swap a data swap rather than a re-registration.
|
||||
custom: CustomTags = dataclasses.field(default_factory=CustomTags)
|
||||
|
||||
def media_path(self, filename: str) -> Path:
|
||||
"""Where one tagged file lives."""
|
||||
|
||||
174
signal_bot/pack_add.py
Normal file
174
signal_bot/pack_add.py
Normal file
@ -0,0 +1,174 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""``!add_photo``: put an attachment into the album, tagged, from the chat.
|
||||
|
||||
Send a photo captioned ``!add_photo alfa beta`` and it lands in the album
|
||||
tagged with both, retrievable through each of their existing commands
|
||||
immediately -- no restart, and no tagger session on the PC.
|
||||
|
||||
Three properties hold and none of them is incidental:
|
||||
|
||||
* **No name travels.** Labels become keys in the resolver, at the dispatcher,
|
||||
before the line that logs the command name. The reply carries counts and
|
||||
pseudonyms; ``dodano · p1: 4`` says a photo landed and nothing about whom.
|
||||
* **Nothing unproven is stored.** Sniffed by magic bytes, stripped, and then
|
||||
*verified* by reading the output back -- an exit code is what lied last time.
|
||||
The two formats exiftool cannot write are the documented exception.
|
||||
* **A failed add changes nothing.** Media and manifests are written, the album
|
||||
is reloaded, and only a load that succeeds is published. A load that fails
|
||||
rolls the files back and keeps serving the pack that was already working.
|
||||
|
||||
The attachment is deleted from signal-cli's own store either way. That copy
|
||||
lives outside the album's 0700 and is pre-existing for every photo the group
|
||||
sends, but this is the first path that ingests one deliberately.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from signal_bot.pack import MAX_NAMES, options_for
|
||||
from signal_bot.pack_command import NAME_PREFIX, UNKNOWN
|
||||
from signal_bot.pack_ingest import commit, ingest
|
||||
from signal_bot.pack_replies import (
|
||||
AMBIGUOUS,
|
||||
DUPLICATE_ONLY,
|
||||
NEED_LABEL,
|
||||
NEED_PHOTO,
|
||||
REJECTED,
|
||||
TOO_MANY,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from signal_bot.media_strip import Stripper
|
||||
from signal_bot.pack import Pack
|
||||
from signal_bot.pack_custom import CustomTags
|
||||
from signal_bot.pack_holder import PackHolder
|
||||
from signal_bot.registry import ArgResolver, CommandContext, CommandRegistry, Handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
COMMAND_NAME: Final = f"{NAME_PREFIX}add"
|
||||
DESCRIPTION: Final = "dodaje załączone zdjęcie do albumu z podanymi etykietami"
|
||||
|
||||
|
||||
class AddResolver:
|
||||
"""Turns typed labels into keys, minting one for a label nobody has.
|
||||
|
||||
The pending mint is held here and only here until the upload succeeds, so
|
||||
``!add_photo dog`` with no attachment leaves ``custom.json`` untouched --
|
||||
as does a rejected file, and as does a photo the album already has.
|
||||
|
||||
``pending`` is cleared at the top of every resolve. That is safe because
|
||||
``Bot.listen`` awaits one ``dispatcher.handle`` before reading the next
|
||||
frame, so exactly one message's mints are ever outstanding; the test
|
||||
``test_pack_add`` pins that by resolving twice in a row.
|
||||
"""
|
||||
|
||||
def __init__(self, holder: PackHolder, registry: CommandRegistry) -> None:
|
||||
"""Read labels through the holder, so a fresh tag resolves at once.
|
||||
|
||||
The registry is held so a mint can refuse a word that is already a
|
||||
command. Without that, "!add_photo kot" mints the label "kot", and
|
||||
scripts/personal_data_sources feeds custom labels to the
|
||||
personal-data gate -- which would then find "kot" in aliases.json and
|
||||
refuse every commit, for ever, until somebody hand-edited an album
|
||||
nobody can see. Anyone in the group could do that by accident.
|
||||
"""
|
||||
self._holder = holder
|
||||
self._registry = registry
|
||||
self.pending: dict[str, str] = {}
|
||||
|
||||
def __call__(self, words: tuple[str, ...]) -> tuple[str, ...]:
|
||||
"""Resolve every label to a key, recording any mint it implies."""
|
||||
self.pending = {}
|
||||
pack = self._holder.pack
|
||||
minted = pack.custom
|
||||
keys: list[str] = []
|
||||
for word in words:
|
||||
resolved, minted = self._resolve(pack, minted, word)
|
||||
keys.extend(resolved)
|
||||
return tuple(keys)
|
||||
|
||||
def _resolve(self, pack: Pack, minted: CustomTags, word: str) -> tuple[list[str], CustomTags]:
|
||||
"""One label: a person, a combination, a known tag, or a new one."""
|
||||
options = options_for(pack, word)
|
||||
if options is not None:
|
||||
# An "any_of" combination means two different answers and there
|
||||
# is nothing to pick between when tagging, so it is refused
|
||||
# rather than guessed at.
|
||||
return ([UNKNOWN] if len(options) > 1 else sorted(options[0])), minted
|
||||
existing = minted.resolve(word)
|
||||
if existing is not None:
|
||||
return [existing], minted
|
||||
if self._registry.resolve(f"!{word}") is not None:
|
||||
# Already a command word, so it is public, and minting it would
|
||||
# make the word mean two things at once.
|
||||
return [UNKNOWN], minted
|
||||
key = minted.next_key()
|
||||
self.pending[key] = word
|
||||
return [key], minted.with_label(key, word)
|
||||
|
||||
def committed(self, base: CustomTags) -> CustomTags:
|
||||
"""``base`` plus every pending mint, for writing to disk."""
|
||||
for key, label in self.pending.items():
|
||||
base = base.with_label(key, label)
|
||||
return base
|
||||
|
||||
|
||||
def add_handler(holder: PackHolder, resolver: AddResolver, stripper: Stripper) -> Handler:
|
||||
"""Build the handler that answers ``!add_photo``."""
|
||||
|
||||
async def handler(ctx: CommandContext) -> None:
|
||||
keys = tuple(dict.fromkeys(ctx.args))
|
||||
refusal = _refusal(keys, ctx.message.attachment_ids)
|
||||
if refusal is not None:
|
||||
await ctx.reply(refusal)
|
||||
return
|
||||
fresh, duplicates = await ingest(ctx, holder, stripper)
|
||||
if not fresh:
|
||||
await ctx.reply(DUPLICATE_ONLY if duplicates else REJECTED)
|
||||
return
|
||||
await commit(ctx, holder, resolver, keys, fresh, duplicates)
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
def _refusal(keys: tuple[str, ...], attachments: tuple[str, ...]) -> str | None:
|
||||
"""The line that ends this command before anything is read, or None."""
|
||||
if UNKNOWN in keys:
|
||||
return AMBIGUOUS
|
||||
if not keys:
|
||||
return NEED_LABEL
|
||||
if len(keys) > MAX_NAMES:
|
||||
return TOO_MANY
|
||||
return NEED_PHOTO if not attachments else None
|
||||
|
||||
|
||||
def register_add_command(
|
||||
registry: CommandRegistry,
|
||||
holder: PackHolder,
|
||||
stripper: Stripper,
|
||||
triggers: tuple[str, ...],
|
||||
) -> AddResolver:
|
||||
"""Register ``!add_photo``, scoped to the album's own groups.
|
||||
|
||||
No decorator: per kuhy's "NO CAP, NO LIMITS" there is no cooldown, no
|
||||
size cap and no daily limit on adding to the album.
|
||||
"""
|
||||
resolver = AddResolver(holder, registry)
|
||||
registry.register(
|
||||
COMMAND_NAME,
|
||||
triggers,
|
||||
add_handler(holder, resolver, stripper),
|
||||
DESCRIPTION,
|
||||
groups=holder.pack.groups,
|
||||
takes_args=True,
|
||||
arg_resolver=_as_resolver(resolver),
|
||||
)
|
||||
return resolver
|
||||
|
||||
|
||||
def _as_resolver(resolver: AddResolver) -> ArgResolver:
|
||||
"""Narrow the callable class to the registry's function type."""
|
||||
return resolver.__call__
|
||||
@ -26,7 +26,8 @@ if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from signal_bot.decorators import Decorator
|
||||
from signal_bot.pack import Entry, Pack
|
||||
from signal_bot.pack import Entry
|
||||
from signal_bot.pack_holder import PackHolder
|
||||
from signal_bot.registry import CommandContext, CommandRegistry, Handler
|
||||
|
||||
# The prefix every pack command's name carries, so one glance at a log line
|
||||
@ -35,6 +36,11 @@ NAME_PREFIX: Final = "pack:"
|
||||
|
||||
NO_PHOTO: Final = "BEEP BOOP NIE MAM TAKIEGO ZDJĘCIA."
|
||||
|
||||
# What an argument resolver yields for a word it cannot turn into a key. It
|
||||
# can never collide with a real one: pack_load requires person keys to be
|
||||
# ascii alphanumeric, and a custom key is "t" followed by digits.
|
||||
UNKNOWN: Final = "?"
|
||||
|
||||
# Deliberately says nothing about who the entry is: !pomoc prints this next
|
||||
# to the trigger, and the trigger is the only place a name may appear.
|
||||
ONE_PERSON: Final = "losowe zdjęcie tej osoby"
|
||||
@ -59,10 +65,16 @@ def pick(items: Sequence[str]) -> str:
|
||||
return items[secrets.randbelow(len(items))]
|
||||
|
||||
|
||||
def pack_handler(pack: Pack, entry: Entry) -> Handler:
|
||||
"""Build the handler that answers one entry's trigger."""
|
||||
def pack_handler(holder: PackHolder, entry: Entry) -> Handler:
|
||||
"""Build the handler that answers one entry's trigger.
|
||||
|
||||
Reads ``holder.pack`` at call time rather than closing over the album it
|
||||
was built with: a photo !add_photo stored a second ago has to be visible
|
||||
to this person's own command, and a restart is not an acceptable price.
|
||||
"""
|
||||
|
||||
async def handler(ctx: CommandContext) -> None:
|
||||
pack = holder.pack
|
||||
wanted = candidate_keysets(pack, entry, ctx.args)
|
||||
viable = [keys for keys in wanted or () if pack.matching(keys)]
|
||||
if not viable:
|
||||
@ -85,20 +97,26 @@ def pick_keys(viable: Sequence[frozenset[str]]) -> frozenset[str]:
|
||||
|
||||
def register_pack_commands(
|
||||
registry: CommandRegistry,
|
||||
pack: Pack,
|
||||
holder: PackHolder,
|
||||
*,
|
||||
decorate: Decorator | None = None,
|
||||
) -> None:
|
||||
"""Register one command per pack entry, scoped to the pack's groups.
|
||||
|
||||
The entries and the groups are read once, here, and never again: a swap
|
||||
only ever replaces Pack *data*. If it could add a command it could also
|
||||
add one to the wrong group, which is the failure this album exists to
|
||||
make impossible.
|
||||
|
||||
Args:
|
||||
registry: Registry to populate.
|
||||
pack: The loaded album.
|
||||
holder: The album, in the mutable box every handler reads through.
|
||||
decorate: Optional callable applied to each handler, used to attach
|
||||
the cooldown gate without hard-wiring policy into the commands.
|
||||
"""
|
||||
pack = holder.pack
|
||||
for entry in pack.entries:
|
||||
handler = pack_handler(pack, entry)
|
||||
handler = pack_handler(holder, entry)
|
||||
if decorate is not None:
|
||||
handler = decorate(handler)
|
||||
registry.register(
|
||||
|
||||
183
signal_bot/pack_custom.py
Normal file
183
signal_bot/pack_custom.py
Normal file
@ -0,0 +1,183 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Custom tags: labels the album learned from the chat rather than a manifest.
|
||||
|
||||
``!add_photo`` never writes a person. A label that matches nobody becomes a
|
||||
custom tag instead -- a pseudonym key (``t1``, ``t2``) plus the raw spellings
|
||||
that reach it -- and the only way to that key is ``!random <label>``. Keeping
|
||||
people and custom tags in different files is what makes that guarantee cheap:
|
||||
``people.json`` is edited by the tagger and defines commands, ``custom.json``
|
||||
is appended to from the chat and defines no commands at all.
|
||||
|
||||
Written atomically, temp-then-replace, exactly as ``tool/tagger.py::Album.write``
|
||||
already does, because the alternative is a half-written manifest that fails the
|
||||
next load and takes startup down.
|
||||
|
||||
This module also owns the validation ``pack_load`` would otherwise grow: that
|
||||
file was at 243 of the 250-line cap before this feature existed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
from signal_bot.folding import fold
|
||||
from signal_bot.groups import ConfigError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
|
||||
CUSTOM_FILE: Final = "custom.json"
|
||||
|
||||
# The pseudonym shape. "t" for tag, mirroring "p" for person, and a digit
|
||||
# suffix so a key can never be mistaken for something somebody typed.
|
||||
KEY_PATTERN: Final = re.compile(r"^t([1-9][0-9]*)$")
|
||||
|
||||
# Manifests are written 0600: the album is 0700 already, but a mode is
|
||||
# cheaper to keep right than to explain.
|
||||
FILE_MODE: Final = 0o600
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CustomTags:
|
||||
"""Every custom tag the album knows, in both directions."""
|
||||
|
||||
# Key to its raw spellings, canonical first. The spellings are the only
|
||||
# place a typed label is kept, and that file never leaves the album.
|
||||
by_key: Mapping[str, tuple[str, ...]] = field(default_factory=dict)
|
||||
# Folded label to key, which is how a typed word becomes a pseudonym.
|
||||
by_label: Mapping[str, str] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def keys(self) -> frozenset[str]:
|
||||
"""Every custom key, for validating tags.json against."""
|
||||
return frozenset(self.by_key)
|
||||
|
||||
def resolve(self, label: str) -> str | None:
|
||||
"""The key one typed label means, or None when nothing does."""
|
||||
return self.by_label.get(fold(label))
|
||||
|
||||
def next_key(self) -> str:
|
||||
"""The key a new label would mint, without minting it.
|
||||
|
||||
One past the highest number in use rather than the count, so
|
||||
deleting ``t2`` by hand can never make a later mint reuse it and
|
||||
silently inherit its photos.
|
||||
"""
|
||||
used = [int(match.group(1)) for key in self.by_key if (match := KEY_PATTERN.match(key))]
|
||||
return f"t{max(used, default=0) + 1}"
|
||||
|
||||
def with_label(self, key: str, label: str) -> CustomTags:
|
||||
"""This table plus one more label, as a new value."""
|
||||
by_key = dict(self.by_key)
|
||||
by_key[key] = (*by_key.get(key, ()), label)
|
||||
by_label = dict(self.by_label)
|
||||
by_label[fold(label)] = key
|
||||
return CustomTags(by_key=by_key, by_label=by_label)
|
||||
|
||||
|
||||
def load(directory: Path, person_triggers: frozenset[str]) -> CustomTags:
|
||||
"""Read custom.json, or return an empty table when there is none.
|
||||
|
||||
A missing file is the normal state of an album nobody has run
|
||||
``!add_photo`` on, so it is not an error. A malformed one is: it would
|
||||
otherwise drop every custom tag silently and make ``!random`` answer "no
|
||||
such photo" for labels that are right there on disk.
|
||||
|
||||
Args:
|
||||
directory: The pack directory.
|
||||
person_triggers: Folded triggers already claimed by people, so a
|
||||
label can never shadow a person's own command.
|
||||
|
||||
Raises:
|
||||
ConfigError: The file is unreadable, malformed, or ambiguous.
|
||||
"""
|
||||
path = directory / CUSTOM_FILE
|
||||
if not path.exists():
|
||||
return CustomTags()
|
||||
document = _document(path)
|
||||
by_key: dict[str, tuple[str, ...]] = {}
|
||||
by_label: dict[str, str] = {}
|
||||
for raw_key, raw_labels in document.items():
|
||||
key = _key(str(raw_key))
|
||||
labels = _labels(key, raw_labels)
|
||||
by_key[key] = labels
|
||||
for label in labels:
|
||||
_claim(by_label, fold(label), key, person_triggers)
|
||||
return CustomTags(by_key=by_key, by_label=by_label)
|
||||
|
||||
|
||||
def write(directory: Path, tags: CustomTags) -> None:
|
||||
"""Replace custom.json atomically, so a crash cannot halve it."""
|
||||
path = directory / CUSTOM_FILE
|
||||
temporary = path.with_suffix(".tmp")
|
||||
temporary.write_text(
|
||||
json.dumps({key: list(labels) for key, labels in tags.by_key.items()}, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary.chmod(FILE_MODE)
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def _document(path: Path) -> dict[str, Any]:
|
||||
"""One JSON object, read strictly."""
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError as error:
|
||||
msg = f"{CUSTOM_FILE} cannot be read: {error}"
|
||||
raise ConfigError(msg) from error
|
||||
try:
|
||||
document = json.loads(text)
|
||||
except json.JSONDecodeError as error:
|
||||
msg = f"{CUSTOM_FILE} is not valid JSON: {error}"
|
||||
raise ConfigError(msg) from error
|
||||
if not isinstance(document, dict):
|
||||
msg = f"{CUSTOM_FILE} must be an object"
|
||||
raise ConfigError(msg)
|
||||
return document
|
||||
|
||||
|
||||
def _key(key: str) -> str:
|
||||
"""One custom key, which has to look like a pseudonym and not a name."""
|
||||
if not KEY_PATTERN.match(key):
|
||||
msg = f"{CUSTOM_FILE} key {key!r} must be 't' followed by a number"
|
||||
raise ConfigError(msg)
|
||||
return key
|
||||
|
||||
|
||||
def _labels(key: str, raw: object) -> tuple[str, ...]:
|
||||
"""The spellings one custom key answers to."""
|
||||
if not isinstance(raw, list) or not raw:
|
||||
msg = f"{CUSTOM_FILE} entry {key!r} needs a non-empty list of labels"
|
||||
raise ConfigError(msg)
|
||||
labels = tuple(str(label) for label in raw)
|
||||
for label in labels:
|
||||
if not fold(label).strip("!"):
|
||||
msg = f"{CUSTOM_FILE} label {label!r} folds away to nothing"
|
||||
raise ConfigError(msg)
|
||||
return labels
|
||||
|
||||
|
||||
def _claim(
|
||||
by_label: dict[str, str],
|
||||
folded: str,
|
||||
key: str,
|
||||
person_triggers: frozenset[str],
|
||||
) -> None:
|
||||
"""Record one label, refusing a spelling that already means something.
|
||||
|
||||
Both collisions matter and neither is recoverable at run time: two custom
|
||||
keys sharing a label would make ``!random`` pick one arbitrarily, and a
|
||||
label that folds onto a person's trigger would quietly shadow the command
|
||||
that person's photos are reached by.
|
||||
"""
|
||||
if folded in person_triggers or f"!{folded}" in person_triggers:
|
||||
msg = f"{CUSTOM_FILE} label {folded!r} is already a person's trigger"
|
||||
raise ConfigError(msg)
|
||||
owner = by_label.setdefault(folded, key)
|
||||
if owner != key:
|
||||
msg = f"{CUSTOM_FILE} label {folded!r} is claimed by {owner!r} and {key!r}"
|
||||
raise ConfigError(msg)
|
||||
61
signal_bot/pack_holder.py
Normal file
61
signal_bot/pack_holder.py
Normal file
@ -0,0 +1,61 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""The one mutable box the pack lives in, so a new photo is visible at once.
|
||||
|
||||
``pack_command.pack_handler`` used to close over a frozen ``Pack``. That was
|
||||
right while the only writer was ``tool/tagger.py`` and a restart followed
|
||||
every change; with ``!add_photo`` it means a photo somebody just added stays
|
||||
invisible to their own command until the unit is restarted. Every pack
|
||||
handler therefore reads ``holder.pack`` at call time instead of capturing it.
|
||||
|
||||
What a swap may and may not change is the load-bearing part. Custom tags
|
||||
never become commands, so a swap only ever replaces *Pack data* -- it never
|
||||
re-registers a trigger and never recomputes ``Command.groups``. That is what
|
||||
makes immediate availability cheap, and it is the property to preserve: the
|
||||
moment a swap could add a command, it could also add one to the wrong group.
|
||||
|
||||
``reload`` never raises. A handler that raised here would take the exception
|
||||
to the dispatcher, which logs and moves on -- but the album would already be
|
||||
half-swapped, and ``Restart=always`` turns a startup-time ConfigError into a
|
||||
loop. Failing back onto the pack that was already working is the only
|
||||
behaviour that degrades safely.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from signal_bot.groups import ConfigError
|
||||
from signal_bot.pack_load import load
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from signal_bot.pack import Pack
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PackHolder:
|
||||
"""A mutable reference to the album every handler reads through."""
|
||||
|
||||
pack: Pack
|
||||
|
||||
def reload(self) -> bool:
|
||||
"""Re-read the album from disk and publish it if it is servable.
|
||||
|
||||
Returns:
|
||||
True when the new pack is live, False when the old one is still
|
||||
being served because the directory no longer loads.
|
||||
"""
|
||||
directory = self.pack.directory
|
||||
try:
|
||||
fresh = load(directory)
|
||||
except ConfigError:
|
||||
# The path that keeps the bot alive after a bad write: the files
|
||||
# are rolled back by the caller and the album in memory is the
|
||||
# one that was already answering.
|
||||
logger.exception("photo pack %s no longer loads; keeping the previous one", directory)
|
||||
return False
|
||||
self.pack = fresh
|
||||
return True
|
||||
134
signal_bot/pack_ingest.py
Normal file
134
signal_bot/pack_ingest.py
Normal file
@ -0,0 +1,134 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Turning the attachments on one message into files inside the album.
|
||||
|
||||
Split out of pack_add.py at the 250-line cap, along the seam that was already
|
||||
there: that half decides *whether* a command runs and what it answers, this
|
||||
half does the reading, the stripping and the writing.
|
||||
|
||||
The order here is the load-bearing part. Media and manifests are written
|
||||
first, the album is reloaded second, and only a reload that succeeds is
|
||||
published. A reload that fails rolls all four files back and leaves the bot
|
||||
serving the pack it was already serving -- and never raises, because
|
||||
``Restart=always`` turns a startup-time ConfigError into a restart loop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from signal_bot.media_sniff import sniff
|
||||
from signal_bot.pack_custom import write as write_custom
|
||||
from signal_bot.pack_replies import ADDED, ALREADY_THERE, FAILED, NEW_TAG_HINT
|
||||
from signal_bot.pack_store import (
|
||||
content_name,
|
||||
record_adds,
|
||||
restore,
|
||||
snapshot,
|
||||
write_media,
|
||||
write_tags,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from signal_bot.media_strip import Stripper
|
||||
from signal_bot.pack_add import AddResolver
|
||||
from signal_bot.pack_holder import PackHolder
|
||||
from signal_bot.registry import CommandContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def ingest(
|
||||
ctx: CommandContext, holder: PackHolder, stripper: Stripper
|
||||
) -> tuple[dict[str, bytes], int]:
|
||||
"""Fetch, sniff, strip and name every attachment on the message.
|
||||
|
||||
Returns:
|
||||
The files that are new to the album, and how many were already in it.
|
||||
"""
|
||||
fresh: dict[str, bytes] = {}
|
||||
duplicates = 0
|
||||
known = set(holder.pack.tags)
|
||||
for attachment_id in ctx.message.attachment_ids:
|
||||
payload = await _fetch(ctx, attachment_id)
|
||||
cleaned = _clean(payload, stripper)
|
||||
if cleaned is not None:
|
||||
name, body = cleaned
|
||||
if name in known or name in fresh:
|
||||
duplicates += 1
|
||||
else:
|
||||
fresh[name] = body
|
||||
return fresh, duplicates
|
||||
|
||||
|
||||
async def _fetch(ctx: CommandContext, attachment_id: str) -> bytes | None:
|
||||
"""Read one attachment, then delete signal-cli's own copy of it.
|
||||
|
||||
Deleted whether the upload is accepted or refused: the point is that no
|
||||
copy survives outside the album, and a refused file is exactly the one
|
||||
nobody wants left lying in the container.
|
||||
"""
|
||||
try:
|
||||
payload = await ctx.transport.fetch_attachment(attachment_id)
|
||||
except Exception:
|
||||
logger.exception("could not fetch an attachment")
|
||||
payload = None
|
||||
try:
|
||||
await ctx.transport.delete_attachment(attachment_id)
|
||||
except Exception:
|
||||
logger.exception("could not delete the server-side attachment copy")
|
||||
return payload
|
||||
|
||||
|
||||
def _clean(payload: bytes | None, stripper: Stripper) -> tuple[str, bytes] | None:
|
||||
"""Sniff and strip one upload, or None when it is not storable."""
|
||||
if payload is None:
|
||||
return None
|
||||
kind = sniff(payload)
|
||||
if kind is None:
|
||||
logger.warning("refusing an upload whose format is not on the allowlist")
|
||||
return None
|
||||
cleaned = stripper.strip(payload, kind)
|
||||
if cleaned is None:
|
||||
return None
|
||||
return content_name(cleaned, kind.suffix), cleaned
|
||||
|
||||
|
||||
async def commit(
|
||||
ctx: CommandContext,
|
||||
holder: PackHolder,
|
||||
resolver: AddResolver,
|
||||
keys: tuple[str, ...],
|
||||
fresh: dict[str, bytes],
|
||||
duplicates: int,
|
||||
) -> None:
|
||||
"""Write the files and manifests, swap the album, or roll it all back."""
|
||||
pack = holder.pack
|
||||
directory = pack.directory
|
||||
previous = snapshot(directory)
|
||||
tags = {name: frozenset(existing) for name, existing in pack.tags.items()}
|
||||
for name, body in fresh.items():
|
||||
write_media(directory, name, body)
|
||||
tags[name] = frozenset(keys)
|
||||
write_tags(directory, tags)
|
||||
write_custom(directory, resolver.committed(pack.custom))
|
||||
record_adds(
|
||||
directory,
|
||||
{name: {"by": ctx.message.source_uuid, "at": int(ctx.now)} for name in fresh},
|
||||
)
|
||||
if not holder.reload():
|
||||
restore(directory, previous, tuple(fresh))
|
||||
await ctx.reply(FAILED)
|
||||
return
|
||||
await ctx.reply(_receipt(holder, keys, duplicates, minted=bool(resolver.pending)))
|
||||
|
||||
|
||||
def _receipt(holder: PackHolder, keys: tuple[str, ...], duplicates: int, *, minted: bool) -> str:
|
||||
"""Counts and pseudonyms only -- never a label, never a filename."""
|
||||
pack = holder.pack
|
||||
parts = [ADDED]
|
||||
parts.extend(f"{key}: {len(pack.matching(frozenset({key})))}" for key in keys)
|
||||
if duplicates:
|
||||
parts.append(ALREADY_THERE.format(count=duplicates))
|
||||
line = " · ".join(parts)
|
||||
return f"{line}\n{NEW_TAG_HINT}" if minted else line
|
||||
@ -25,6 +25,7 @@ from signal_bot.pack import (
|
||||
Entry,
|
||||
Pack,
|
||||
)
|
||||
from signal_bot.pack_custom import load as load_custom
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@ -50,12 +51,18 @@ def load(directory: Path) -> Pack:
|
||||
if unknown:
|
||||
msg = f"{PEOPLE_FILE} refers to unknown people: {sorted(unknown)}"
|
||||
raise ConfigError(msg)
|
||||
by_trigger = {fold(t): entry for entry in entries for t in entry.triggers}
|
||||
# Custom tags are keys tags.json may legitimately name, so they have to
|
||||
# be known before it is validated -- and they are checked against the
|
||||
# people's triggers so a label can never shadow somebody's command.
|
||||
custom = load_custom(directory, frozenset(by_trigger))
|
||||
return Pack(
|
||||
directory=directory,
|
||||
groups=_groups(document),
|
||||
entries=entries,
|
||||
tags=_tags(directory, named),
|
||||
by_trigger={fold(t): entry for entry in entries for t in entry.triggers},
|
||||
tags=_tags(directory, named | custom.keys),
|
||||
by_trigger=by_trigger,
|
||||
custom=custom,
|
||||
)
|
||||
|
||||
|
||||
|
||||
94
signal_bot/pack_random.py
Normal file
94
signal_bot/pack_random.py
Normal file
@ -0,0 +1,94 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""``!random``: a photo from the album, optionally narrowed by custom tags.
|
||||
|
||||
Custom tags define no commands of their own -- that is what keeps a hot-swap
|
||||
a data swap -- so this is the only door to a label ``!add_photo`` minted.
|
||||
|
||||
Its resolver is deliberately **not** the one ``!add_photo`` uses. That one
|
||||
mints a key for a label nobody has; if ``!random`` shared it, every typo here
|
||||
would quietly create a tag, and the album would fill with keys nobody can
|
||||
reach. An unknown label resolves to a sentinel instead and is answered with
|
||||
the same line as a combination nobody photographed -- from the group's side
|
||||
those are the same question.
|
||||
|
||||
Labels only, never people: ``!alfa`` already exists for those, and a second
|
||||
entrance with an unscoped argument list is one too many.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from signal_bot.pack import MAX_NAMES, media_type
|
||||
from signal_bot.pack_command import NAME_PREFIX, NO_PHOTO, UNKNOWN, pick
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from signal_bot.decorators import Decorator
|
||||
from signal_bot.pack_holder import PackHolder
|
||||
from signal_bot.registry import ArgResolver, CommandContext, CommandRegistry, Handler
|
||||
|
||||
COMMAND_NAME: Final = f"{NAME_PREFIX}random"
|
||||
|
||||
DESCRIPTION: Final = "losowe zdjęcie z albumu, opcjonalnie po etykiecie"
|
||||
|
||||
|
||||
def random_resolver(holder: PackHolder) -> ArgResolver:
|
||||
"""Turn typed labels into custom-tag keys, minting nothing.
|
||||
|
||||
Reads through the holder rather than closing over a Pack, so a tag
|
||||
minted at 12:00 resolves at 12:01 without a restart.
|
||||
"""
|
||||
|
||||
def resolve(words: tuple[str, ...]) -> tuple[str, ...]:
|
||||
custom = holder.pack.custom
|
||||
return tuple(custom.resolve(word) or UNKNOWN for word in words)
|
||||
|
||||
return resolve
|
||||
|
||||
|
||||
def random_handler(holder: PackHolder) -> Handler:
|
||||
"""Build the handler that answers ``!random``."""
|
||||
|
||||
async def handler(ctx: CommandContext) -> None:
|
||||
keys = ctx.args
|
||||
# The ceiling is applied to the label list directly rather than
|
||||
# through candidate_keysets, whose ">=" is written against a
|
||||
# head-plus-tail shape that !random does not have.
|
||||
if len(keys) > MAX_NAMES or UNKNOWN in keys:
|
||||
await ctx.reply(NO_PHOTO)
|
||||
return
|
||||
pack = holder.pack
|
||||
# An empty frozenset is a superset test that every file passes, so
|
||||
# bare !random needs no separate path through here.
|
||||
viable = pack.matching(frozenset(keys))
|
||||
if not viable:
|
||||
await ctx.reply(NO_PHOTO)
|
||||
return
|
||||
filename = pick(viable)
|
||||
payload = base64.b64encode(pack.media_path(filename).read_bytes()).decode("ascii")
|
||||
await ctx.reply_media(payload, media_type(filename), filename)
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
def register_random_command(
|
||||
registry: CommandRegistry,
|
||||
holder: PackHolder,
|
||||
triggers: tuple[str, ...],
|
||||
*,
|
||||
decorate: Decorator | None = None,
|
||||
) -> None:
|
||||
"""Register ``!random``, scoped to the album's own groups."""
|
||||
handler = random_handler(holder)
|
||||
if decorate is not None:
|
||||
handler = decorate(handler)
|
||||
registry.register(
|
||||
COMMAND_NAME,
|
||||
triggers,
|
||||
handler,
|
||||
DESCRIPTION,
|
||||
groups=holder.pack.groups,
|
||||
takes_args=True,
|
||||
arg_resolver=random_resolver(holder),
|
||||
)
|
||||
30
signal_bot/pack_replies.py
Normal file
30
signal_bot/pack_replies.py
Normal file
@ -0,0 +1,30 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Everything !add_photo says back, in one place.
|
||||
|
||||
One module because two of them are needed by pack_ingest and the rest by
|
||||
pack_add, and a cross-import between those two would be a cycle. It is also
|
||||
the file to read when the question is "can this command leak a name?": every
|
||||
string the group can see is here, and none of them interpolates anything a
|
||||
person typed. Counts and pseudonyms only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
from signal_bot.pack import MAX_NAMES
|
||||
|
||||
NEED_LABEL: Final = "BEEP BOOP PODAJ CHOĆ JEDNĄ ETYKIETĘ."
|
||||
NEED_PHOTO: Final = "BEEP BOOP DOŁĄCZ ZDJĘCIE DO TEJ WIADOMOŚCI."
|
||||
TOO_MANY: Final = f"BEEP BOOP NAJWYŻEJ {MAX_NAMES} ETYKIET."
|
||||
AMBIGUOUS: Final = "BEEP BOOP TA ETYKIETA JEST NIEJEDNOZNACZNA."
|
||||
REJECTED: Final = "BEEP BOOP NIE PRZYJMUJĘ TEGO PLIKU."
|
||||
FAILED: Final = "BEEP BOOP NIE UDAŁO SIĘ ZAPISAĆ, ALBUM BEZ ZMIAN."
|
||||
DUPLICATE_ONLY: Final = "BEEP BOOP TO ZDJĘCIE JUŻ JEST W ALBUMIE."
|
||||
|
||||
# Says a new label now exists and how to reach it, without echoing it.
|
||||
NEW_TAG_HINT: Final = "NOWA ETYKIETA — SZUKAJ PRZEZ !losowe <etykieta>"
|
||||
|
||||
# What a successful add opens with. The counts that follow are per pseudonym.
|
||||
ADDED: Final = "dodano"
|
||||
ALREADY_THERE: Final = "{count} już było"
|
||||
146
signal_bot/pack_store.py
Normal file
146
signal_bot/pack_store.py
Normal file
@ -0,0 +1,146 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Putting a file into the album, and taking it back out when the swap fails.
|
||||
|
||||
Every write here is temp-then-``Path.replace``, the pattern
|
||||
``tool/tagger.py::Album.write`` already uses, because a manifest that is half
|
||||
written is a manifest that fails the next load -- and with ``Restart=always``
|
||||
a startup-time ConfigError is a loop, not an error message.
|
||||
|
||||
The stored name is a content hash. The original filename is discarded: it is
|
||||
attacker-chosen, it is frequently somebody's name and a date, and hashing
|
||||
makes "is this photo already here?" a lookup instead of a comparison.
|
||||
|
||||
``adds.json`` records who added what. Nothing reads it -- ``pack_load``
|
||||
ignores the file entirely -- and it exists because ``!undo_photo`` cannot be
|
||||
built later without it, and the id has to have been recorded at add time.
|
||||
It stays inside the album directory, which is 0700 and outside the
|
||||
repository, so it is subject to exactly the same handling as the photos.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
from signal_bot.pack import MEDIA_DIR, TAGS_FILE
|
||||
from signal_bot.pack_custom import CUSTOM_FILE, FILE_MODE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
|
||||
ADDS_FILE: Final = "adds.json"
|
||||
|
||||
# Half a SHA-256 is 128 bits of name. Collisions are not a threat model here;
|
||||
# a shorter name is simply easier to read in a directory listing.
|
||||
NAME_LENGTH: Final = 32
|
||||
|
||||
|
||||
def content_name(payload: bytes, suffix: str) -> str:
|
||||
"""The filename one upload gets, derived from the bytes themselves."""
|
||||
digest = hashlib.sha256(payload).hexdigest()[:NAME_LENGTH]
|
||||
return f"{digest}{suffix}"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Snapshot:
|
||||
"""The album's manifests as they were before a write, for rolling back.
|
||||
|
||||
Bytes rather than parsed documents: restoring has to put back exactly
|
||||
what was there, and a reparse-and-redump could quietly normalise a file
|
||||
the tagger wrote by hand.
|
||||
"""
|
||||
|
||||
tags: bytes | None
|
||||
custom: bytes | None
|
||||
adds: bytes | None
|
||||
|
||||
|
||||
def snapshot(directory: Path) -> Snapshot:
|
||||
"""Read the three manifests as they stand, before anything is written."""
|
||||
return Snapshot(
|
||||
tags=_read(directory / TAGS_FILE),
|
||||
custom=_read(directory / CUSTOM_FILE),
|
||||
adds=_read(directory / ADDS_FILE),
|
||||
)
|
||||
|
||||
|
||||
def restore(directory: Path, previous: Snapshot, written: tuple[str, ...]) -> None:
|
||||
"""Undo a failed add: put the manifests back and delete the new files.
|
||||
|
||||
Called only when the album no longer loads after a write, which means the
|
||||
bot is still serving the *old* pack from memory. Leaving the new files
|
||||
behind would make the next start fail on a tag that no longer resolves.
|
||||
"""
|
||||
_restore(directory / TAGS_FILE, previous.tags)
|
||||
_restore(directory / CUSTOM_FILE, previous.custom)
|
||||
_restore(directory / ADDS_FILE, previous.adds)
|
||||
for name in written:
|
||||
(directory / MEDIA_DIR / name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def write_media(directory: Path, name: str, payload: bytes) -> None:
|
||||
"""Put one stripped file into the album, atomically."""
|
||||
album = directory / MEDIA_DIR
|
||||
album.mkdir(parents=True, exist_ok=True)
|
||||
path = album / name
|
||||
temporary = path.with_name(f"{name}.tmp")
|
||||
temporary.write_bytes(payload)
|
||||
temporary.chmod(FILE_MODE)
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def write_tags(directory: Path, tags: Mapping[str, frozenset[str]]) -> None:
|
||||
"""Replace tags.json with the merged table."""
|
||||
document = {name: sorted(keys) for name, keys in sorted(tags.items())}
|
||||
write_json(directory / TAGS_FILE, document)
|
||||
|
||||
|
||||
def record_adds(directory: Path, entries: Mapping[str, dict[str, Any]]) -> None:
|
||||
"""Append to adds.json, which nothing at run time ever reads back."""
|
||||
document = _document(directory / ADDS_FILE)
|
||||
document.update(entries)
|
||||
write_json(directory / ADDS_FILE, document)
|
||||
|
||||
|
||||
def write_json(path: Path, document: Mapping[str, object]) -> None:
|
||||
"""Write one manifest atomically, 0600, so a crash cannot halve it."""
|
||||
temporary = path.with_name(f"{path.name}.tmp")
|
||||
temporary.write_text(json.dumps(document, indent=2), encoding="utf-8")
|
||||
temporary.chmod(FILE_MODE)
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def _document(path: Path) -> dict[str, Any]:
|
||||
"""One JSON object, or an empty one for a file that is not there yet.
|
||||
|
||||
Deliberately forgiving, unlike pack_load: adds.json is write-only
|
||||
bookkeeping, and refusing to store a photo because an unread audit file
|
||||
got corrupted would be the tail wagging the dog.
|
||||
"""
|
||||
raw = _read(path)
|
||||
if raw is None:
|
||||
return {}
|
||||
try:
|
||||
document = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return document if isinstance(document, dict) else {}
|
||||
|
||||
|
||||
def _read(path: Path) -> bytes | None:
|
||||
"""The file's bytes, or None when it does not exist."""
|
||||
try:
|
||||
return path.read_bytes()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _restore(path: Path, previous: bytes | None) -> None:
|
||||
"""Put one manifest back, or remove it if there was none."""
|
||||
if previous is None:
|
||||
path.unlink(missing_ok=True)
|
||||
return
|
||||
path.write_bytes(previous)
|
||||
@ -74,6 +74,13 @@ class CommandContext:
|
||||
|
||||
Handler = Callable[[CommandContext], Awaitable[None]]
|
||||
|
||||
# Turns the words after a trigger into whatever the handler is allowed to see.
|
||||
# For the pack that means pseudonym keys and nothing else: the raw remainder
|
||||
# stops here, so no handler, log line or command_uses row ever holds a name
|
||||
# somebody typed. A command without one gets the words unchanged, which is
|
||||
# what every command before this feature did.
|
||||
ArgResolver = Callable[[tuple[str, ...]], tuple[str, ...]]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Command:
|
||||
@ -101,6 +108,9 @@ class Command:
|
||||
# Off by default, so "!cat blah" keeps resolving to nothing exactly as
|
||||
# it does today.
|
||||
takes_args: bool = False
|
||||
# Applied to those words before they reach CommandContext.args. The
|
||||
# scrubbing seam -- see ArgResolver.
|
||||
arg_resolver: ArgResolver | None = None
|
||||
|
||||
def allowed_in(self, group_id: str) -> bool:
|
||||
"""Whether this command exists in one group."""
|
||||
@ -146,6 +156,7 @@ class CommandRegistry:
|
||||
*,
|
||||
groups: frozenset[str] | None = None,
|
||||
takes_args: bool = False,
|
||||
arg_resolver: ArgResolver | None = None,
|
||||
) -> Command:
|
||||
"""Register one command under every one of its triggers.
|
||||
|
||||
@ -166,6 +177,7 @@ class CommandRegistry:
|
||||
primary=raw[0],
|
||||
groups=groups,
|
||||
takes_args=takes_args,
|
||||
arg_resolver=arg_resolver,
|
||||
)
|
||||
for trigger in normalised:
|
||||
self._by_trigger[trigger] = command
|
||||
@ -205,7 +217,13 @@ class CommandRegistry:
|
||||
head = self._by_trigger.get(fold(words[0]))
|
||||
if head is None or not head.takes_args or not head.allowed_in(group_id):
|
||||
return None
|
||||
return Resolved(head, tuple(words[1:]))
|
||||
tail = tuple(words[1:])
|
||||
# Applied here rather than in the handler, which is the whole point:
|
||||
# a resolver that runs later has already let the raw words past the
|
||||
# log line the dispatcher writes when it names the command.
|
||||
if head.arg_resolver is not None:
|
||||
tail = head.arg_resolver(tail)
|
||||
return Resolved(head, tail)
|
||||
|
||||
@property
|
||||
def commands(self) -> list[Command]:
|
||||
|
||||
@ -58,5 +58,13 @@ class Transport(Protocol):
|
||||
async def list_attachments(self) -> list[str]:
|
||||
"""Return the ids of attachments stored server-side."""
|
||||
|
||||
async def fetch_attachment(self, attachment_id: str) -> bytes:
|
||||
"""Download one stored attachment by id.
|
||||
|
||||
The inbound half of the store list_attachments/delete_attachment
|
||||
already covered. !add_photo is the first caller: an envelope names an
|
||||
attachment but never carries its bytes.
|
||||
"""
|
||||
|
||||
async def delete_attachment(self, attachment_id: str) -> None:
|
||||
"""Delete one stored attachment by id."""
|
||||
|
||||
@ -38,6 +38,10 @@ class FakeTransport:
|
||||
sent: list[SentMessage] = field(default_factory=list)
|
||||
attachments: list[str] = field(default_factory=list)
|
||||
deleted: list[str] = field(default_factory=list)
|
||||
# Attachment id to its bytes, so a test can post a real upload. An id
|
||||
# that is not in here raises, which is how the "the container lost it"
|
||||
# branch of !add_photo is reached.
|
||||
payloads: dict[str, bytes] = field(default_factory=dict)
|
||||
|
||||
def queue(self, frames: Iterable[str]) -> None:
|
||||
"""Add frames to be yielded by the next receive() iteration."""
|
||||
@ -87,9 +91,18 @@ class FakeTransport:
|
||||
"""Return the configured attachment ids."""
|
||||
return list(self.attachments)
|
||||
|
||||
async def fetch_attachment(self, attachment_id: str) -> bytes:
|
||||
"""Return one stored attachment's bytes.
|
||||
|
||||
Raises:
|
||||
KeyError: Nothing was stored under that id.
|
||||
"""
|
||||
return self.payloads[attachment_id]
|
||||
|
||||
async def delete_attachment(self, attachment_id: str) -> None:
|
||||
"""Record an attachment deletion."""
|
||||
self.deleted.append(attachment_id)
|
||||
self.payloads.pop(attachment_id, None)
|
||||
|
||||
@property
|
||||
def texts(self) -> list[str]:
|
||||
|
||||
@ -30,6 +30,8 @@ MAX_BACKOFF: Final = 30.0
|
||||
REQUIRED_MODE: Final = "json-rpc"
|
||||
BACKOFF_FACTOR: Final = 2.0
|
||||
_OK_STATUSES: Final = frozenset({200, 201, 204})
|
||||
# A body is only expected from a plain 200; the others carry none.
|
||||
_HTTP_OK: Final = 200
|
||||
|
||||
Connector = Callable[[str], Any]
|
||||
Sleeper = Callable[[float], Awaitable[None]]
|
||||
@ -180,6 +182,18 @@ class RestTransport:
|
||||
return []
|
||||
return [str(item) for item in body]
|
||||
|
||||
async def fetch_attachment(self, attachment_id: str) -> bytes:
|
||||
"""Download one stored attachment by id.
|
||||
|
||||
Raises:
|
||||
TransportError: The container refused or does not have it.
|
||||
"""
|
||||
response = await self._client.get(f"{self._config.attachments_url}/{attachment_id}")
|
||||
if response.status_code != _HTTP_OK:
|
||||
msg = f"attachment fetch failed with HTTP {response.status_code}"
|
||||
raise TransportError(msg)
|
||||
return bytes(response.content)
|
||||
|
||||
async def delete_attachment(self, attachment_id: str) -> None:
|
||||
"""Delete one stored attachment by id."""
|
||||
response = await self._client.delete(f"{self._config.attachments_url}/{attachment_id}")
|
||||
|
||||
121
tests/pack_add_fixture.py
Normal file
121
tests/pack_add_fixture.py
Normal file
@ -0,0 +1,121 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Shared scaffolding for the two !add_photo test modules.
|
||||
|
||||
A module rather than a conftest fixture, for the reason tests/pack_fixture.py
|
||||
gives: several tests want packs and wirings of different shapes, and saying
|
||||
which one a test needs reads better in the test itself.
|
||||
|
||||
The stripper is injected everywhere here, so no unit test shells out;
|
||||
scripts/e2e.sh runs the real exiftool against media it generates.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from signal_bot.models import parse_envelope
|
||||
from signal_bot.pack_add import AddResolver, register_add_command
|
||||
from signal_bot.pack_holder import PackHolder
|
||||
from signal_bot.pack_load import load
|
||||
from signal_bot.registry import CommandContext, CommandRegistry
|
||||
from signal_bot.transport_fake import FakeTransport
|
||||
from tests import pack_fixture
|
||||
from tests.conftest import WATCHED_GROUP, envelope
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from signal_bot.config import Config
|
||||
from signal_bot.media_sniff import MediaKind
|
||||
from signal_bot.store import Store
|
||||
|
||||
TRIGGERS = ("!dodaj", "!add_photo")
|
||||
|
||||
# Real magic bytes, because the sniffer reads them. Distinct payloads so the
|
||||
# content hashes differ.
|
||||
JPEG_ONE = b"\xff\xd8\xff\xe0one"
|
||||
JPEG_TWO = b"\xff\xd8\xff\xe0two"
|
||||
NOT_MEDIA = b"just a text file"
|
||||
|
||||
|
||||
class FakeStripper:
|
||||
"""Returns the payload unchanged, or refuses everything."""
|
||||
|
||||
def __init__(self, *, accepts: bool = True) -> None:
|
||||
self.accepts = accepts
|
||||
self.seen: list[str] = []
|
||||
|
||||
def strip(self, payload: bytes, kind: MediaKind) -> bytes | None:
|
||||
self.seen.append(kind.suffix)
|
||||
return payload if self.accepts else None
|
||||
|
||||
|
||||
class UndeletableTransport(FakeTransport):
|
||||
"""A container that hands the bytes over but refuses to forget them."""
|
||||
|
||||
async def delete_attachment(self, attachment_id: str) -> None:
|
||||
msg = f"the container will not delete {attachment_id}"
|
||||
raise RuntimeError(msg)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def holder(tmp_path: Path) -> PackHolder:
|
||||
return PackHolder(load(pack_fixture.build(tmp_path / "pack")))
|
||||
|
||||
|
||||
def _registry(holder: PackHolder, stripper: FakeStripper) -> tuple[CommandRegistry, AddResolver]:
|
||||
registry = CommandRegistry()
|
||||
resolver = register_add_command(registry, holder, stripper, TRIGGERS)
|
||||
return registry, resolver
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Wiring:
|
||||
"""The objects a command needs around it, bundled to keep _run readable."""
|
||||
|
||||
holder: PackHolder
|
||||
transport: FakeTransport
|
||||
store: Store
|
||||
config: Config
|
||||
|
||||
|
||||
async def _run(
|
||||
wiring: Wiring,
|
||||
text: str,
|
||||
*,
|
||||
stripper: FakeStripper | None = None,
|
||||
attachments: tuple[str, ...] = (),
|
||||
group: str = WATCHED_GROUP,
|
||||
) -> None:
|
||||
holder, transport, store, config = (
|
||||
wiring.holder,
|
||||
wiring.transport,
|
||||
wiring.store,
|
||||
wiring.config,
|
||||
)
|
||||
registry, _ = _registry(holder, stripper or FakeStripper())
|
||||
resolved = registry.resolve_with_args(text, group)
|
||||
if resolved is None:
|
||||
return
|
||||
raw = envelope(text=text, attachments=("image/jpeg",) * len(attachments))
|
||||
message = parse_envelope(raw)
|
||||
assert message is not None
|
||||
# The envelope builder numbers its ids from 0; point them at the
|
||||
# payloads this test put in the fake's store.
|
||||
message = replace(message, attachment_ids=attachments)
|
||||
await resolved.command.handler(
|
||||
CommandContext(
|
||||
message=message,
|
||||
group_id=group,
|
||||
transport=transport,
|
||||
store=store,
|
||||
config=config,
|
||||
command_name=resolved.command.name,
|
||||
now=1756600000.0,
|
||||
day="2026-08-31",
|
||||
args=resolved.args,
|
||||
)
|
||||
)
|
||||
88
tests/test_external.py
Normal file
88
tests/test_external.py
Normal file
@ -0,0 +1,88 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""The single subprocess site: every way a command can fail, reported as one.
|
||||
|
||||
The property under test is that nothing here raises and nothing looks like it
|
||||
worked when it did not. An exit code that lied is what put ffmpeg in this
|
||||
codebase's history, so "ok" has to mean ok.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from signal_bot.external import DEFAULT_TIMEOUT, run
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pytest
|
||||
|
||||
|
||||
def _script(tmp_path: Path, name: str, body: str) -> str:
|
||||
path = tmp_path / name
|
||||
path.write_text(f"#!/usr/bin/env python3\nimport sys\n{body}\n", encoding="utf-8")
|
||||
path.chmod(path.stat().st_mode | stat.S_IEXEC)
|
||||
return str(path)
|
||||
|
||||
|
||||
def test_a_command_that_succeeds_reports_its_output(tmp_path: Path) -> None:
|
||||
script = _script(tmp_path, "ok", 'sys.stdout.write("hello")')
|
||||
result = run([script])
|
||||
assert result.ok
|
||||
assert result.stdout == "hello"
|
||||
|
||||
|
||||
def test_arguments_are_passed_through(tmp_path: Path) -> None:
|
||||
script = _script(tmp_path, "echo", 'sys.stdout.write(" ".join(sys.argv[1:]))')
|
||||
assert run([script, "-all=", "-o", "out.jpg"]).stdout == "-all= -o out.jpg"
|
||||
|
||||
|
||||
def test_a_nonzero_exit_is_not_ok(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
|
||||
script = _script(tmp_path, "fails", 'sys.stderr.write("no")\nsys.exit(3)')
|
||||
result = run([script])
|
||||
assert not result.ok
|
||||
assert "exited 3" in caplog.text
|
||||
|
||||
|
||||
def test_a_binary_that_is_not_there_is_not_ok(tmp_path: Path) -> None:
|
||||
result = run([str(tmp_path / "nothing-here")])
|
||||
assert not result.ok
|
||||
assert result.stdout == ""
|
||||
|
||||
|
||||
def test_a_command_that_hangs_is_killed_and_not_ok(tmp_path: Path) -> None:
|
||||
script = _script(tmp_path, "hangs", "import time\ntime.sleep(30)")
|
||||
result = run([script], timeout=0.2)
|
||||
assert not result.ok
|
||||
|
||||
|
||||
def test_the_failure_log_never_prints_the_command_output(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""exiftool prints tag *values* on failure; a journal must not carry them."""
|
||||
script = _script(tmp_path, "leaks", 'sys.stdout.write("GPS 52.2297 21.0122")\nsys.exit(1)')
|
||||
run([script])
|
||||
assert "52.2297" not in caplog.text
|
||||
|
||||
|
||||
def test_the_default_timeout_is_long_enough_for_a_video() -> None:
|
||||
assert DEFAULT_TIMEOUT >= 30
|
||||
|
||||
|
||||
def test_it_is_the_only_module_that_imports_subprocess() -> None:
|
||||
"""The whole reason this module exists: exactly one per-file-ignore.
|
||||
|
||||
A second file shelling out would need a second S603 exemption, and the
|
||||
point of the single entry is that adding one is a visible decision rather
|
||||
than a habit that spreads.
|
||||
"""
|
||||
here = Path(__file__).resolve()
|
||||
root = here.parent.parent
|
||||
importers = sorted(
|
||||
str(path.relative_to(root))
|
||||
for directory in ("signal_bot", "fake_signal", "tool", "scripts", "tests")
|
||||
for path in (root / directory).rglob("*.py")
|
||||
# This file names the import in order to look for it.
|
||||
if path != here and "import subprocess" in path.read_text(encoding="utf-8")
|
||||
)
|
||||
assert importers == ["signal_bot/external.py"]
|
||||
@ -3,6 +3,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@ -112,3 +114,40 @@ def test_default_state_reads_the_port_from_the_environment(
|
||||
def test_control_sent_exposes_captured_payloads(client: TestClient) -> None:
|
||||
client.post("/v2/send", json={"message": "hi", "recipients": ["R=="]})
|
||||
assert client.get("/_control/sent").json() == [{"message": "hi", "recipients": ["R=="]}]
|
||||
|
||||
|
||||
def test_an_inbound_attachment_can_be_stored_and_fetched(client: TestClient) -> None:
|
||||
"""Without this the fake could name an attachment but never hand it over."""
|
||||
payload = b"\xff\xd8\xffphoto"
|
||||
stored = client.post(
|
||||
"/_control/attachment",
|
||||
json={"id": "att-1", "base64": base64.b64encode(payload).decode("ascii")},
|
||||
)
|
||||
assert stored.status_code == 200
|
||||
assert client.get("/_control/attachments").json() == ["att-1"]
|
||||
assert client.get("/v1/attachments/att-1").content == payload
|
||||
assert client.get("/v1/attachments").json() == ["att-1"]
|
||||
|
||||
|
||||
def test_fetching_an_attachment_that_is_not_there_is_a_404(client: TestClient) -> None:
|
||||
assert client.get("/v1/attachments/nope").status_code == 404
|
||||
|
||||
|
||||
def test_deleting_an_attachment_takes_its_bytes_too(client: TestClient) -> None:
|
||||
""" "The second copy is gone" has to be assertable, not assumed."""
|
||||
client.post(
|
||||
"/_control/attachment",
|
||||
json={"id": "att-1", "base64": base64.b64encode(b"\xff\xd8\xffx").decode("ascii")},
|
||||
)
|
||||
assert client.delete("/v1/attachments/att-1").status_code == 204
|
||||
assert client.get("/_control/attachments").json() == []
|
||||
assert client.get("/v1/attachments/att-1").status_code == 404
|
||||
|
||||
|
||||
def test_uploading_the_same_id_twice_does_not_duplicate_the_listing(
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
body = {"id": "att-1", "base64": base64.b64encode(b"\xff\xd8\xffx").decode("ascii")}
|
||||
client.post("/_control/attachment", json=body)
|
||||
client.post("/_control/attachment", json=body)
|
||||
assert client.get("/v1/attachments").json() == ["att-1"]
|
||||
|
||||
@ -11,6 +11,7 @@ import pytest
|
||||
from signal_bot.animals import register_animal_commands
|
||||
from signal_bot.config import DEFAULT_COMMONS_API_URL
|
||||
from signal_bot.help_command import (
|
||||
ALBUM_HEADING,
|
||||
COMMAND_NAME,
|
||||
GROUPS,
|
||||
HEADING,
|
||||
@ -21,8 +22,11 @@ from signal_bot.help_command import (
|
||||
)
|
||||
from signal_bot.http_client import ImageFetcher
|
||||
from signal_bot.models import parse_envelope
|
||||
from signal_bot.pack_add import register_add_command
|
||||
from signal_bot.pack_command import register_pack_commands
|
||||
from signal_bot.pack_holder import PackHolder
|
||||
from signal_bot.pack_load import load as load_pack
|
||||
from signal_bot.pack_random import register_random_command
|
||||
from signal_bot.popes_command import register_pope_command
|
||||
from signal_bot.portraits import PortraitCache
|
||||
from signal_bot.registry import CommandContext, CommandRegistry
|
||||
@ -34,6 +38,7 @@ if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from signal_bot.config import Config
|
||||
from signal_bot.media_sniff import MediaKind
|
||||
from signal_bot.store import Store
|
||||
from signal_bot.transport_fake import FakeTransport
|
||||
|
||||
@ -153,7 +158,7 @@ def test_every_alias_resolves(trigger: str) -> None:
|
||||
def test_an_album_gets_its_own_heading_and_a_hint(tmp_path: Path) -> None:
|
||||
"""And the hint is built from real triggers, because they are the example."""
|
||||
registry = full_registry()
|
||||
register_pack_commands(registry, load_pack(pack_fixture.build(tmp_path / "pack")))
|
||||
register_pack_commands(registry, PackHolder(load_pack(pack_fixture.build(tmp_path / "pack"))))
|
||||
|
||||
text = render_help(registry.commands, WATCHED_GROUP)
|
||||
|
||||
@ -165,7 +170,7 @@ def test_an_album_gets_its_own_heading_and_a_hint(tmp_path: Path) -> None:
|
||||
def test_the_album_is_invisible_in_every_other_group(tmp_path: Path) -> None:
|
||||
"""The list is what would announce a private command family to strangers."""
|
||||
registry = full_registry()
|
||||
register_pack_commands(registry, load_pack(pack_fixture.build(tmp_path / "pack")))
|
||||
register_pack_commands(registry, PackHolder(load_pack(pack_fixture.build(tmp_path / "pack"))))
|
||||
|
||||
text = render_help(registry.commands, SECOND_GROUP)
|
||||
|
||||
@ -183,9 +188,52 @@ def test_a_pack_of_one_prints_no_hint(tmp_path: Path) -> None:
|
||||
tags={},
|
||||
)
|
||||
registry = full_registry()
|
||||
register_pack_commands(registry, load_pack(root))
|
||||
register_pack_commands(registry, PackHolder(load_pack(root)))
|
||||
|
||||
text = render_help(registry.commands, WATCHED_GROUP)
|
||||
|
||||
assert PACK_HEADING in text
|
||||
assert "Kilka naraz" not in text
|
||||
|
||||
|
||||
class _NoStripper:
|
||||
"""Registration needs a stripper; rendering help never calls one."""
|
||||
|
||||
def strip(self, payload: bytes, kind: MediaKind) -> bytes | None:
|
||||
return payload
|
||||
|
||||
|
||||
def test_the_album_commands_get_their_own_block(tmp_path: Path) -> None:
|
||||
"""Their own heading, so the "kilka naraz" hint keeps describing people.
|
||||
|
||||
And neither description may name anybody: !pomoc prints them, and the
|
||||
trigger is the only place a name is allowed to appear.
|
||||
"""
|
||||
holder = PackHolder(load_pack(pack_fixture.build(tmp_path / "pack")))
|
||||
registry = full_registry()
|
||||
register_pack_commands(registry, holder)
|
||||
register_random_command(registry, holder, ("!losowe",))
|
||||
register_add_command(registry, holder, _NoStripper(), ("!dodaj",))
|
||||
|
||||
text = render_help(registry.commands, WATCHED_GROUP)
|
||||
|
||||
assert f"\n{ALBUM_HEADING}\n" in text
|
||||
assert "!dodaj — dodaje załączone zdjęcie do albumu z podanymi etykietami" in text
|
||||
assert "!losowe — losowe zdjęcie z albumu, opcjonalnie po etykiecie" in text
|
||||
# The people's block is still its own, with its own hint.
|
||||
assert f"\n{PACK_HEADING}\n" in text
|
||||
assert "Kilka naraz (!alfa !beta)" in text
|
||||
|
||||
|
||||
def test_the_album_commands_are_invisible_in_every_other_group(tmp_path: Path) -> None:
|
||||
holder = PackHolder(load_pack(pack_fixture.build(tmp_path / "pack")))
|
||||
registry = full_registry()
|
||||
register_pack_commands(registry, holder)
|
||||
register_random_command(registry, holder, ("!losowe",))
|
||||
register_add_command(registry, holder, _NoStripper(), ("!dodaj",))
|
||||
|
||||
text = render_help(registry.commands, SECOND_GROUP)
|
||||
|
||||
assert ALBUM_HEADING not in text
|
||||
assert "!dodaj" not in text
|
||||
assert "!losowe" not in text
|
||||
|
||||
93
tests/test_media_sniff.py
Normal file
93
tests/test_media_sniff.py
Normal file
@ -0,0 +1,93 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""What an upload is decided to be, from its bytes and never its name."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from signal_bot.media_sniff import (
|
||||
AVIF,
|
||||
GIF,
|
||||
HEIC,
|
||||
HEIF,
|
||||
JPEG,
|
||||
KINDS,
|
||||
M4V,
|
||||
MOV,
|
||||
MP4,
|
||||
PNG,
|
||||
SVG,
|
||||
WEBM,
|
||||
WEBP,
|
||||
MediaKind,
|
||||
sniff,
|
||||
)
|
||||
from signal_bot.pack import MEDIA_TYPES
|
||||
|
||||
|
||||
def _iso(brand: bytes) -> bytes:
|
||||
return b"\x00\x00\x00\x20ftyp" + brand + b"\x00\x00\x02\x00rest"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("payload", "expected"),
|
||||
[
|
||||
(b"\xff\xd8\xff\xe0JFIF", JPEG),
|
||||
(b"\x89PNG\r\n\x1a\n....", PNG),
|
||||
(b"GIF87a....", GIF),
|
||||
(b"GIF89a....", GIF),
|
||||
(b"RIFF\x00\x00\x00\x00WEBPVP8 ", WEBP),
|
||||
(b"\x1a\x45\xdf\xa3....", WEBM),
|
||||
(_iso(b"isom"), MP4),
|
||||
(_iso(b"mp42"), MP4),
|
||||
(_iso(b"qt "), MOV),
|
||||
(_iso(b"M4V "), M4V),
|
||||
(_iso(b"heic"), HEIC),
|
||||
(_iso(b"HEIX"), HEIC),
|
||||
(_iso(b"mif1"), HEIF),
|
||||
(_iso(b"avif"), AVIF),
|
||||
(b'<?xml version="1.0"?><svg/>', SVG),
|
||||
(b"<svg xmlns='x'/>", SVG),
|
||||
(b"\xef\xbb\xbf <svg/>", SVG),
|
||||
],
|
||||
)
|
||||
def test_every_allowlisted_format_is_recognised(payload: bytes, expected: MediaKind) -> None:
|
||||
assert sniff(payload) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
b"",
|
||||
b"just some text",
|
||||
b"RIFF\x00\x00\x00\x00WAVEfmt ", # RIFF, but not a picture
|
||||
b"RIFF", # truncated before the form type
|
||||
b"%PDF-1.7",
|
||||
b"\x00\x00\x00\x20ftyq" + b"isom", # "ftyq", not "ftyp"
|
||||
b"MZ\x90\x00",
|
||||
],
|
||||
)
|
||||
def test_anything_off_the_allowlist_is_refused(payload: bytes) -> None:
|
||||
assert sniff(payload) is None
|
||||
|
||||
|
||||
def test_a_jpeg_named_as_a_png_is_still_a_jpeg() -> None:
|
||||
"""The filename is attacker-chosen and is never consulted."""
|
||||
assert sniff(b"\xff\xd8\xff\xe0evil.png") is JPEG
|
||||
|
||||
|
||||
def test_every_kind_has_a_media_type_the_pack_knows() -> None:
|
||||
"""The gap that takes startup down: ingested here, refused by pack_load.
|
||||
|
||||
A file stored with a suffix MEDIA_TYPES does not carry uploads fine and
|
||||
then fails the *next* load, so this is the check that has to exist.
|
||||
"""
|
||||
for kind in KINDS:
|
||||
assert kind.suffix in MEDIA_TYPES, kind.suffix
|
||||
assert MEDIA_TYPES[kind.suffix] == kind.media_type
|
||||
|
||||
|
||||
def test_only_webm_and_svg_are_stored_unstripped() -> None:
|
||||
"""kuhy's decision of 2026-08-31, pinned so it cannot spread silently."""
|
||||
unstrippable = {kind.suffix for kind in KINDS if not kind.strippable}
|
||||
assert unstrippable == {".webm", ".svg"}
|
||||
129
tests/test_media_strip.py
Normal file
129
tests/test_media_strip.py
Normal file
@ -0,0 +1,129 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""The stripper, driven against stub binaries rather than the real exiftool.
|
||||
|
||||
Stubs, not mocks of ``subprocess``: the thing worth testing is that the gate
|
||||
reads the *output back* and refuses on what it finds there, and a stub that
|
||||
really writes a file and really prints a report exercises that end to end
|
||||
without needing exiftool installed. ``scripts/e2e.sh`` runs the real one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import stat
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from signal_bot.media_sniff import JPEG, MP4, SVG, WEBM, MediaKind
|
||||
from signal_bot.media_strip import ExiftoolStripper
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
CLEAN_REPORT = """[ExifTool] ExifToolVersion : 13.55
|
||||
[File] FileName : out.jpg
|
||||
[File] MIMEType : image/jpeg
|
||||
[Composite] ImageSize : 48x48
|
||||
"""
|
||||
|
||||
# A geotag as a phone actually writes it into an mp4: a QuickTime atom, which
|
||||
# a gate looking only at "GPS" would wave straight through.
|
||||
LEAKY_REPORT = """[File] FileName : out.mp4
|
||||
[QuickTime] MajorBrand : MP4 Base Media v1
|
||||
[QuickTime] GPSCoordinates : 52.2297 21.0122 0
|
||||
"""
|
||||
|
||||
# Lines the parser must simply skip rather than trip over.
|
||||
NOISY_REPORT = """Warning: something exiftool felt like saying
|
||||
[File] FileName : out.jpg
|
||||
|
||||
======== out.jpg
|
||||
[Composite] ImageSize : 48x48
|
||||
"""
|
||||
|
||||
STUB = """#!/usr/bin/env python3
|
||||
import sys
|
||||
args = sys.argv[1:]
|
||||
if "-all=" in args:
|
||||
{strip}
|
||||
else:
|
||||
sys.stdout.write({report!r})
|
||||
sys.exit(0)
|
||||
"""
|
||||
|
||||
|
||||
def _stub(tmp_path: Path, name: str, *, strip: str, report: str = CLEAN_REPORT) -> str:
|
||||
"""Write an executable stand-in for exiftool and return its path."""
|
||||
path = tmp_path / name
|
||||
path.write_text(STUB.format(strip=strip, report=report), encoding="utf-8")
|
||||
path.chmod(path.stat().st_mode | stat.S_IEXEC)
|
||||
return str(path)
|
||||
|
||||
|
||||
COPIES = 'open(args[args.index("-o") + 1], "wb").write(open(args[-1], "rb").read())'
|
||||
WRITES_NOTHING = "pass"
|
||||
FAILS = "sys.exit(3)"
|
||||
|
||||
|
||||
def test_a_clean_file_comes_back_stripped(tmp_path: Path) -> None:
|
||||
stripper = ExiftoolStripper(_stub(tmp_path, "ok", strip=COPIES))
|
||||
assert stripper.strip(b"\xff\xd8\xffdata", JPEG) == b"\xff\xd8\xffdata"
|
||||
|
||||
|
||||
def test_a_tag_that_survived_the_strip_refuses_the_upload(tmp_path: Path) -> None:
|
||||
"""The whole point: the exit code was 0 and the file is still dirty."""
|
||||
stripper = ExiftoolStripper(_stub(tmp_path, "leaky", strip=COPIES, report=LEAKY_REPORT))
|
||||
assert stripper.strip(b"\x00\x00\x00\x20ftypmp42", MP4) is None
|
||||
|
||||
|
||||
def test_lines_that_are_not_tags_are_skipped(tmp_path: Path) -> None:
|
||||
stripper = ExiftoolStripper(_stub(tmp_path, "noisy", strip=COPIES, report=NOISY_REPORT))
|
||||
assert stripper.strip(b"\xff\xd8\xffdata", JPEG) == b"\xff\xd8\xffdata"
|
||||
|
||||
|
||||
def test_a_strip_that_exits_nonzero_stores_nothing(tmp_path: Path) -> None:
|
||||
stripper = ExiftoolStripper(_stub(tmp_path, "fails", strip=FAILS))
|
||||
assert stripper.strip(b"\xff\xd8\xffdata", JPEG) is None
|
||||
|
||||
|
||||
def test_a_strip_that_writes_no_output_stores_nothing(tmp_path: Path) -> None:
|
||||
"""Exit 0 and no file is exactly the shape of the ffmpeg regression."""
|
||||
stripper = ExiftoolStripper(_stub(tmp_path, "silent", strip=WRITES_NOTHING))
|
||||
assert stripper.strip(b"\xff\xd8\xffdata", JPEG) is None
|
||||
|
||||
|
||||
def test_a_report_that_fails_to_run_stores_nothing(tmp_path: Path) -> None:
|
||||
path = tmp_path / "half"
|
||||
path.write_text(
|
||||
"#!/usr/bin/env python3\n"
|
||||
"import sys\n"
|
||||
"args = sys.argv[1:]\n"
|
||||
'if "-all=" in args:\n'
|
||||
' open(args[args.index("-o") + 1], "wb").write(open(args[-1], "rb").read())\n'
|
||||
" sys.exit(0)\n"
|
||||
"sys.exit(4)\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
path.chmod(path.stat().st_mode | stat.S_IEXEC)
|
||||
assert ExiftoolStripper(str(path)).strip(b"\xff\xd8\xffdata", JPEG) is None
|
||||
|
||||
|
||||
def test_a_missing_binary_refuses_rather_than_raising(tmp_path: Path) -> None:
|
||||
stripper = ExiftoolStripper(str(tmp_path / "not-here"))
|
||||
assert stripper.strip(b"\xff\xd8\xffdata", JPEG) is None
|
||||
|
||||
|
||||
def test_no_exiftool_at_all_refuses_the_upload(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Never stores it dirty instead: a strip that cannot run is a refusal."""
|
||||
monkeypatch.setattr("signal_bot.media_strip.shutil.which", lambda _: None)
|
||||
assert ExiftoolStripper().strip(b"\xff\xd8\xffdata", JPEG) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", [SVG, WEBM])
|
||||
def test_the_two_unwritable_formats_are_stored_as_they_arrived(
|
||||
tmp_path: Path, kind: MediaKind
|
||||
) -> None:
|
||||
"""kuhy's decision of 2026-08-31, and it must not need exiftool at all."""
|
||||
stripper = ExiftoolStripper(str(tmp_path / "never-invoked"))
|
||||
payload = b"<svg><metadata>a name</metadata></svg>"
|
||||
assert stripper.strip(payload, kind) == payload
|
||||
91
tests/test_media_tags.py
Normal file
91
tests/test_media_tags.py
Normal file
@ -0,0 +1,91 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Which surviving tags are structural, and which mean the strip failed."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from signal_bot.media_tags import STRUCTURAL, ZERO_DATE, is_allowed
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("group", "tag"),
|
||||
[
|
||||
("File", "FileName"),
|
||||
("Composite", "ImageSize"),
|
||||
("ExifTool", "ExifToolVersion"),
|
||||
("PNG", "ImageWidth"),
|
||||
("GIF", "GIFVersion"),
|
||||
("RIFF", "VP8Version"),
|
||||
("QuickTime", "TimeScale"),
|
||||
("QuickTime", "MajorBrand"),
|
||||
],
|
||||
)
|
||||
def test_structural_tags_are_allowed(group: str, tag: str) -> None:
|
||||
assert is_allowed(group, tag, "whatever")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("group", "tag"),
|
||||
[
|
||||
# The case a naive "-GPS:all" gate misses entirely: a phone geotag is
|
||||
# a QuickTime atom, filed under QuickTime and not under GPS.
|
||||
("QuickTime", "GPSCoordinates"),
|
||||
("QuickTime", "LocationInformation"),
|
||||
("EXIF", "Make"),
|
||||
("GPS", "GPSLatitude"),
|
||||
("XMP", "Creator"),
|
||||
("IPTC", "By-line"),
|
||||
("PNG", "Comment"),
|
||||
],
|
||||
)
|
||||
def test_metadata_that_survived_is_refused(group: str, tag: str) -> None:
|
||||
assert not is_allowed(group, tag, "value")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"tag",
|
||||
[
|
||||
# Codec configuration, allowed by kuhy's decision of 2026-08-31. With
|
||||
# these refused, exiftool 13.55 could not produce an acceptable HEIC
|
||||
# or mp4 at all: CompressorName lives in the avc1 sample description
|
||||
# and is not writable, and a real HEIC reports 16 HEVC config tags.
|
||||
"CompressorName",
|
||||
"VendorID",
|
||||
"HEVCConfigurationVersion",
|
||||
"GeneralProfileIDC",
|
||||
"BitDepthLuma",
|
||||
"CleanAperture",
|
||||
],
|
||||
)
|
||||
def test_codec_configuration_is_allowed_so_video_and_heic_can_exist(tag: str) -> None:
|
||||
assert is_allowed("QuickTime", tag, "whatever")
|
||||
|
||||
|
||||
def test_allowing_codec_names_did_not_allow_location() -> None:
|
||||
"""The trade was codec parameters, not anything about where or who."""
|
||||
for tag in ("GPSCoordinates", "GPSPosition", "LocationInformation", "LocationName"):
|
||||
assert not is_allowed("QuickTime", tag, "52.2297 21.0122")
|
||||
|
||||
|
||||
def test_a_group_nobody_listed_is_refused_whatever_the_tag() -> None:
|
||||
"""Not knowing what a group can carry is what the allowlist is for."""
|
||||
assert not is_allowed("SomeFutureFormat", "ImageWidth", "64")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tag", sorted({"CreateDate", "TrackCreateDate", "MediaModifyDate"}))
|
||||
def test_container_dates_are_allowed_only_when_zeroed(tag: str) -> None:
|
||||
"""`-all=` zeroes these rather than removing them, so the check is by value."""
|
||||
assert is_allowed("QuickTime", tag, ZERO_DATE)
|
||||
assert is_allowed("QuickTime", tag, f" {ZERO_DATE} ")
|
||||
assert not is_allowed("QuickTime", tag, "2026:08:31 09:00:00")
|
||||
|
||||
|
||||
def test_a_zeroed_date_in_an_unknown_group_is_still_refused() -> None:
|
||||
assert not is_allowed("SomeFutureFormat", "CreateDate", ZERO_DATE)
|
||||
|
||||
|
||||
def test_the_video_containers_share_one_table() -> None:
|
||||
"""mp4, mov, m4v, heic, heif and avif all report under QuickTime."""
|
||||
assert "QuickTime" in STRUCTURAL
|
||||
assert "ImageWidth" in STRUCTURAL["QuickTime"]
|
||||
228
tests/test_pack_add.py
Normal file
228
tests/test_pack_add.py
Normal file
@ -0,0 +1,228 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""!add_photo: what lands in the album, and what is refused before it can."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from signal_bot.pack import TAGS_FILE
|
||||
from signal_bot.pack_custom import CUSTOM_FILE
|
||||
from signal_bot.pack_replies import (
|
||||
AMBIGUOUS,
|
||||
DUPLICATE_ONLY,
|
||||
NEED_LABEL,
|
||||
NEED_PHOTO,
|
||||
NEW_TAG_HINT,
|
||||
REJECTED,
|
||||
TOO_MANY,
|
||||
)
|
||||
from signal_bot.pack_store import ADDS_FILE
|
||||
from tests.conftest import SECOND_GROUP
|
||||
from tests.pack_add_fixture import (
|
||||
JPEG_ONE,
|
||||
JPEG_TWO,
|
||||
NOT_MEDIA,
|
||||
FakeStripper,
|
||||
Wiring,
|
||||
_run,
|
||||
holder,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from signal_bot.config import Config
|
||||
from signal_bot.pack_holder import PackHolder
|
||||
from signal_bot.store import Store
|
||||
from signal_bot.transport_fake import FakeTransport
|
||||
|
||||
__all__ = ["holder"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# The happy paths
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_a_photo_tagged_with_a_person_is_retrievable_at_once(
|
||||
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
transport.payloads["a1"] = JPEG_ONE
|
||||
before = len(holder.pack.matching(frozenset({"p1"})))
|
||||
await _run(Wiring(holder, transport, store, config), "!dodaj alfa", attachments=("a1",))
|
||||
assert len(holder.pack.matching(frozenset({"p1"}))) == before + 1
|
||||
assert transport.texts[-1] == f"dodano · p1: {before + 1}"
|
||||
|
||||
|
||||
async def test_an_unknown_label_mints_a_custom_tag_and_says_how_to_reach_it(
|
||||
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
transport.payloads["a1"] = JPEG_ONE
|
||||
await _run(Wiring(holder, transport, store, config), "!dodaj wakacje", attachments=("a1",))
|
||||
assert holder.pack.custom.resolve("wakacje") == "t1"
|
||||
assert NEW_TAG_HINT in transport.texts[-1]
|
||||
assert "dodano · t1: 1" in transport.texts[-1]
|
||||
|
||||
|
||||
async def test_two_labels_tag_both(
|
||||
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
transport.payloads["a1"] = JPEG_ONE
|
||||
await _run(Wiring(holder, transport, store, config), "!dodaj alfa wakacje", attachments=("a1",))
|
||||
stored = holder.pack.matching(frozenset({"p1", "t1"}))
|
||||
assert len(stored) == 1
|
||||
|
||||
|
||||
async def test_a_whole_batch_is_stored(
|
||||
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
"""Picking three photos at once is the normal phone gesture."""
|
||||
transport.payloads.update({"a1": JPEG_ONE, "a2": JPEG_TWO})
|
||||
before = len(holder.pack.matching(frozenset({"p1"})))
|
||||
await _run(Wiring(holder, transport, store, config), "!dodaj alfa", attachments=("a1", "a2"))
|
||||
assert len(holder.pack.matching(frozenset({"p1"}))) == before + 2
|
||||
|
||||
|
||||
async def test_a_combination_trigger_tags_everybody_in_it(
|
||||
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
transport.payloads["a1"] = JPEG_ONE
|
||||
await _run(Wiring(holder, transport, store, config), "!dodaj oboje", attachments=("a1",))
|
||||
assert len(holder.pack.matching(frozenset({"p1", "p2"}))) == 2
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# The refusals, none of which may write anything
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_no_label_stores_nothing(
|
||||
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
transport.payloads["a1"] = JPEG_ONE
|
||||
await _run(Wiring(holder, transport, store, config), "!dodaj", attachments=("a1",))
|
||||
assert transport.texts[-1] == NEED_LABEL
|
||||
|
||||
|
||||
async def test_no_attachment_changes_nothing_on_disk(
|
||||
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
"""The pending mint is discarded: custom.json must not appear at all."""
|
||||
root = holder.pack.directory
|
||||
await _run(Wiring(holder, transport, store, config), "!dodaj piesek")
|
||||
assert transport.texts[-1] == NEED_PHOTO
|
||||
assert not (root / CUSTOM_FILE).exists()
|
||||
assert not (root / ADDS_FILE).exists()
|
||||
assert holder.pack.custom.keys == frozenset()
|
||||
|
||||
|
||||
async def test_too_many_labels_are_refused(
|
||||
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
transport.payloads["a1"] = JPEG_ONE
|
||||
labels = " ".join(f"etykieta{n}" for n in range(7))
|
||||
await _run(Wiring(holder, transport, store, config), f"!dodaj {labels}", attachments=("a1",))
|
||||
assert transport.texts[-1] == TOO_MANY
|
||||
|
||||
|
||||
async def test_an_either_or_combination_is_refused_rather_than_guessed(
|
||||
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
""" "!ktokolwiek" means two different answers; tagging cannot pick one."""
|
||||
transport.payloads["a1"] = JPEG_ONE
|
||||
await _run(Wiring(holder, transport, store, config), "!dodaj ktokolwiek", attachments=("a1",))
|
||||
assert transport.texts[-1] == AMBIGUOUS
|
||||
|
||||
|
||||
async def test_a_file_that_is_not_media_is_refused_on_its_bytes(
|
||||
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
transport.payloads["a1"] = NOT_MEDIA
|
||||
await _run(Wiring(holder, transport, store, config), "!dodaj alfa", attachments=("a1",))
|
||||
assert transport.texts[-1] == REJECTED
|
||||
assert not (holder.pack.directory / CUSTOM_FILE).exists()
|
||||
|
||||
|
||||
async def test_a_file_the_stripper_refuses_is_not_stored(
|
||||
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
transport.payloads["a1"] = JPEG_ONE
|
||||
before = len(holder.pack.tags)
|
||||
await _run(
|
||||
Wiring(holder, transport, store, config),
|
||||
"!dodaj alfa",
|
||||
stripper=FakeStripper(accepts=False),
|
||||
attachments=("a1",),
|
||||
)
|
||||
assert transport.texts[-1] == REJECTED
|
||||
assert len(holder.pack.tags) == before
|
||||
|
||||
|
||||
async def test_an_attachment_the_container_lost_is_refused(
|
||||
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
await _run(Wiring(holder, transport, store, config), "!dodaj alfa", attachments=("missing",))
|
||||
assert transport.texts[-1] == REJECTED
|
||||
|
||||
|
||||
async def test_it_does_not_exist_in_another_group(
|
||||
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
transport.payloads["a1"] = JPEG_ONE
|
||||
await _run(
|
||||
Wiring(holder, transport, store, config),
|
||||
"!dodaj alfa",
|
||||
attachments=("a1",),
|
||||
group=SECOND_GROUP,
|
||||
)
|
||||
assert transport.sent == []
|
||||
assert transport.deleted == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Duplicates
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_a_photo_already_in_the_album_is_reported_and_nothing_changes(
|
||||
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
transport.payloads["a1"] = JPEG_ONE
|
||||
await _run(Wiring(holder, transport, store, config), "!dodaj alfa", attachments=("a1",))
|
||||
tags_before = (holder.pack.directory / TAGS_FILE).read_text(encoding="utf-8")
|
||||
|
||||
transport.payloads["a2"] = JPEG_ONE
|
||||
await _run(Wiring(holder, transport, store, config), "!dodaj beta", attachments=("a2",))
|
||||
assert transport.texts[-1] == DUPLICATE_ONLY
|
||||
assert (holder.pack.directory / TAGS_FILE).read_text(encoding="utf-8") == tags_before
|
||||
|
||||
|
||||
async def test_a_duplicate_only_message_mints_nothing(
|
||||
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
transport.payloads["a1"] = JPEG_ONE
|
||||
await _run(Wiring(holder, transport, store, config), "!dodaj alfa", attachments=("a1",))
|
||||
transport.payloads["a2"] = JPEG_ONE
|
||||
await _run(Wiring(holder, transport, store, config), "!dodaj zupelnienowe", attachments=("a2",))
|
||||
assert holder.pack.custom.resolve("zupelnienowe") is None
|
||||
|
||||
|
||||
async def test_a_mixed_batch_stores_the_new_ones_and_counts_the_rest(
|
||||
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
transport.payloads["a1"] = JPEG_ONE
|
||||
await _run(Wiring(holder, transport, store, config), "!dodaj alfa", attachments=("a1",))
|
||||
before = len(holder.pack.matching(frozenset({"p2"})))
|
||||
|
||||
transport.payloads.update({"a2": JPEG_ONE, "a3": JPEG_TWO})
|
||||
await _run(Wiring(holder, transport, store, config), "!dodaj beta", attachments=("a2", "a3"))
|
||||
assert "1 już było" in transport.texts[-1]
|
||||
assert len(holder.pack.matching(frozenset({"p2"}))) == before + 1
|
||||
|
||||
|
||||
async def test_the_same_photo_twice_in_one_message_counts_once(
|
||||
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
transport.payloads.update({"a1": JPEG_ONE, "a2": JPEG_ONE})
|
||||
before = len(holder.pack.matching(frozenset({"p1"})))
|
||||
await _run(Wiring(holder, transport, store, config), "!dodaj alfa", attachments=("a1", "a2"))
|
||||
assert len(holder.pack.matching(frozenset({"p1"}))) == before + 1
|
||||
assert "1 już było" in transport.texts[-1]
|
||||
227
tests/test_pack_add_invariants.py
Normal file
227
tests/test_pack_add_invariants.py
Normal file
@ -0,0 +1,227 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""The guarantees !add_photo exists to keep, each asserted on its own.
|
||||
|
||||
Separate from test_pack_add.py because these are not "does the feature work"
|
||||
-- they are the properties DOCS-photo-pack.md promises, and they should fail
|
||||
loudly and by name when one of them stops holding.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
|
||||
from signal_bot.animals import register_animal_commands
|
||||
from signal_bot.dispatcher import Dispatcher
|
||||
from signal_bot.http_client import ImageFetcher
|
||||
from signal_bot.pack import TAGS_FILE
|
||||
from signal_bot.pack_add import COMMAND_NAME, AddResolver
|
||||
from signal_bot.pack_command import UNKNOWN
|
||||
from signal_bot.pack_custom import CUSTOM_FILE
|
||||
from signal_bot.pack_holder import PackHolder
|
||||
from signal_bot.pack_load import load
|
||||
from signal_bot.pack_replies import FAILED
|
||||
from signal_bot.pack_store import ADDS_FILE
|
||||
from signal_bot.registry import CommandRegistry
|
||||
from tests import pack_fixture
|
||||
from tests.conftest import WATCHED_GROUP, envelope
|
||||
from tests.pack_add_fixture import (
|
||||
JPEG_ONE,
|
||||
NOT_MEDIA,
|
||||
FakeStripper,
|
||||
UndeletableTransport,
|
||||
Wiring,
|
||||
_registry,
|
||||
_run,
|
||||
holder,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from signal_bot.config import Config
|
||||
from signal_bot.discovery import GroupDirectory
|
||||
from signal_bot.store import Store
|
||||
from signal_bot.transport_fake import FakeTransport
|
||||
|
||||
__all__ = ["holder"]
|
||||
|
||||
|
||||
async def test_a_typed_label_reaches_no_log_line_no_row_and_no_reply(
|
||||
holder: PackHolder,
|
||||
transport: FakeTransport,
|
||||
store: Store,
|
||||
config: Config,
|
||||
directory: GroupDirectory,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""The scrubbing seam, on all three surfaces, through the real dispatcher.
|
||||
|
||||
Driven end to end rather than by calling the handler: the log line that
|
||||
names a command is written by the dispatcher, and the command_uses row is
|
||||
written by the dispatcher, so a handler-level test could not see either.
|
||||
"""
|
||||
registry, _ = _registry(holder, FakeStripper())
|
||||
dispatcher = Dispatcher(
|
||||
config=config,
|
||||
directory=directory,
|
||||
registry=registry,
|
||||
store=store,
|
||||
transport=transport,
|
||||
clock=lambda: dt.datetime(2026, 8, 31, 12, 0, tzinfo=dt.UTC),
|
||||
)
|
||||
transport.payloads["0"] = JPEG_ONE
|
||||
frame = envelope(text="!dodaj sekretnaetykieta", attachments=("image/jpeg",))
|
||||
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
await dispatcher.handle(frame)
|
||||
|
||||
assert "dodano" in transport.texts[-1]
|
||||
assert "sekretnaetykieta" not in caplog.text
|
||||
assert all("sekretnaetykieta" not in text for text in transport.texts)
|
||||
# The row exists and is keyed by the pseudonym, not by anything typed.
|
||||
assert store.uses_today(WATCHED_GROUP, "uuid-alice", COMMAND_NAME, "2026-08-31") == 1
|
||||
assert "sekretnaetykieta" not in COMMAND_NAME
|
||||
|
||||
|
||||
async def test_the_container_copy_is_deleted_whether_accepted_or_refused(
|
||||
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
transport.payloads["a1"] = JPEG_ONE
|
||||
await _run(Wiring(holder, transport, store, config), "!dodaj alfa", attachments=("a1",))
|
||||
assert transport.deleted == ["a1"]
|
||||
|
||||
transport.payloads["a2"] = NOT_MEDIA
|
||||
await _run(Wiring(holder, transport, store, config), "!dodaj alfa", attachments=("a2",))
|
||||
assert transport.deleted == ["a1", "a2"]
|
||||
|
||||
|
||||
async def test_the_adder_is_recorded_for_a_future_undo(
|
||||
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
transport.payloads["a1"] = JPEG_ONE
|
||||
await _run(Wiring(holder, transport, store, config), "!dodaj alfa", attachments=("a1",))
|
||||
document = json.loads((holder.pack.directory / ADDS_FILE).read_text(encoding="utf-8"))
|
||||
entry = next(iter(document.values()))
|
||||
assert entry == {"by": "uuid-alice", "at": 1756600000}
|
||||
|
||||
|
||||
async def test_nothing_reads_adds_json_at_load_time(
|
||||
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
"""A corrupt audit file must not be able to take the album down."""
|
||||
transport.payloads["a1"] = JPEG_ONE
|
||||
await _run(Wiring(holder, transport, store, config), "!dodaj alfa", attachments=("a1",))
|
||||
(holder.pack.directory / ADDS_FILE).write_text("{ corrupt", encoding="utf-8")
|
||||
assert holder.reload() is True
|
||||
|
||||
|
||||
async def test_a_swap_that_fails_rolls_everything_back_and_keeps_serving(
|
||||
holder: PackHolder,
|
||||
transport: FakeTransport,
|
||||
store: Store,
|
||||
config: Config,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The branch nobody remembers: written, unloadable, and must not raise."""
|
||||
root = holder.pack.directory
|
||||
before_pack = holder.pack
|
||||
before_tags = (root / TAGS_FILE).read_text(encoding="utf-8")
|
||||
before_media = sorted(p.name for p in (root / "media").iterdir())
|
||||
|
||||
monkeypatch.setattr(PackHolder, "reload", lambda _self: False)
|
||||
transport.payloads["a1"] = JPEG_ONE
|
||||
await _run(Wiring(holder, transport, store, config), "!dodaj alfa wakacje", attachments=("a1",))
|
||||
|
||||
assert transport.texts[-1] == FAILED
|
||||
assert holder.pack is before_pack
|
||||
assert (root / TAGS_FILE).read_text(encoding="utf-8") == before_tags
|
||||
assert sorted(p.name for p in (root / "media").iterdir()) == before_media
|
||||
assert not (root / CUSTOM_FILE).exists()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# The resolver on its own
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_pending_mint_is_cleared_between_messages(holder: PackHolder) -> None:
|
||||
"""Dispatch is sequential, so exactly one message's mints are outstanding."""
|
||||
resolver = AddResolver(holder, CommandRegistry())
|
||||
assert resolver(("wakacje",)) == ("t1",)
|
||||
assert resolver.pending == {"t1": "wakacje"}
|
||||
assert resolver(("alfa",)) == ("p1",)
|
||||
assert resolver.pending == {}
|
||||
|
||||
|
||||
def test_two_new_labels_in_one_message_get_different_keys(holder: PackHolder) -> None:
|
||||
resolver = AddResolver(holder, CommandRegistry())
|
||||
assert resolver(("wakacje", "gory")) == ("t1", "t2")
|
||||
|
||||
|
||||
def test_a_known_custom_label_resolves_without_minting(tmp_path: Path) -> None:
|
||||
"""A label the album already has is reused, never allocated a second key."""
|
||||
root = pack_fixture.build(tmp_path / "pack")
|
||||
(root / CUSTOM_FILE).write_text(json.dumps({"t1": ["wakacje"]}), encoding="utf-8")
|
||||
holder = PackHolder(load(root))
|
||||
|
||||
resolver = AddResolver(holder, CommandRegistry())
|
||||
assert resolver(("wakacje",)) == ("t1",)
|
||||
assert resolver.pending == {}
|
||||
|
||||
|
||||
async def test_a_delete_that_fails_does_not_lose_the_photo(
|
||||
holder: PackHolder, store: Store, config: Config, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""The album write already succeeded; a stuck container copy is a log line."""
|
||||
transport = UndeletableTransport()
|
||||
transport.payloads["a1"] = JPEG_ONE
|
||||
before = len(holder.pack.matching(frozenset({"p1"})))
|
||||
await _run(Wiring(holder, transport, store, config), "!dodaj alfa", attachments=("a1",))
|
||||
assert len(holder.pack.matching(frozenset({"p1"}))) == before + 1
|
||||
assert "could not delete" in caplog.text
|
||||
|
||||
|
||||
def test_no_decorator_is_applied_so_there_is_no_cap(holder: PackHolder) -> None:
|
||||
"""kuhy: NO CAP, NO LIMITS -- no cooldown, no size cap, no daily limit."""
|
||||
registry, _ = _registry(holder, FakeStripper())
|
||||
command = registry.commands[0]
|
||||
assert command.name == COMMAND_NAME
|
||||
assert command.groups == holder.pack.groups
|
||||
assert command.arg_resolver is not None
|
||||
|
||||
|
||||
def test_a_label_that_is_already_a_command_is_refused(holder: PackHolder) -> None:
|
||||
"""Otherwise one message wedges the personal-data gate permanently.
|
||||
|
||||
scripts/personal_data_sources feeds custom labels to the gate. Minting
|
||||
"kot" would put a word that is in aliases.json and DOCS-commands.md onto
|
||||
the forbidden list, and every commit after that would be refused until
|
||||
somebody hand-edited an album the committer cannot see.
|
||||
"""
|
||||
registry = CommandRegistry()
|
||||
register_animal_commands(registry, _fetcher())
|
||||
resolver = AddResolver(holder, registry)
|
||||
|
||||
assert resolver(("kot",)) == (UNKNOWN,)
|
||||
assert resolver.pending == {}
|
||||
|
||||
|
||||
def test_a_label_that_is_not_a_command_still_mints(holder: PackHolder) -> None:
|
||||
registry = CommandRegistry()
|
||||
register_animal_commands(registry, _fetcher())
|
||||
resolver = AddResolver(holder, registry)
|
||||
|
||||
assert resolver(("wakacje",)) == ("t1",)
|
||||
assert resolver.pending == {"t1": "wakacje"}
|
||||
|
||||
|
||||
def _fetcher() -> ImageFetcher:
|
||||
"""An image fetcher whose client is never called: only triggers matter."""
|
||||
return ImageFetcher(httpx.AsyncClient())
|
||||
@ -17,6 +17,7 @@ from signal_bot.pack_command import (
|
||||
description_for,
|
||||
register_pack_commands,
|
||||
)
|
||||
from signal_bot.pack_holder import PackHolder
|
||||
from signal_bot.pack_load import load
|
||||
from signal_bot.registry import CommandContext, CommandRegistry
|
||||
from tests import pack_fixture
|
||||
@ -39,7 +40,7 @@ def pack(tmp_path: Path) -> Pack:
|
||||
|
||||
def _registry(pack: Pack) -> CommandRegistry:
|
||||
registry = CommandRegistry()
|
||||
register_pack_commands(registry, pack)
|
||||
register_pack_commands(registry, PackHolder(pack))
|
||||
return registry
|
||||
|
||||
|
||||
@ -191,5 +192,5 @@ def test_the_gate_is_applied_to_every_entry(pack: Pack) -> None:
|
||||
marked.append("wrapped")
|
||||
return handler
|
||||
|
||||
register_pack_commands(registry, pack, decorate=decorate)
|
||||
register_pack_commands(registry, PackHolder(pack), decorate=decorate)
|
||||
assert len(marked) == len(pack.entries)
|
||||
|
||||
122
tests/test_pack_custom.py
Normal file
122
tests/test_pack_custom.py
Normal file
@ -0,0 +1,122 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""custom.json: labels the album learned from the chat, and what refuses one."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from signal_bot.groups import ConfigError
|
||||
from signal_bot.pack_custom import CUSTOM_FILE, CustomTags, load, write
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
NO_TRIGGERS: frozenset[str] = frozenset()
|
||||
|
||||
|
||||
def _write(directory: Path, document: object) -> Path:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
(directory / CUSTOM_FILE).write_text(json.dumps(document), encoding="utf-8")
|
||||
return directory
|
||||
|
||||
|
||||
def test_an_album_with_no_custom_file_has_no_custom_tags(tmp_path: Path) -> None:
|
||||
"""The normal state of an album nobody has run !add_photo on."""
|
||||
tags = load(tmp_path, NO_TRIGGERS)
|
||||
assert tags.keys == frozenset()
|
||||
assert tags.resolve("anything") is None
|
||||
|
||||
|
||||
def test_labels_resolve_to_their_key(tmp_path: Path) -> None:
|
||||
tags = load(_write(tmp_path, {"t1": ["wakacje"], "t2": ["gory", "gorki"]}), NO_TRIGGERS)
|
||||
assert tags.resolve("wakacje") == "t1"
|
||||
assert tags.resolve("gorki") == "t2"
|
||||
assert tags.keys == {"t1", "t2"}
|
||||
|
||||
|
||||
def test_a_label_resolves_through_folding(tmp_path: Path) -> None:
|
||||
"""The same folding a trigger gets, so a Cyrillic "o" still resolves."""
|
||||
tags = load(_write(tmp_path, {"t1": ["Wakacje"]}), NO_TRIGGERS)
|
||||
assert tags.resolve("WAKACJE") == "t1"
|
||||
assert tags.resolve("wakacje") == "t1"
|
||||
|
||||
|
||||
def test_the_next_key_is_one_past_the_highest_in_use() -> None:
|
||||
"""Not the count: deleting t2 by hand must not make a mint reuse it.
|
||||
|
||||
Reusing it would silently inherit every photo the old t2 was tagged with.
|
||||
"""
|
||||
tags = CustomTags(by_key={"t1": ("a",), "t5": ("b",)}, by_label={"a": "t1", "b": "t5"})
|
||||
assert tags.next_key() == "t6"
|
||||
assert CustomTags().next_key() == "t1"
|
||||
|
||||
|
||||
def test_a_key_that_is_not_a_pseudonym_fails_the_load(tmp_path: Path) -> None:
|
||||
"""The key reaches the log and tags.json, so it may not look like a name."""
|
||||
with pytest.raises(ConfigError, match="must be 't' followed by a number"):
|
||||
load(_write(tmp_path, {"wakacje": ["x"]}), NO_TRIGGERS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("document", [{"t1": []}, {"t1": "wakacje"}, {"t1": None}])
|
||||
def test_an_entry_without_labels_fails_the_load(tmp_path: Path, document: object) -> None:
|
||||
with pytest.raises(ConfigError, match="non-empty list of labels"):
|
||||
load(_write(tmp_path, document), NO_TRIGGERS)
|
||||
|
||||
|
||||
def test_a_label_that_folds_away_to_nothing_fails_the_load(tmp_path: Path) -> None:
|
||||
with pytest.raises(ConfigError, match="folds away to nothing"):
|
||||
load(_write(tmp_path, {"t1": ["!!!"]}), NO_TRIGGERS)
|
||||
|
||||
|
||||
def test_a_label_that_shadows_a_person_fails_the_load(tmp_path: Path) -> None:
|
||||
"""Otherwise the label quietly hides the command that person is reached by."""
|
||||
with pytest.raises(ConfigError, match="already a person's trigger"):
|
||||
load(_write(tmp_path, {"t1": ["alfa"]}), frozenset({"!alfa"}))
|
||||
|
||||
|
||||
def test_two_keys_claiming_one_label_fails_the_load(tmp_path: Path) -> None:
|
||||
"""!random would otherwise pick between them arbitrarily."""
|
||||
with pytest.raises(ConfigError, match="is claimed by"):
|
||||
load(_write(tmp_path, {"t1": ["wakacje"], "t2": ["wakacje"]}), NO_TRIGGERS)
|
||||
|
||||
|
||||
def test_malformed_json_fails_the_load(tmp_path: Path) -> None:
|
||||
(tmp_path / CUSTOM_FILE).write_text("{not json", encoding="utf-8")
|
||||
with pytest.raises(ConfigError, match="not valid JSON"):
|
||||
load(tmp_path, NO_TRIGGERS)
|
||||
|
||||
|
||||
def test_a_document_that_is_not_an_object_fails_the_load(tmp_path: Path) -> None:
|
||||
with pytest.raises(ConfigError, match="must be an object"):
|
||||
load(_write(tmp_path, ["t1"]), NO_TRIGGERS)
|
||||
|
||||
|
||||
def test_an_unreadable_file_fails_the_load(tmp_path: Path) -> None:
|
||||
"""A directory where the manifest should be: read_text raises OSError."""
|
||||
(tmp_path / CUSTOM_FILE).mkdir(parents=True)
|
||||
with pytest.raises(ConfigError, match="cannot be read"):
|
||||
load(tmp_path, NO_TRIGGERS)
|
||||
|
||||
|
||||
def test_writing_then_loading_round_trips(tmp_path: Path) -> None:
|
||||
tags = CustomTags().with_label("t1", "wakacje").with_label("t1", "wakacje2019")
|
||||
write(tmp_path, tags)
|
||||
again = load(tmp_path, NO_TRIGGERS)
|
||||
assert again.by_key == {"t1": ("wakacje", "wakacje2019")}
|
||||
assert again.resolve("wakacje2019") == "t1"
|
||||
|
||||
|
||||
def test_the_written_file_is_not_world_readable(tmp_path: Path) -> None:
|
||||
write(tmp_path, CustomTags().with_label("t1", "wakacje"))
|
||||
assert (tmp_path / CUSTOM_FILE).stat().st_mode & 0o077 == 0
|
||||
|
||||
|
||||
def test_with_label_does_not_mutate_the_original() -> None:
|
||||
"""The pending mint must be discardable, which needs a value type."""
|
||||
original = CustomTags()
|
||||
extended = original.with_label("t1", "wakacje")
|
||||
assert original.keys == frozenset()
|
||||
assert extended.keys == {"t1"}
|
||||
55
tests/test_pack_holder.py
Normal file
55
tests/test_pack_holder.py
Normal file
@ -0,0 +1,55 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""The mutable box: a swap that works, and one that must not take the bot down."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from signal_bot.pack_holder import PackHolder
|
||||
from signal_bot.pack_load import load
|
||||
from tests import pack_fixture
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_a_reload_publishes_what_is_now_on_disk(tmp_path: Path) -> None:
|
||||
root = pack_fixture.build(tmp_path / "pack")
|
||||
holder = PackHolder(load(root))
|
||||
assert holder.pack.matching(frozenset({"p3"})) == ("gamma.png",)
|
||||
|
||||
(root / "media" / "extra.png").write_bytes(pack_fixture.PIXEL)
|
||||
tags = json.loads((root / "tags.json").read_text(encoding="utf-8"))
|
||||
tags["extra.png"] = ["p3"]
|
||||
(root / "tags.json").write_text(json.dumps(tags), encoding="utf-8")
|
||||
|
||||
assert holder.reload() is True
|
||||
assert holder.pack.matching(frozenset({"p3"})) == ("extra.png", "gamma.png")
|
||||
|
||||
|
||||
def test_a_pack_that_no_longer_loads_keeps_the_old_one_and_does_not_raise(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Restart=always turns a raised ConfigError into a restart loop."""
|
||||
root = pack_fixture.build(tmp_path / "pack")
|
||||
holder = PackHolder(load(root))
|
||||
before = holder.pack
|
||||
|
||||
# A tag naming a file that is not there: exactly what a half-rolled-back
|
||||
# write leaves behind.
|
||||
(root / "tags.json").write_text(json.dumps({"vanished.png": ["p1"]}), encoding="utf-8")
|
||||
|
||||
assert holder.reload() is False
|
||||
assert holder.pack is before
|
||||
assert holder.pack.matching(frozenset({"p1"})) == ("alfa.png", "both.png", "clip.mp4")
|
||||
assert "no longer loads" in caplog.text
|
||||
|
||||
|
||||
def test_the_holder_reloads_from_the_directory_it_already_has(tmp_path: Path) -> None:
|
||||
root = pack_fixture.build(tmp_path / "pack")
|
||||
holder = PackHolder(load(root))
|
||||
assert holder.reload() is True
|
||||
assert holder.pack.directory == root
|
||||
182
tests/test_pack_random.py
Normal file
182
tests/test_pack_random.py
Normal file
@ -0,0 +1,182 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""!random: the only door to a custom tag, and the one that mints nothing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from signal_bot.models import parse_envelope
|
||||
from signal_bot.pack_command import NO_PHOTO
|
||||
from signal_bot.pack_custom import CUSTOM_FILE
|
||||
from signal_bot.pack_holder import PackHolder
|
||||
from signal_bot.pack_load import load
|
||||
from signal_bot.pack_random import COMMAND_NAME, register_random_command
|
||||
from signal_bot.registry import CommandContext, CommandRegistry
|
||||
from tests import pack_fixture
|
||||
from tests.conftest import SECOND_GROUP, WATCHED_GROUP, envelope
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from signal_bot.config import Config
|
||||
from signal_bot.registry import Handler
|
||||
from signal_bot.store import Store
|
||||
from signal_bot.transport_fake import FakeTransport
|
||||
|
||||
TRIGGERS = ("!losowe", "!random")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def holder(tmp_path: Path) -> PackHolder:
|
||||
root = pack_fixture.build(tmp_path / "pack")
|
||||
(root / CUSTOM_FILE).write_text(
|
||||
json.dumps({"t1": ["wakacje"], "t2": ["gory"]}), encoding="utf-8"
|
||||
)
|
||||
tags = json.loads((root / "tags.json").read_text(encoding="utf-8"))
|
||||
tags["alfa.png"] = ["p1", "t1"]
|
||||
(root / "tags.json").write_text(json.dumps(tags), encoding="utf-8")
|
||||
return PackHolder(load(root))
|
||||
|
||||
|
||||
def _registry(holder: PackHolder) -> CommandRegistry:
|
||||
registry = CommandRegistry()
|
||||
register_random_command(registry, holder, TRIGGERS)
|
||||
return registry
|
||||
|
||||
|
||||
async def _run(
|
||||
holder: PackHolder,
|
||||
transport: FakeTransport,
|
||||
store: Store,
|
||||
config: Config,
|
||||
text: str,
|
||||
group: str = WATCHED_GROUP,
|
||||
) -> None:
|
||||
registry = _registry(holder)
|
||||
resolved = registry.resolve_with_args(text, group)
|
||||
if resolved is None:
|
||||
return
|
||||
message = parse_envelope(envelope(text=text))
|
||||
assert message is not None
|
||||
await resolved.command.handler(
|
||||
CommandContext(
|
||||
message=message,
|
||||
group_id=group,
|
||||
transport=transport,
|
||||
store=store,
|
||||
config=config,
|
||||
command_name=resolved.command.name,
|
||||
now=0.0,
|
||||
day="2026-08-31",
|
||||
args=resolved.args,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def test_a_bare_random_posts_something_from_the_whole_album(
|
||||
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
"""matching(frozenset()) is already "everything", so there is no new path."""
|
||||
await _run(holder, transport, store, config, "!losowe")
|
||||
assert transport.sent[-1].image_base64 is not None
|
||||
|
||||
|
||||
async def test_a_custom_label_narrows_to_its_own_photos(
|
||||
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
await _run(holder, transport, store, config, "!losowe wakacje")
|
||||
assert transport.sent[-1].filename == "alfa.png"
|
||||
|
||||
|
||||
async def test_a_label_nobody_has_gets_one_line_and_no_photo(
|
||||
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
await _run(holder, transport, store, config, "!losowe niktnietaki")
|
||||
assert transport.texts[-1] == NO_PHOTO
|
||||
|
||||
|
||||
async def test_a_typo_on_random_mints_nothing(
|
||||
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
"""The reason !random does not share !add_photo's resolver."""
|
||||
before = (holder.pack.directory / CUSTOM_FILE).read_text(encoding="utf-8")
|
||||
await _run(holder, transport, store, config, "!losowe wakacjee")
|
||||
assert (holder.pack.directory / CUSTOM_FILE).read_text(encoding="utf-8") == before
|
||||
assert holder.pack.custom.keys == {"t1", "t2"}
|
||||
|
||||
|
||||
async def test_a_tag_nobody_photographed_gets_one_line(
|
||||
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
await _run(holder, transport, store, config, "!losowe gory")
|
||||
assert transport.texts[-1] == NO_PHOTO
|
||||
|
||||
|
||||
async def test_two_labels_intersect(
|
||||
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
"""t1 and t2 share no photo, so the intersection is empty."""
|
||||
await _run(holder, transport, store, config, "!losowe wakacje gory")
|
||||
assert transport.texts[-1] == NO_PHOTO
|
||||
|
||||
|
||||
async def test_too_many_labels_are_refused_rather_than_multiplied(
|
||||
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
await _run(holder, transport, store, config, "!losowe " + " ".join(["wakacje"] * 7))
|
||||
assert transport.texts[-1] == NO_PHOTO
|
||||
|
||||
|
||||
async def test_random_never_resolves_a_person(
|
||||
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
"""!alfa already exists; a second entrance would be one too many."""
|
||||
await _run(holder, transport, store, config, "!losowe alfa")
|
||||
assert transport.texts[-1] == NO_PHOTO
|
||||
|
||||
|
||||
async def test_it_does_not_exist_in_a_group_the_album_was_not_given_to(
|
||||
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
await _run(holder, transport, store, config, "!losowe wakacje", group=SECOND_GROUP)
|
||||
assert transport.sent == []
|
||||
|
||||
|
||||
def test_it_is_scoped_and_named_by_pseudonym(holder: PackHolder) -> None:
|
||||
command = _registry(holder).commands[0]
|
||||
assert command.name == COMMAND_NAME
|
||||
assert command.groups == holder.pack.groups
|
||||
assert "wakacje" not in command.description
|
||||
|
||||
|
||||
async def test_a_tag_minted_after_registration_still_resolves(
|
||||
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
"""The resolver reads the holder, so a swap is enough -- no re-register."""
|
||||
root = holder.pack.directory
|
||||
(root / CUSTOM_FILE).write_text(
|
||||
json.dumps({"t1": ["wakacje"], "t2": ["gory"], "t3": ["nowe"]}), encoding="utf-8"
|
||||
)
|
||||
tags = json.loads((root / "tags.json").read_text(encoding="utf-8"))
|
||||
tags["gamma.png"] = ["p3", "t3"]
|
||||
(root / "tags.json").write_text(json.dumps(tags), encoding="utf-8")
|
||||
assert holder.reload() is True
|
||||
|
||||
await _run(holder, transport, store, config, "!losowe nowe")
|
||||
assert transport.sent[-1].filename == "gamma.png"
|
||||
|
||||
|
||||
def test_the_cooldown_decorator_is_applied_when_given(holder: PackHolder) -> None:
|
||||
"""!random is a photo poster, so it takes the same cooldown as !alfa."""
|
||||
calls: list[str] = []
|
||||
|
||||
def decorate(handler: Handler) -> Handler:
|
||||
calls.append("wrapped")
|
||||
return handler
|
||||
|
||||
registry = CommandRegistry()
|
||||
register_random_command(registry, holder, TRIGGERS, decorate=decorate)
|
||||
assert calls == ["wrapped"]
|
||||
101
tests/test_pack_store.py
Normal file
101
tests/test_pack_store.py
Normal file
@ -0,0 +1,101 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Writing into the album, and putting it back the way it was."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from signal_bot.pack import TAGS_FILE
|
||||
from signal_bot.pack_custom import CUSTOM_FILE
|
||||
from signal_bot.pack_store import (
|
||||
ADDS_FILE,
|
||||
content_name,
|
||||
record_adds,
|
||||
restore,
|
||||
snapshot,
|
||||
write_media,
|
||||
write_tags,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_the_name_comes_from_the_bytes_not_the_upload(tmp_path: Path) -> None:
|
||||
"""Which is what makes "already in the album?" a lookup, not a comparison."""
|
||||
assert content_name(b"same", ".jpg") == content_name(b"same", ".jpg")
|
||||
assert content_name(b"same", ".jpg") != content_name(b"other", ".jpg")
|
||||
assert content_name(b"same", ".jpg").endswith(".jpg")
|
||||
|
||||
|
||||
def test_a_written_file_lands_in_media_and_is_not_world_readable(tmp_path: Path) -> None:
|
||||
write_media(tmp_path, "abc.jpg", b"payload")
|
||||
stored = tmp_path / "media" / "abc.jpg"
|
||||
assert stored.read_bytes() == b"payload"
|
||||
assert stored.stat().st_mode & 0o077 == 0
|
||||
|
||||
|
||||
def test_no_temporary_file_is_left_behind(tmp_path: Path) -> None:
|
||||
"""A .tmp beside media/ would fail the next load as an untagged file."""
|
||||
write_media(tmp_path, "abc.jpg", b"payload")
|
||||
assert [p.name for p in (tmp_path / "media").iterdir()] == ["abc.jpg"]
|
||||
|
||||
|
||||
def test_tags_are_written_sorted_and_reloadable(tmp_path: Path) -> None:
|
||||
write_tags(tmp_path, {"b.jpg": frozenset({"p2", "p1"}), "a.jpg": frozenset({"p1"})})
|
||||
document = json.loads((tmp_path / TAGS_FILE).read_text(encoding="utf-8"))
|
||||
assert document == {"a.jpg": ["p1"], "b.jpg": ["p1", "p2"]}
|
||||
assert list(document) == ["a.jpg", "b.jpg"]
|
||||
|
||||
|
||||
def test_adds_accumulate_rather_than_replace(tmp_path: Path) -> None:
|
||||
record_adds(tmp_path, {"a.jpg": {"by": "uuid-1", "at": 10}})
|
||||
record_adds(tmp_path, {"b.jpg": {"by": "uuid-2", "at": 20}})
|
||||
document = json.loads((tmp_path / ADDS_FILE).read_text(encoding="utf-8"))
|
||||
assert set(document) == {"a.jpg", "b.jpg"}
|
||||
assert document["a.jpg"]["by"] == "uuid-1"
|
||||
|
||||
|
||||
def test_a_corrupt_adds_file_does_not_block_an_upload(tmp_path: Path) -> None:
|
||||
"""It is write-only bookkeeping; refusing a photo over it is backwards."""
|
||||
(tmp_path / ADDS_FILE).write_text("{ not json", encoding="utf-8")
|
||||
record_adds(tmp_path, {"a.jpg": {"by": "uuid-1", "at": 10}})
|
||||
assert json.loads((tmp_path / ADDS_FILE).read_text(encoding="utf-8")) == {
|
||||
"a.jpg": {"by": "uuid-1", "at": 10}
|
||||
}
|
||||
|
||||
|
||||
def test_an_adds_file_that_is_not_an_object_is_replaced(tmp_path: Path) -> None:
|
||||
(tmp_path / ADDS_FILE).write_text("[1, 2]", encoding="utf-8")
|
||||
record_adds(tmp_path, {"a.jpg": {"by": "uuid-1", "at": 10}})
|
||||
assert set(json.loads((tmp_path / ADDS_FILE).read_text(encoding="utf-8"))) == {"a.jpg"}
|
||||
|
||||
|
||||
def test_restore_puts_the_manifests_back_and_removes_the_new_files(tmp_path: Path) -> None:
|
||||
(tmp_path / TAGS_FILE).write_text('{"old.jpg": ["p1"]}', encoding="utf-8")
|
||||
(tmp_path / CUSTOM_FILE).write_text('{"t1": ["was-here"]}', encoding="utf-8")
|
||||
before = snapshot(tmp_path)
|
||||
|
||||
write_media(tmp_path, "new.jpg", b"payload")
|
||||
write_tags(tmp_path, {"old.jpg": frozenset({"p1"}), "new.jpg": frozenset({"p2"})})
|
||||
record_adds(tmp_path, {"new.jpg": {"by": "uuid-1", "at": 1}})
|
||||
|
||||
restore(tmp_path, before, ("new.jpg",))
|
||||
|
||||
assert json.loads((tmp_path / TAGS_FILE).read_text(encoding="utf-8")) == {"old.jpg": ["p1"]}
|
||||
assert json.loads((tmp_path / CUSTOM_FILE).read_text(encoding="utf-8")) == {"t1": ["was-here"]}
|
||||
# adds.json did not exist before, so restoring means removing it.
|
||||
assert not (tmp_path / ADDS_FILE).exists()
|
||||
assert not (tmp_path / "media" / "new.jpg").exists()
|
||||
|
||||
|
||||
def test_restoring_a_file_that_was_already_gone_is_not_an_error(tmp_path: Path) -> None:
|
||||
before = snapshot(tmp_path)
|
||||
restore(tmp_path, before, ("never-written.jpg",))
|
||||
assert not (tmp_path / TAGS_FILE).exists()
|
||||
|
||||
|
||||
def test_a_snapshot_of_an_empty_album_records_three_absences(tmp_path: Path) -> None:
|
||||
before = snapshot(tmp_path)
|
||||
assert (before.tags, before.custom, before.adds) == (None, None, None)
|
||||
125
tests/test_personal_data_sources.py
Normal file
125
tests/test_personal_data_sources.py
Normal file
@ -0,0 +1,125 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Where the personal-data gate learns its forbidden words from.
|
||||
|
||||
Worth its own test because ``!add_photo`` made one of those sources
|
||||
*writable from a group chat*, and the failure mode is silent and permanent:
|
||||
a label that is also a word in this repository would put that word on the
|
||||
forbidden list and refuse every commit from then on, fixable only by
|
||||
hand-editing an album the committer cannot see.
|
||||
|
||||
Driven entirely through ``forbidden()``, which is what the gate itself calls.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from types import ModuleType
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sources() -> ModuleType:
|
||||
"""Import scripts/personal_data_sources.py, which is not a package."""
|
||||
path = REPO / "scripts" / "personal_data_sources.py"
|
||||
spec = importlib.util.spec_from_file_location("personal_data_sources", path)
|
||||
assert spec is not None
|
||||
assert spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _album(
|
||||
root: Path,
|
||||
*,
|
||||
triggers: list[str] | None = None,
|
||||
custom: dict[str, Any] | None = None,
|
||||
corrupt_custom: bool = False,
|
||||
) -> Path:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
people = [{"key": "p1", "triggers": triggers or ["!zenon"]}]
|
||||
(root / "people.json").write_text(
|
||||
json.dumps({"groups": ["g"], "people": people}), encoding="utf-8"
|
||||
)
|
||||
if corrupt_custom:
|
||||
(root / "custom.json").write_text("{ not json", encoding="utf-8")
|
||||
elif custom is not None:
|
||||
(root / "custom.json").write_text(json.dumps(custom), encoding="utf-8")
|
||||
return root
|
||||
|
||||
|
||||
def _words(sources: ModuleType, root: Path, monkeypatch: pytest.MonkeyPatch) -> set[str]:
|
||||
monkeypatch.setenv("PHOTO_PACK_DIR", str(root))
|
||||
words, _ = sources.forbidden(set())
|
||||
return set(words)
|
||||
|
||||
|
||||
def test_a_people_trigger_is_forbidden(
|
||||
sources: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
assert "zenon" in _words(sources, _album(tmp_path / "pack"), monkeypatch)
|
||||
|
||||
|
||||
def test_a_custom_label_is_forbidden_too(
|
||||
sources: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A label can be a name -- "ciocia_hania" is one however it got there."""
|
||||
root = _album(tmp_path / "pack", custom={"t1": ["ciocia_hania"]})
|
||||
assert "ciocia_hania" in _words(sources, root, monkeypatch)
|
||||
|
||||
|
||||
def test_a_label_that_is_a_command_word_is_not_forbidden(
|
||||
sources: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The wedge: "!add_photo kot" must not outlaw "kot" in aliases.json."""
|
||||
root = _album(tmp_path / "pack", custom={"t1": ["kot"], "t2": ["ciocia_hania"]})
|
||||
words = _words(sources, root, monkeypatch)
|
||||
assert "kot" not in words
|
||||
assert "ciocia_hania" in words
|
||||
|
||||
|
||||
@pytest.mark.parametrize("label", ["losowe", "papiez", "add_photo", "staty"])
|
||||
def test_no_command_word_can_be_outlawed_by_a_label(
|
||||
sources: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, label: str
|
||||
) -> None:
|
||||
"""Generalised: every alias in the table, not just the one that bit."""
|
||||
root = _album(tmp_path / "pack", custom={"t1": [label]})
|
||||
assert label not in _words(sources, root, monkeypatch)
|
||||
|
||||
|
||||
def test_a_people_trigger_is_never_excused_by_the_vocabulary(
|
||||
sources: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Only custom labels get the subtraction.
|
||||
|
||||
A curated real name turning up in this repository is precisely the leak
|
||||
the gate exists to fail on, so it stays forbidden even when it collides.
|
||||
"""
|
||||
root = _album(tmp_path / "pack", triggers=["!kot"])
|
||||
assert "kot" in _words(sources, root, monkeypatch)
|
||||
|
||||
|
||||
def test_an_album_with_no_custom_file_still_works(
|
||||
sources: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Other sources (the database, .env) contribute too, so this is membership."""
|
||||
words = _words(sources, _album(tmp_path / "pack"), monkeypatch)
|
||||
assert "zenon" in words
|
||||
|
||||
|
||||
def test_a_corrupt_custom_file_is_ignored_rather_than_fatal(
|
||||
sources: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Refusing every commit because an album file got truncated is backwards."""
|
||||
root = _album(tmp_path / "pack", corrupt_custom=True)
|
||||
assert "zenon" in _words(sources, root, monkeypatch)
|
||||
@ -177,3 +177,30 @@ def test_arrivals_are_parsed_from_a_file(tmp_path: Path) -> None:
|
||||
"name: demo\narrive:\n - {id: reply, internal_id: watched}\n", encoding="utf-8"
|
||||
)
|
||||
assert load_scenario(path).arrive[0].internal_id == "watched"
|
||||
|
||||
|
||||
def test_an_injection_can_carry_an_inbound_attachment() -> None:
|
||||
"""What makes !add_photo expressible offline at all."""
|
||||
frame = Injection(text="!dodaj alfa", attachment="att-1").to_frame(GROUPS)
|
||||
message = parse_envelope(frame)
|
||||
assert message is not None
|
||||
assert message.attachment_ids == ("att-1",)
|
||||
assert message.attachment_types == ("image/jpeg",)
|
||||
|
||||
|
||||
def test_an_attachment_type_can_be_stated() -> None:
|
||||
frame = Injection(text="!dodaj alfa", attachment="att-2", attachment_type="video/mp4").to_frame(
|
||||
GROUPS
|
||||
)
|
||||
message = parse_envelope(frame)
|
||||
assert message is not None
|
||||
assert message.attachment_types == ("video/mp4",)
|
||||
|
||||
|
||||
def test_the_attachment_lands_at_the_top_level_not_inside_a_quote() -> None:
|
||||
"""A reply's quote carries the *quoted* message's media; taking it would
|
||||
let !add_photo re-file a photo somebody else posted."""
|
||||
frame = json.loads(Injection(text="!dodaj alfa", attachment="att-1").to_frame(GROUPS))
|
||||
payload = frame["envelope"]["dataMessage"]
|
||||
assert "attachments" in payload
|
||||
assert "quote" not in payload
|
||||
|
||||
76
tests/test_transport_attachments.py
Normal file
76
tests/test_transport_attachments.py
Normal file
@ -0,0 +1,76 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""The attachment store: listing, fetching and deleting the container's copy.
|
||||
|
||||
Split out of test_transport_rest.py when !add_photo added the fetch half and
|
||||
that file crossed the 250-line cap. These four endpoints are one subject: the
|
||||
copy signal-cli keeps of every inbound attachment, which lives outside the
|
||||
album's 0700 and which !add_photo is the first thing to delete deliberately.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from signal_bot.transport_rest import RestTransport, TransportError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from signal_bot.config import Config
|
||||
|
||||
# The callable httpx.MockTransport expects -- not a bot command handler.
|
||||
MockHandler = Callable[[httpx.Request], httpx.Response]
|
||||
|
||||
|
||||
def _transport(config: Config, handler: MockHandler) -> RestTransport:
|
||||
"""A transport whose every HTTP call is answered by ``handler``."""
|
||||
return RestTransport(config, httpx.AsyncClient(transport=httpx.MockTransport(handler)))
|
||||
|
||||
|
||||
async def test_list_attachments(config: Config) -> None:
|
||||
transport = _transport(config, lambda r: httpx.Response(200, json=["a", "b"]))
|
||||
assert await transport.list_attachments() == ["a", "b"]
|
||||
|
||||
|
||||
async def test_list_attachments_tolerates_non_list(config: Config) -> None:
|
||||
transport = _transport(config, lambda r: httpx.Response(200, json={"oops": 1}))
|
||||
assert await transport.list_attachments() == []
|
||||
|
||||
|
||||
async def test_delete_attachment(config: Config) -> None:
|
||||
seen: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(str(request.url))
|
||||
return httpx.Response(204)
|
||||
|
||||
await _transport(config, handler).delete_attachment("abc")
|
||||
assert seen[0].endswith("/v1/attachments/abc")
|
||||
|
||||
|
||||
async def test_delete_attachment_raises_on_failure(config: Config) -> None:
|
||||
transport = _transport(config, lambda r: httpx.Response(500))
|
||||
with pytest.raises(TransportError, match="500"):
|
||||
await transport.delete_attachment("abc")
|
||||
|
||||
|
||||
async def test_fetch_attachment_returns_the_bytes(config: Config) -> None:
|
||||
"""An envelope names an attachment but never carries it; this fetches it."""
|
||||
seen: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(str(request.url))
|
||||
return httpx.Response(200, content=b"\xff\xd8\xffphoto")
|
||||
|
||||
assert await _transport(config, handler).fetch_attachment("abc") == b"\xff\xd8\xffphoto"
|
||||
assert seen[0].endswith("/v1/attachments/abc")
|
||||
|
||||
|
||||
async def test_fetch_attachment_raises_when_the_container_has_not_got_it(
|
||||
config: Config,
|
||||
) -> None:
|
||||
transport = _transport(config, lambda r: httpx.Response(404))
|
||||
with pytest.raises(TransportError, match="404"):
|
||||
await transport.fetch_attachment("gone")
|
||||
@ -34,6 +34,7 @@ def test_protocol_names_the_whole_surface() -> None:
|
||||
"send_text",
|
||||
"send_image",
|
||||
"list_attachments",
|
||||
"fetch_attachment",
|
||||
"delete_attachment",
|
||||
):
|
||||
assert hasattr(Transport, name)
|
||||
|
||||
@ -148,33 +148,6 @@ async def test_verify_mode_rejects_non_websocket_modes(config: Config, body: obj
|
||||
await transport.verify_mode()
|
||||
|
||||
|
||||
async def test_list_attachments(config: Config) -> None:
|
||||
transport = _transport(config, lambda r: httpx.Response(200, json=["a", "b"]))
|
||||
assert await transport.list_attachments() == ["a", "b"]
|
||||
|
||||
|
||||
async def test_list_attachments_tolerates_non_list(config: Config) -> None:
|
||||
transport = _transport(config, lambda r: httpx.Response(200, json={"oops": 1}))
|
||||
assert await transport.list_attachments() == []
|
||||
|
||||
|
||||
async def test_delete_attachment(config: Config) -> None:
|
||||
seen: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(str(request.url))
|
||||
return httpx.Response(204)
|
||||
|
||||
await _transport(config, handler).delete_attachment("abc")
|
||||
assert seen[0].endswith("/v1/attachments/abc")
|
||||
|
||||
|
||||
async def test_delete_attachment_raises_on_failure(config: Config) -> None:
|
||||
transport = _transport(config, lambda r: httpx.Response(500))
|
||||
with pytest.raises(TransportError, match="500"):
|
||||
await transport.delete_attachment("abc")
|
||||
|
||||
|
||||
async def test_receive_yields_frames(config: Config) -> None:
|
||||
connector = FakeConnect([FakeSocket(["a", "b"])])
|
||||
transport = _transport(
|
||||
|
||||
184
tool/e2e_fixtures.py
Executable file
184
tool/e2e_fixtures.py
Executable file
@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Generating the media scripts/e2e.sh feeds to !add_photo.
|
||||
|
||||
Split out of e2e_media.py at the 250-line cap. Nothing here is committed as a
|
||||
file: a fixture photo in this repository is exactly what the binary gate and
|
||||
the personal-data gate exist to prevent, so every sample is built at run time
|
||||
and carries *real* metadata -- a real APP1 Exif segment, a real QuickTime
|
||||
geotag, a real HEVC parameter set. A file with nothing to strip proves nothing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
from signal_bot.external import run
|
||||
|
||||
# Warsaw, in an actual GPS tag. If any of this survives, the run must fail.
|
||||
LATITUDE: Final = "52.2297"
|
||||
LONGITUDE: Final = "21.0122"
|
||||
PROBE_MAKE: Final = "SecretPhoneModel"
|
||||
PROBE_CREATOR: Final = "A Real Person Name"
|
||||
|
||||
# An SVG carrying both things exiftool cannot help with: a metadata block and
|
||||
# a whole embedded raster. Stored as-is, per the decision recorded in
|
||||
# DOCS-photo-pack.md -- this exists so the e2e run *demonstrates* that hole
|
||||
# rather than leaving it to the prose.
|
||||
SVG_DOCUMENT: Final = (
|
||||
'<?xml version="1.0"?>'
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8">'
|
||||
f"<metadata>{PROBE_CREATOR}</metadata>"
|
||||
'<rect width="8" height="8" fill="teal"/>'
|
||||
"</svg>"
|
||||
).encode()
|
||||
|
||||
# Not a media file in any sense. The sniffer must refuse it on the bytes.
|
||||
JUNK: Final = b"this is not a photo, it is a text file pretending to be one\n"
|
||||
|
||||
|
||||
class ToolError(RuntimeError):
|
||||
"""An external tool this script needs did not do its job."""
|
||||
|
||||
|
||||
def run_tool(command: list[str]) -> str:
|
||||
"""Run a tool and return its stdout, failing loudly if it errors.
|
||||
|
||||
Goes through signal_bot.external like everything else that starts a
|
||||
process, and turns its "ok=False" into an exception because this is a
|
||||
script: there is nothing to degrade to.
|
||||
"""
|
||||
result = run(command)
|
||||
if not result.ok:
|
||||
msg = f"{command[0]} failed: {result.stderr.strip()}"
|
||||
raise ToolError(msg)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def _jpeg(workspace: Path, name: str, colour: str) -> bytes:
|
||||
"""A JPEG with a real APP1 Exif segment: GPS, Make and an XMP creator."""
|
||||
path = workspace / f"{name}.jpg"
|
||||
run_tool(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
f"color=c={colour}:s=48x48:d=1",
|
||||
"-frames:v",
|
||||
"1",
|
||||
str(path),
|
||||
"-y",
|
||||
]
|
||||
)
|
||||
run_tool(
|
||||
[
|
||||
"exiftool",
|
||||
"-q",
|
||||
"-overwrite_original",
|
||||
f"-GPSLatitude={LATITUDE}",
|
||||
"-GPSLatitudeRef=N",
|
||||
f"-GPSLongitude={LONGITUDE}",
|
||||
"-GPSLongitudeRef=E",
|
||||
f"-Make={PROBE_MAKE}",
|
||||
f"-XMP:Creator={PROBE_CREATOR}",
|
||||
str(path),
|
||||
]
|
||||
)
|
||||
return path.read_bytes()
|
||||
|
||||
|
||||
def _mp4(workspace: Path) -> bytes:
|
||||
"""An MP4 geotagged the way a phone does it: a QuickTime atom, not GPS.
|
||||
|
||||
This is the case a naive "-Exif:all -GPS:all -XMP:all" gate passes
|
||||
straight through, because exiftool files the atom under QuickTime.
|
||||
"""
|
||||
path = workspace / "clip.mp4"
|
||||
run_tool(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"color=c=navy:s=48x48:d=1",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
str(path),
|
||||
"-y",
|
||||
]
|
||||
)
|
||||
run_tool(
|
||||
[
|
||||
"exiftool",
|
||||
"-q",
|
||||
"-overwrite_original",
|
||||
f"-QuickTime:GPSCoordinates={LATITUDE} {LONGITUDE} 0",
|
||||
f"-XMP:Creator={PROBE_CREATOR}",
|
||||
str(path),
|
||||
]
|
||||
)
|
||||
return path.read_bytes()
|
||||
|
||||
|
||||
def _heic(workspace: Path) -> bytes:
|
||||
"""A geotagged HEIC -- what an iPhone actually sends.
|
||||
|
||||
Its HEVC parameter set survives the strip and is on the allowlist; the
|
||||
GPS and the Make must not survive, and verify_pack re-reads the stored
|
||||
file to prove it.
|
||||
"""
|
||||
path = workspace / "shot.heic"
|
||||
source = workspace / "heic-source.jpg"
|
||||
run_tool(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"color=c=teal:s=48x48:d=1",
|
||||
"-frames:v",
|
||||
"1",
|
||||
str(source),
|
||||
"-y",
|
||||
]
|
||||
)
|
||||
run_tool(["heif-enc", str(source), "-o", str(path)])
|
||||
run_tool(
|
||||
[
|
||||
"exiftool",
|
||||
"-q",
|
||||
"-overwrite_original",
|
||||
f"-GPSLatitude={LATITUDE}",
|
||||
"-GPSLatitudeRef=N",
|
||||
f"-Make={PROBE_MAKE}",
|
||||
f"-XMP:Creator={PROBE_CREATOR}",
|
||||
str(path),
|
||||
]
|
||||
)
|
||||
return path.read_bytes()
|
||||
|
||||
|
||||
def build() -> dict[str, bytes]:
|
||||
"""Every attachment the scenarios reference, by the id they name."""
|
||||
with tempfile.TemporaryDirectory(prefix="e2e-media-") as raw:
|
||||
workspace = Path(raw)
|
||||
return {
|
||||
"att-person": _jpeg(workspace, "person", "orange"),
|
||||
"att-custom": _jpeg(workspace, "custom", "purple"),
|
||||
"att-second": _jpeg(workspace, "second", "green"),
|
||||
"att-video": _mp4(workspace),
|
||||
"att-heic": _heic(workspace),
|
||||
"att-svg": SVG_DOCUMENT,
|
||||
"att-junk": JUNK,
|
||||
}
|
||||
112
tool/e2e_media.py
Executable file
112
tool/e2e_media.py
Executable file
@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Seed the fake's attachment store, and check what the album ended up storing.
|
||||
|
||||
The samples themselves are built by tool/e2e_fixtures.py; this half moves them
|
||||
in and reads the result back out.
|
||||
|
||||
Two modes:
|
||||
|
||||
--upload <base-url> put the media into the fake's attachment store
|
||||
--verify-pack <dir> assert every stored file came out clean
|
||||
|
||||
The second is the half that matters. It re-reads the album with exiftool and
|
||||
fails on any surviving non-structural tag, which is the same check
|
||||
``media_tags.is_allowed`` makes -- run here against files that went through
|
||||
the whole live path rather than through a unit test's fake stripper.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
from e2e_fixtures import PROBE_CREATOR, PROBE_MAKE, build, run_tool
|
||||
|
||||
from signal_bot.media_tags import is_allowed
|
||||
|
||||
# The suffixes exiftool 13.55 cannot write, and which are therefore stored
|
||||
# exactly as they arrived. Excluded from the verification sweep because they
|
||||
# are *expected* to still carry everything.
|
||||
UNSTRIPPED: Final = frozenset({".svg", ".webm"})
|
||||
|
||||
|
||||
def upload(base_url: str) -> int:
|
||||
"""Put every attachment into the fake container's store."""
|
||||
for attachment_id, payload in build().items():
|
||||
response = httpx.post(
|
||||
f"{base_url}/_control/attachment",
|
||||
json={"id": attachment_id, "base64": base64.b64encode(payload).decode("ascii")},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
sys.stdout.write(f" uploaded {attachment_id} ({len(payload)} bytes)\n")
|
||||
return 0
|
||||
|
||||
|
||||
def _survivors(path: Path) -> list[str]:
|
||||
"""Every non-structural tag one stored file still reports."""
|
||||
report = run_tool(["exiftool", "-G0", "-s", "-all", str(path)])
|
||||
return [name for name, allowed in _tags(report) if not allowed]
|
||||
|
||||
|
||||
def _tags(report: str) -> list[tuple[str, bool]]:
|
||||
"""Each reported tag as "Group:Tag", and whether it may remain."""
|
||||
return [_tag(line) for line in report.splitlines() if line.startswith("[")]
|
||||
|
||||
|
||||
def _tag(line: str) -> tuple[str, bool]:
|
||||
"""One "[Group] Tag : value" line as its name and whether it may remain."""
|
||||
group, _, rest = line[1:].partition("]")
|
||||
tag, _, value = rest.strip().partition(":")
|
||||
return f"{group}:{tag.strip()}", is_allowed(group, tag.strip(), value.strip())
|
||||
|
||||
|
||||
def verify_pack(directory: Path) -> int:
|
||||
"""Fail unless every strippable file in the album came out clean."""
|
||||
album = directory / "media"
|
||||
failures: list[str] = []
|
||||
checked = 0
|
||||
for path in sorted(album.iterdir()):
|
||||
if path.suffix.lower() in UNSTRIPPED:
|
||||
sys.stdout.write(f" {path.name}: stored unstripped by decision, not checked\n")
|
||||
continue
|
||||
raw = path.read_bytes()
|
||||
# The raw byte search is not redundant with the tag sweep: exiftool
|
||||
# reports what it can parse, and a string left in a chunk it does not
|
||||
# model would be invisible to the sweep and present in the file.
|
||||
failures.extend(
|
||||
f"{path.name} still contains {probe!r}"
|
||||
for probe in (PROBE_MAKE.encode(), PROBE_CREATOR.encode())
|
||||
if probe in raw
|
||||
)
|
||||
leaks = _survivors(path)
|
||||
if leaks:
|
||||
failures.append(f"{path.name} still reports {leaks}")
|
||||
checked += 1
|
||||
for line in failures:
|
||||
sys.stderr.write(f"Error: {line}\n")
|
||||
if failures:
|
||||
return 1
|
||||
sys.stdout.write(f" {checked} stored file(s) carry no metadata beyond the container\n")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Generate, upload or verify, depending on the mode asked for."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument("--upload", metavar="BASE_URL", help="seed the fake's attachment store")
|
||||
group.add_argument("--verify-pack", metavar="DIR", type=Path, help="check a written album")
|
||||
arguments = parser.parse_args()
|
||||
if arguments.upload:
|
||||
return upload(arguments.upload)
|
||||
return verify_pack(arguments.verify_pack)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Reference in New Issue
Block a user