mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 18:17:02 +01:00
!dodaj lands in a queue; the management page approves it into the album
A photo sent to the chat no longer enters the album by itself. pack_add strips it, checks it against the album and the queue, and writes it to pending/ with a sidecar naming the people and labels the sender proposed; the reply is "czeka na zatwierdzenie". The management page lists the queue above the tagger, with the proposal pre-ticked, and Approve moves the file in, mints the labels, records approved_at and asks the bot to rebuild its table through /_reload -- the same publish path every other data change takes, so PackHolder.reload is gone. A reload the bot refuses is reported on the page, not rolled back: the photo is in the album either way. The personal-data gate now reads pending/*.json too, so a label that is still waiting for approval is forbidden in the tree already. The e2e run boots the management page beside the bot with the same environment, proves the queue is stripped and never reaches media/, approves it through the real routes, and has !losowe answer with the photo afterwards without a restart; exiftool is found through media_strip.locate() there rather than PATH, which lacks vendor_perl on Arch. !gadaj is dropped from !odcisz: it was within a typo of !dodaj, and the nearest-command suggestion must never be a coin toss. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
77795ee6f8
commit
da016e1b94
@ -1,24 +1,48 @@
|
||||
# Adding a photo from the chat: `!add_photo`
|
||||
|
||||
> **Unwired since 2026-09-04.** `!dodaj` is not a command until photos land
|
||||
> in an approval queue instead of straight in the album. Everything below
|
||||
> describes the ingest path as it was wired, and as the queue will reuse it.
|
||||
How a member of an album's group proposes a photo for it by sending one,
|
||||
how the operator approves it, and what keeps that path from leaking anything.
|
||||
The album itself — where it lives, what its manifests look like, how several
|
||||
albums coexist — is `DOCS-photo-pack.md`.
|
||||
|
||||
How a member of an album's group puts a photo into it by sending one, and
|
||||
what keeps that path from leaking anything. The album itself — where it
|
||||
lives, what its manifests look like, how several albums coexist — is
|
||||
`DOCS-photo-pack.md`.
|
||||
Send a photo captioned `!dodaj alfa wakacje2019` in one of the album's groups.
|
||||
`alfa` resolves to that person's key; `wakacje2019` matches nobody and travels
|
||||
as a *proposed* label. The photo is stripped and lands in the album's
|
||||
`pending/`, and the reply is `czeka na zatwierdzenie: 1`. It is **not in the
|
||||
album yet**: `!alfa` and `!losowe wakacje2019` cannot find it until somebody
|
||||
approves it.
|
||||
|
||||
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.
|
||||
### Approval: the management page, and nothing else
|
||||
|
||||
`./web.sh` shows a **Waiting for approval** section above the tagger whenever
|
||||
`pending/` is not empty (`web/src/views/pending.tsx`). Each queued photo is a
|
||||
card with the album's people as checkboxes — the ones the sender named are
|
||||
pre-ticked — and one checkbox per proposed label. **Approve** moves the file
|
||||
into `media/`, writes `tags.json`, mints any ticked label in `custom.json`,
|
||||
records the sender in `adds.json` (with `approved_at`), and asks the bot for
|
||||
`POST /_reload`. From that moment `!losowe wakacje2019` answers with it, with
|
||||
no restart. **Reject** deletes the file and its sidecar; nothing else to undo,
|
||||
because nothing else was written.
|
||||
|
||||
The queue is the only door in. The bot's own process never approves anything;
|
||||
it writes `pending/<content-hash>.<ext>` plus a `.json` sidecar (`keys`,
|
||||
`labels`, `by`, `at`, `group`) and reads the result back at the reload. The
|
||||
page can only approve a label the sender actually proposed (anything else is
|
||||
`400`), and it can keep fewer — an unticked label is simply not minted. If the
|
||||
bot then refuses the reload (a stale alias, say) the page **reports** it and
|
||||
does not roll back: the photo is in the album with the tags the approver
|
||||
chose, and the bot's reason is about something else.
|
||||
|
||||
What holds:
|
||||
|
||||
- **No typed label travels.** Labels become keys in the registry's
|
||||
`arg_resolver`, at the dispatcher, *before* the line that logs the command
|
||||
name. The reply is counts and pseudonyms — `dodano · p1: 4 · t1: 1`.
|
||||
name; a new word becomes a per-message token (`new1`, `new2`) and is claimed
|
||||
back by its own handler, so concurrent frames cannot swap labels. The reply
|
||||
is counts only — `czeka na zatwierdzenie: 2 · 1 już było` — and the proposed
|
||||
word exists in exactly one place, the sidecar inside the album's `0700`.
|
||||
`scripts/check_personal_data.sh` reads those sidecars too, so a proposed
|
||||
name is on the forbidden list before it is ever approved.
|
||||
- **No cap.** No cooldown, no size limit, no daily limit. `!random` takes the
|
||||
ordinary pack cooldown; `!add_photo` takes no decorator at all.
|
||||
- **At most six labels**, the same ceiling `!alfa beta …` has.
|
||||
@ -26,15 +50,19 @@ What holds:
|
||||
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.
|
||||
photo already in the album is `już było`, one already waiting is `już
|
||||
czeka` — the same photo twice in one message counts as waiting too — and a
|
||||
mixed batch queues the new files and says how many were which.
|
||||
- **Approval is whole or not at all.** Media, `tags.json`, `custom.json` and
|
||||
`adds.json` are written, the album is loaded again, and only a load that
|
||||
succeeds keeps them. A load that fails rolls all four back and leaves the
|
||||
photo in the queue with a `400` naming the reason.
|
||||
- **The second copy goes.** signal-cli keeps its own copy of every inbound
|
||||
attachment outside the album's `0700`. `!add_photo` deletes it once the file
|
||||
is stored, and also when the upload was refused.
|
||||
is queued, and also when the upload was refused.
|
||||
- **Stripping happens before the wait.** The file in `pending/` is already
|
||||
the stripped one; `scripts/e2e.sh` re-reads the queue with exiftool before
|
||||
approving anything, and the album after.
|
||||
|
||||
### Stripping: exiftool, with a positive check
|
||||
|
||||
|
||||
@ -31,12 +31,13 @@ This file is the trigger reference — every spelling that resolves, which
|
||||
| command_stats | `!analityka` `!command_stats` `!commandstats` `!analytics` `!uzycie` `!użycie` `!ranking` |
|
||||
| help | `!pomoc` `!pomocy` `!help` `!komendy` `!commands` |
|
||||
| random | `!losowe` `!losuj` `!random` `!rand` |
|
||||
| add_photo | `!dodaj` `!dodaj_zdjecie` `!dodajzdjecie` `!add_photo` `!addphoto` |
|
||||
| suggest | `!zaproponuj` `!propozycja` `!sugestia` `!suggest` `!pomysl` |
|
||||
| good_bot | `!good bot` `!dobry bot` `!goodbot` `!dobrybot` `!good_bot` `!dobry_bot` |
|
||||
| food_today | `!co_jadł` `!co_jadl` `!cojadł` `!cojadl` `!jedzenie` `!dieta` + the operator's own in `aliases_custom.json` |
|
||||
| workout_today | `!co_ćwiczył` `!co_cwiczyl` `!coćwiczył` `!cocwiczyl` `!trening` `!workout` + the operator's own in `aliases_custom.json` |
|
||||
| mute | `!cisza` `!cicho` `!mute` |
|
||||
| unmute | `!odcisz` `!gadaj` `!wracaj` |
|
||||
| unmute | `!odcisz` `!wracaj` |
|
||||
|
||||
Aliases live in `signal_bot/aliases.json`, not in source, because they are
|
||||
data: adding one should never mean touching Python. Two constraints on a new
|
||||
@ -116,10 +117,12 @@ for `TRUSTED_INVITERS`; anyone else is refused without a reply, because a
|
||||
refusal is a reply. With `TRUSTED_INVITERS` unset, anybody may. There is no
|
||||
`!unmute` alias: it sits one edit from `!mute`, inside the near-miss budget.
|
||||
|
||||
`add_photo` (`!dodaj`) is **unwired since 2026-09-04**: a photo posted to a
|
||||
group must not reach the album without somebody approving it, and no approval
|
||||
queue exists yet. It comes back when one does; `DOCS-add-photo.md` describes
|
||||
the path as it was.
|
||||
`add_photo` (`!dodaj alfa wakacje` + an attachment) exists in the same groups
|
||||
as `random` and under the same silence elsewhere. It does **not** put the
|
||||
photo in the album: it strips it and queues it for approval on the management
|
||||
page (`./web.sh`), and answers with a count — `czeka na zatwierdzenie: 1`.
|
||||
Only an approved photo is retrievable, and it is from the moment it is
|
||||
approved. No cooldown, no size cap, no daily limit. `DOCS-add-photo.md`.
|
||||
|
||||
Any command can be **switched off** from the management page (Commands tab),
|
||||
outright or until a moment, and given its own cooldown. The bot reads the
|
||||
|
||||
@ -15,18 +15,18 @@ gamma — so the mechanism can be public while the album stays private.
|
||||
~/.local/share/signal-bot/pack/
|
||||
people.json # the commands, and the only place names exist
|
||||
tags.json # which file shows whom
|
||||
custom.json # labels !add_photo minted (optional)
|
||||
custom.json # labels approved from !add_photo (optional)
|
||||
adds.json # who added what, read by nothing (optional)
|
||||
media/ # the photos and videos themselves
|
||||
pending/ # !add_photo sends waiting for approval (optional)
|
||||
```
|
||||
|
||||
Found in this order: `PHOTO_PACK_DIR`, then `$STATE_DIRECTORY/pack` (what
|
||||
systemd hands a **system** unit), then `$XDG_DATA_HOME/signal-bot/pack`.
|
||||
`StateDirectory=` is deliberately **not** in the unit this repo installs:
|
||||
for a *user* unit systemd resolves it below `$XDG_STATE_HOME`, which is not
|
||||
where the album is. Discovery finds the same directory, and leaving
|
||||
`PHOTO_PACK_DIR` unset is what keeps a not-yet-filled-in pack from turning
|
||||
`Restart=always` into a restart loop.
|
||||
`StateDirectory=` is deliberately **not** in the unit this repo installs: for
|
||||
a *user* unit systemd resolves it below `$XDG_STATE_HOME`, which is not where
|
||||
the album is. Discovery finds the same directory, and leaving `PHOTO_PACK_DIR`
|
||||
unset keeps a not-yet-filled-in pack from turning `Restart=always` into a loop.
|
||||
|
||||
Two absences, deliberately different:
|
||||
|
||||
@ -130,14 +130,14 @@ startup rather than shadowing the command it collides with.
|
||||
## adds.json
|
||||
|
||||
```json
|
||||
{"a3ae52c7….jpg": {"by": "<uuid>", "at": 1756600000}}
|
||||
{"a3ae52c7….jpg": {"by": "<uuid>", "at": 1756600000, "approved_at": 1756700000}}
|
||||
```
|
||||
|
||||
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.
|
||||
Who added which file, and when it was approved. **Nothing reads it** —
|
||||
`pack_load` ignores it — and it exists only so `!undo_photo` can be built
|
||||
later without a gap in its history; the adder is recorded at add time or not
|
||||
at all. It lives inside the album (`0700`, outside this repository), so it is
|
||||
handled exactly like the photos.
|
||||
|
||||
## sources.json
|
||||
|
||||
@ -169,11 +169,14 @@ at startup would be a restart loop under `Restart=always`.
|
||||
|
||||
## Adding from the chat: !add_photo
|
||||
|
||||
A member of one of the album's groups can send a photo captioned
|
||||
`!add_photo alfa wakacje` and have it land in that album, tagged, stripped
|
||||
and retrievable immediately. That path has its own document, because the
|
||||
metadata handling and the privacy invariants it has to keep are longer than
|
||||
the album format itself: **`DOCS-add-photo.md`**.
|
||||
A member of one of the album's groups sends a photo captioned
|
||||
`!add_photo alfa wakacje`. It is stripped and lands in **`pending/`** — under
|
||||
its content hash, beside a `<file>.json` sidecar with the proposed `keys`,
|
||||
`labels`, sender and group — until the operator approves it on `./web.sh`,
|
||||
which is when it is tagged, moved into `media/` and retrievable. `pack_load`
|
||||
never reads `pending/`. The path has its own document, because its metadata
|
||||
handling and privacy invariants are longer than the album format itself:
|
||||
**`DOCS-add-photo.md`**.
|
||||
|
||||
## Filling it in: ./web.sh
|
||||
|
||||
@ -242,5 +245,6 @@ 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. It also drives `!add_photo` against the real exiftool, on media it
|
||||
generates at run time, so no photo enters this repository.
|
||||
sends. It also drives `!dodaj` against the real exiftool on media generated at
|
||||
run time (no photo enters this repository), approves the queue through the
|
||||
management page it boots beside the bot, and has `!losowe` answer, no restart.
|
||||
|
||||
@ -77,13 +77,12 @@ account's number and is edited from a tab that can be closed mid-save.
|
||||
|
||||
### Group settings need a group listing
|
||||
|
||||
`GROUP_IDS` and `SELFTEST_GROUPS` are editable, but only while `/api/groups`
|
||||
has actually returned something. `parse_group_ids` checks *syntax*; startup
|
||||
additionally requires each listed group to be one the bot really joined,
|
||||
judged against a listing that was really read. So with the container down, a
|
||||
syntactically valid write can name a group the next startup rejects — turning
|
||||
a scoping mistake into a unit that will not start. The page says so and leaves
|
||||
the other keys editable.
|
||||
`GROUP_IDS` and `SELFTEST_GROUPS` are editable only while `/api/groups` has
|
||||
actually returned something. `parse_group_ids` checks *syntax*; startup also
|
||||
requires each listed group to be one the bot really joined, judged against a
|
||||
listing that was really read. With the container down, a syntactically valid
|
||||
write can name a group the next startup rejects — a scoping mistake turned
|
||||
into a unit that will not start. The page says so and leaves the rest editable.
|
||||
|
||||
Group ids are **picked, never typed** — `GROUP_IDS` and `SELFTEST_GROUPS`
|
||||
render as a `<select>` filled from `/api/groups`, for the same reason the
|
||||
@ -95,20 +94,21 @@ tagger picked them: an id is 44 characters of base64.
|
||||
|
||||
## The album, in two halves
|
||||
|
||||
Which half you get is the album's own `configured` flag, exactly as the
|
||||
tagger decided it.
|
||||
Which half you get is the album's own `configured` flag, as the tagger decided.
|
||||
|
||||
- **No `people.json` yet** → the roster form. One person per line, aliases
|
||||
separated by commas; a line with an `=` is a combination, `+` for "both of
|
||||
them" and `|` for "either of them". Keys are reused for anyone already on
|
||||
the roster and otherwise handed out one past the highest in use — never the
|
||||
count, because a key deleted by hand must not be reissued and inherit
|
||||
somebody else's photos. That mirrors `pack_people.next_key`, which is the
|
||||
authority. Names are typed into the page and never passed as arguments: an
|
||||
argument lands in the shell history.
|
||||
count, or a key deleted by hand would be reissued and inherit somebody
|
||||
else's photos (`pack_people.next_key` is the authority). Names are typed
|
||||
into the page, never passed as arguments: an argument lands in shell history.
|
||||
- **Configured** → the tagger: the item, a checkbox per person, saved on the
|
||||
click rather than on a "done" button, and an "add somebody" box for the
|
||||
usual case of noticing a new face in the photo already on screen.
|
||||
usual case of noticing a new face in the photo already on screen. Above it,
|
||||
only while something is in it, the `!dodaj` queue: one card per photo with
|
||||
the sender's people and labels pre-ticked, **Approve** or **Reject** — the
|
||||
only door into the album from the chat (`DOCS-add-photo.md`).
|
||||
|
||||
`--untagged` is a *view filter*, never a shorter list — `/api/album/media` and
|
||||
the tag route both index the full album, so filtering server-side would show
|
||||
@ -171,16 +171,15 @@ The fourth tab lists what the group has asked for: `!zaproponuj` notes with
|
||||
the text people wrote, and every unrecognised `!word` with a count and the
|
||||
messages it came in.
|
||||
|
||||
The **Export** button writes that to a file, and the file goes to the state
|
||||
directory — `$STATE_DIRECTORY`, else `$XDG_DATA_HOME/signal-bot/` — never
|
||||
into this repository. Two reasons, and both are about a public git tree that
|
||||
has leaked real names once. A file the group can write into would wedge
|
||||
`check_personal_data` the moment somebody's name landed in it, refusing every
|
||||
future commit until a human edited it by hand; and the gate learns the names
|
||||
it refuses from the live database, so it catches `anna` and cannot possibly
|
||||
catch `!annna`, which is exactly what an unrecognised-command corpus is full
|
||||
of. The export therefore carries deliberate notes in full and unrecognised
|
||||
words as counts only. Copy what is worth keeping into the tree yourself.
|
||||
The **Export** button writes that to the state directory — `$STATE_DIRECTORY`,
|
||||
else `$XDG_DATA_HOME/signal-bot/` — never into this repository. Two reasons,
|
||||
both about a public git tree that has leaked real names once: a file the group
|
||||
can write into would wedge `check_personal_data` the moment somebody's name
|
||||
landed in it, refusing every commit until a human edited it by hand; and the
|
||||
gate learns the names it refuses from the live database, so it catches `anna`
|
||||
and cannot catch `!annna`, which is what an unrecognised-command corpus is full
|
||||
of. So the export carries deliberate notes in full and unrecognised words as
|
||||
counts only. Copy what is worth keeping into the tree yourself.
|
||||
|
||||
## Routes
|
||||
|
||||
@ -201,6 +200,8 @@ other path, so a reload on a sub-view works.
|
||||
| POST | `/api/album/setup` | Write the roster, then prove the bot could load it. |
|
||||
| POST | `/api/album/person` | Add one person mid-session. |
|
||||
| GET | `/api/album/media/{index}` | One item, **by position**. |
|
||||
| GET | `/api/album/pending`, `…/pending/{name}/media` | The `!dodaj` queue, oldest first and naming no sender; one queued file, by the name the listing gave. |
|
||||
| POST | `/api/album/pending/{name}/{approve,reject}` | Into the album with the ticked people and labels, then the bot reloads (a refused reload is reported, not rolled back); or deleted. |
|
||||
| GET | `/api/suggestions` | Filed ideas and unrecognised-word tallies, bodies included. |
|
||||
| GET | `/api/commands` | Proxies the bot's `/_commands`: the live table with each command's switches. |
|
||||
| PUT | `/api/commands/{name}` | One command's switches, forwarded to the bot; its 404/503 come back unwrapped. |
|
||||
@ -237,12 +238,11 @@ on all four metrics. TypeScript stays on 6.0.3 because
|
||||
fleet-wide in `~/utils/dependency-freshness.allowlist.yaml` and clears itself
|
||||
when the linter catches up. The compiler waits for the linter.
|
||||
|
||||
`web/tsconfig.json` is strict JSON rather than the usual JSONC: this
|
||||
repository's `check-json` hook is generic and tsconfig being commentable is a
|
||||
convention, not a requirement. The two things the comments used to say: `lib`
|
||||
is `ESNext` while `target` stays `ES2023`, so iterator helpers are declared
|
||||
without being emitted for; and every strictness flag is on, including
|
||||
`noUncheckedIndexedAccess` and `exactOptionalPropertyTypes`.
|
||||
`web/tsconfig.json` is strict JSON, not JSONC: the `check-json` hook is
|
||||
generic and a commentable tsconfig is a convention, not a requirement. What
|
||||
the comments used to say: `lib` is `ESNext` while `target` stays `ES2023`, so
|
||||
iterator helpers are declared without being emitted for; and every strictness
|
||||
flag is on, `noUncheckedIndexedAccess` and `exactOptionalPropertyTypes` included.
|
||||
|
||||
There is **no coverage exclude list**, including for `src/main.tsx`. A
|
||||
coverage omit is a suppression, and "the DOM bootstrap is awkward to assert"
|
||||
|
||||
9
fake_signal/approved/dodaj_again_is_a_duplicate.yaml
Normal file
9
fake_signal/approved/dodaj_again_is_a_duplicate.yaml
Normal file
@ -0,0 +1,9 @@
|
||||
# Content-addressed: the same bytes sent a second time, after approval, are
|
||||
# recognised as already in the album -- the queue is not a way to store a
|
||||
# photo twice. tool/e2e_media.py re-seeds the fake's store first, because
|
||||
# the bot deleted the container's copy the first time.
|
||||
name: "the same photo sent again after approval is already in the album"
|
||||
inject:
|
||||
- { text: "!dodaj wakacje", uuid: uuid-approved-again, name: ApprovedAgain, attachment: att-custom }
|
||||
expect:
|
||||
- { kind: text, to: reply, contains: "JUŻ JEST W ALBUMIE" }
|
||||
@ -0,0 +1,9 @@
|
||||
# The second half of scenarios/pack_add_custom.yaml. Replayed only after
|
||||
# scripts/e2e.sh has approved the queue through the management page and the
|
||||
# bot has rebuilt its table: the word that was NIE MAM TAKIEGO ZDJĘCIA a
|
||||
# moment ago now opens a photo, with no restart in between.
|
||||
name: "!losowe finds a label the moment it is approved"
|
||||
inject:
|
||||
- { text: "!losowe wakacje", uuid: uuid-approved-random, name: ApprovedRandom }
|
||||
expect:
|
||||
- { kind: image, to: reply }
|
||||
12
fake_signal/scenarios/pack_add_custom.yaml
Normal file
12
fake_signal/scenarios/pack_add_custom.yaml
Normal file
@ -0,0 +1,12 @@
|
||||
# A label matching nobody is a proposal, not a tag: the photo waits in the
|
||||
# queue and !losowe cannot find the word until somebody approves it on the
|
||||
# management page. fake_signal/approved/ replays the other half of this,
|
||||
# after scripts/e2e.sh has approved the queue. The typed label must appear
|
||||
# in neither reply.
|
||||
name: "!dodaj with a new label queues the photo, and !losowe cannot see it yet"
|
||||
inject:
|
||||
- { text: "!dodaj wakacje", uuid: uuid-add-custom, name: AddCustom, attachment: att-custom }
|
||||
- { text: "!losowe wakacje", uuid: uuid-add-custom-b, name: AddCustomB }
|
||||
expect:
|
||||
- { kind: text, to: reply, contains: "czeka na zatwierdzenie: 1" }
|
||||
- { kind: text, to: reply, contains: "NIE MAM TAKIEGO ZDJĘCIA" }
|
||||
@ -1,13 +0,0 @@
|
||||
# Adding a photo from the chat is switched off until an approval queue
|
||||
# exists (2026-09-04): a photo somebody posts must not reach the album
|
||||
# unreviewed. Unwired rather than gated, so !dodaj is an unknown word like
|
||||
# any other -- answered, and never storing anything. The eight scenarios
|
||||
# that proved the ingest path are at 64852ae, for the queue to bring back.
|
||||
name: "!dodaj is not a command while photos cannot be approved"
|
||||
inject:
|
||||
- {
|
||||
text: "!dodaj alfa", uuid: uuid-add-off, name: AddOff,
|
||||
attachment: att-off,
|
||||
}
|
||||
expect:
|
||||
- { kind: text, to: reply, contains: "Nie znam !dodaj" }
|
||||
14
fake_signal/scenarios/pack_add_heic.yaml
Normal file
14
fake_signal/scenarios/pack_add_heic.yaml
Normal file
@ -0,0 +1,14 @@
|
||||
# The iPhone default. Its HEVC parameter set survives `-all=` and is on the
|
||||
# structural allowlist by kuhy's decision of 2026-08-31; the GPS and the Make
|
||||
# do not survive, which is what e2e.sh re-reads the queued file to prove.
|
||||
name: "a geotagged heic is accepted and waits without its GPS"
|
||||
inject:
|
||||
- {
|
||||
text: "!dodaj gamma", uuid: uuid-add-heic, name: AddHeic,
|
||||
attachment: att-heic, attachment_type: "image/heic",
|
||||
}
|
||||
# Tagged to gamma rather than alfa on purpose: pack_command.yaml asserts
|
||||
# !alfa returns an image, and the pick is random -- putting a clip in p1
|
||||
# would make that scenario fail about half the time once approved.
|
||||
expect:
|
||||
- { kind: text, to: reply, contains: "czeka na zatwierdzenie" }
|
||||
9
fake_signal/scenarios/pack_add_no_attachment.yaml
Normal file
9
fake_signal/scenarios/pack_add_no_attachment.yaml
Normal file
@ -0,0 +1,9 @@
|
||||
# The proposed label is held in memory and reaches neither custom.json nor
|
||||
# the queue: this is the case that must leave the album byte-identical.
|
||||
# scripts/e2e.sh checks the files themselves, because a reply cannot prove
|
||||
# what was not written.
|
||||
name: "!dodaj with no attachment changes nothing on disk"
|
||||
inject:
|
||||
- { text: "!dodaj piesek", uuid: uuid-add-none, name: AddNone }
|
||||
expect:
|
||||
- { kind: text, to: reply, contains: "DOŁĄCZ ZDJĘCIE" }
|
||||
10
fake_signal/scenarios/pack_add_person.yaml
Normal file
10
fake_signal/scenarios/pack_add_person.yaml
Normal file
@ -0,0 +1,10 @@
|
||||
# The feature in the shape the group will actually use it: a photo captioned
|
||||
# with somebody's name. It is queued, not stored -- the attachment carries a
|
||||
# real APP1 Exif segment (tool/e2e_media.py puts GPS, a Make and an XMP
|
||||
# creator in it), and scripts/e2e.sh re-reads the queued file to prove the
|
||||
# strip happened before the file waited anywhere. The reply is a count.
|
||||
name: "!dodaj with an existing person queues the photo for approval"
|
||||
inject:
|
||||
- { text: "!dodaj alfa", uuid: uuid-add-person, name: AddPerson, attachment: att-person }
|
||||
expect:
|
||||
- { kind: text, to: reply, contains: "czeka na zatwierdzenie: 1" }
|
||||
10
fake_signal/scenarios/pack_add_rejected.yaml
Normal file
10
fake_signal/scenarios/pack_add_rejected.yaml
Normal file
@ -0,0 +1,10 @@
|
||||
# Sniffed on the bytes, never on the filename or the stated contentType --
|
||||
# this one claims to be a photo and is a text file.
|
||||
name: "a file that is not media is refused and never queued"
|
||||
inject:
|
||||
- {
|
||||
text: "!dodaj alfa", uuid: uuid-add-junk, name: AddJunk,
|
||||
attachment: att-junk, attachment_type: "image/jpeg",
|
||||
}
|
||||
expect:
|
||||
- { kind: text, to: reply, contains: "NIE PRZYJMUJĘ" }
|
||||
10
fake_signal/scenarios/pack_add_scoping.yaml
Normal file
10
fake_signal/scenarios/pack_add_scoping.yaml
Normal file
@ -0,0 +1,10 @@
|
||||
# The privacy guarantee, for the write path this time. In a group the album
|
||||
# was not given to, !dodaj is not a command at all: nothing is queued, nothing
|
||||
# is answered, and nothing names the album in a group that does not have one.
|
||||
name: "!dodaj does not exist in a group the album was not given to"
|
||||
inject:
|
||||
- {
|
||||
text: "!dodaj alfa", group: second, uuid: uuid-add-scope, name: AddScope,
|
||||
attachment: att-second,
|
||||
}
|
||||
expect: []
|
||||
12
fake_signal/scenarios/pack_add_svg.yaml
Normal file
12
fake_signal/scenarios/pack_add_svg.yaml
Normal file
@ -0,0 +1,12 @@
|
||||
# kuhy's decision of 2026-08-31: exiftool 13.55 can write neither .svg nor
|
||||
# .webm, and both stay on the allowlist and are queued exactly as they
|
||||
# arrive -- metadata included. This scenario exists so that hole is
|
||||
# demonstrated by the suite rather than only described in the docs.
|
||||
name: "an svg is accepted and queued unstripped, by decision"
|
||||
inject:
|
||||
- {
|
||||
text: "!dodaj alfa", uuid: uuid-add-svg, name: AddSvg,
|
||||
attachment: att-svg, attachment_type: "image/svg+xml",
|
||||
}
|
||||
expect:
|
||||
- { kind: text, to: reply, contains: "czeka na zatwierdzenie" }
|
||||
15
fake_signal/scenarios/pack_add_video.yaml
Normal file
15
fake_signal/scenarios/pack_add_video.yaml
Normal file
@ -0,0 +1,15 @@
|
||||
# An mp4 geotagged the way a phone does it: a QuickTime udta atom, which a
|
||||
# naive "-GPS:all" gate passes straight through because exiftool files it
|
||||
# under QuickTime and not under GPS. It is accepted, and scripts/e2e.sh then
|
||||
# re-reads the queued file and fails the run if the atom survived.
|
||||
name: "a geotagged mp4 is stripped of its QuickTime atom and queued"
|
||||
inject:
|
||||
- {
|
||||
text: "!dodaj gamma", uuid: uuid-add-video, name: AddVideo,
|
||||
attachment: att-video, attachment_type: "video/mp4",
|
||||
}
|
||||
# Tagged to gamma rather than alfa on purpose: pack_command.yaml asserts
|
||||
# !alfa returns an image, and the pick is random -- putting a clip in p1
|
||||
# would make that scenario fail about half the time once approved.
|
||||
expect:
|
||||
- { kind: text, to: reply, contains: "czeka na zatwierdzenie" }
|
||||
@ -25,7 +25,9 @@ readonly REPO_ROOT
|
||||
readonly PYTHON="${PYTHON:-$REPO_ROOT/.venv/bin/python}"
|
||||
readonly FAKE_PORT="${FAKE_PORT:-8099}"
|
||||
readonly BOT_PORT="${BOT_PORT:-8098}"
|
||||
readonly MANAGE_PORT="${MANAGE_PORT:-8097}"
|
||||
readonly FAKE_URL="http://127.0.0.1:${FAKE_PORT}"
|
||||
readonly MANAGE_URL="http://127.0.0.1:${MANAGE_PORT}"
|
||||
|
||||
# Both spellings of one group, exactly as the real API reports them:
|
||||
# /v1/groups returns the `group.<base64>` form (what .env holds), while an
|
||||
@ -47,6 +49,7 @@ readonly OTHER_GROUP="group.e2e.other=="
|
||||
|
||||
FAKE_PID=""
|
||||
BOT_PID=""
|
||||
MANAGE_PID=""
|
||||
TEMP_DIR=""
|
||||
|
||||
cleanup() {
|
||||
@ -56,6 +59,9 @@ cleanup() {
|
||||
if [[ -n "$BOT_PID" ]]; then
|
||||
kill "$BOT_PID" 2>/dev/null || true
|
||||
fi
|
||||
if [[ -n "$MANAGE_PID" ]]; then
|
||||
kill "$MANAGE_PID" 2>/dev/null || true
|
||||
fi
|
||||
if [[ -n "$FAKE_PID" ]]; then
|
||||
kill "$FAKE_PID" 2>/dev/null || true
|
||||
fi
|
||||
@ -113,6 +119,40 @@ replay() {
|
||||
--group "other=${OTHER_GROUP}"
|
||||
}
|
||||
|
||||
# The bot and the management page under one environment: the page approves
|
||||
# into the album the bot reads, and asks the bot at BIND_PORT to rebuild its
|
||||
# table -- a path that differed between them would prove nothing.
|
||||
# GROUP_IDS and TRUSTED_INVITERS deliberately empty: the bot discovers its
|
||||
# groups from the account, which is the configuration real deployments use.
|
||||
# Set rather than merely unset, because load_dotenv() would otherwise let
|
||||
# the developer's own .env decide what this run watches -- and a
|
||||
# TRUSTED_INVITERS there would refuse every fixture group. The gate itself
|
||||
# is covered in tests/test_trust.py.
|
||||
# exec, because this is backgrounded: without it $! is a subshell, and the
|
||||
# kill in cleanup would leave the real process running after the run.
|
||||
with_bot_env() {
|
||||
exec env -u CAT_API \
|
||||
PHONE_NUMBER="+15550000000" \
|
||||
GROUP_IDS="" \
|
||||
TRUSTED_INVITERS="" \
|
||||
API_BASE_URL="$FAKE_URL" \
|
||||
DATABASE_PATH="${TEMP_DIR}/e2e.sqlite3" \
|
||||
CAT_API_URL="${FAKE_URL}/_fixtures/cat-api" \
|
||||
DOG_API_URL="${FAKE_URL}/_fixtures/dog-api" \
|
||||
GECKO_API_URL="${FAKE_URL}/_fixtures/gecko-api" \
|
||||
COMMONS_API_URL="${FAKE_URL}/_fixtures/commons-api" \
|
||||
POEM_TEXT_URL="${FAKE_URL}/_fixtures/poem-text?slug={slug}" \
|
||||
PORTRAIT_CACHE_DIR="${TEMP_DIR}/portraits" \
|
||||
PHOTO_PACK_DIR="${TEMP_DIR}/pack" \
|
||||
CUSTOM_ALIASES_FILE="${TEMP_DIR}/aliases_custom.json" \
|
||||
FOOD_LOG_FILE="${TEMP_DIR}/logs/food_log.json" \
|
||||
WORKOUT_LOG_FILE="${TEMP_DIR}/logs/workout_log.json" \
|
||||
BIND_PORT="$BOT_PORT" \
|
||||
SILENCE_SECONDS=0 \
|
||||
LIVENESS_TICK_SECONDS=1 \
|
||||
"$@"
|
||||
}
|
||||
|
||||
validate_requirements() {
|
||||
if [[ ! -x "$PYTHON" ]]; then
|
||||
echo "Error: no interpreter at $PYTHON. Run: uv venv .venv && uv pip install -e '.[dev]'" >&2
|
||||
@ -141,12 +181,6 @@ main() {
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "[{\"id\":\"${WATCHED_GROUP}\",\"internal_id\":\"${WATCHED_GROUP_INTERNAL}\",\"name\":\"e2e\",\"member\":true,\"joinable\":true},{\"id\":\"${SECOND_GROUP}\",\"internal_id\":\"${SECOND_GROUP_INTERNAL}\",\"name\":\"e2e-second\",\"member\":true,\"joinable\":true}]" >/dev/null
|
||||
|
||||
# GROUP_IDS and TRUSTED_INVITERS deliberately empty: the bot discovers
|
||||
# its groups from the account, which is the configuration real
|
||||
# deployments use. Set rather than merely unset, because load_dotenv()
|
||||
# would otherwise let the developer's own .env decide what this run
|
||||
# watches -- and a TRUSTED_INVITERS there would refuse every fixture
|
||||
# group. The gate itself is covered in tests/test_trust.py.
|
||||
# The album is private and lives outside this repository, so the run
|
||||
# builds a synthetic one: three invented people, a combination and a
|
||||
# video, scoped to the watched group so the scoping scenario can prove
|
||||
@ -156,38 +190,24 @@ main() {
|
||||
# The food and workout logs are the operator's day, kept by other
|
||||
# programs; the run gets one invented entry of each, dated today.
|
||||
"$PYTHON" "$REPO_ROOT/tool/e2e_logs.py" --dir "${TEMP_DIR}/logs"
|
||||
echo "== generating media for !dodaj (real exif, never committed)"
|
||||
"$PYTHON" "$REPO_ROOT/tool/e2e_media.py" --upload "$FAKE_URL"
|
||||
|
||||
echo "== booting the bot on :${BOT_PORT}, pointed at the fake"
|
||||
env -u CAT_API \
|
||||
PHONE_NUMBER="+15550000000" \
|
||||
GROUP_IDS="" \
|
||||
TRUSTED_INVITERS="" \
|
||||
API_BASE_URL="$FAKE_URL" \
|
||||
DATABASE_PATH="${TEMP_DIR}/e2e.sqlite3" \
|
||||
CAT_API_URL="${FAKE_URL}/_fixtures/cat-api" \
|
||||
DOG_API_URL="${FAKE_URL}/_fixtures/dog-api" \
|
||||
GECKO_API_URL="${FAKE_URL}/_fixtures/gecko-api" \
|
||||
COMMONS_API_URL="${FAKE_URL}/_fixtures/commons-api" \
|
||||
POEM_TEXT_URL="${FAKE_URL}/_fixtures/poem-text?slug={slug}" \
|
||||
PORTRAIT_CACHE_DIR="${TEMP_DIR}/portraits" \
|
||||
PHOTO_PACK_DIR="${TEMP_DIR}/pack" \
|
||||
CUSTOM_ALIASES_FILE="${TEMP_DIR}/aliases_custom.json" \
|
||||
FOOD_LOG_FILE="${TEMP_DIR}/logs/food_log.json" \
|
||||
WORKOUT_LOG_FILE="${TEMP_DIR}/logs/workout_log.json" \
|
||||
BIND_PORT="$BOT_PORT" \
|
||||
SILENCE_SECONDS=0 \
|
||||
LIVENESS_TICK_SECONDS=1 \
|
||||
"$PYTHON" -m signal_bot &
|
||||
with_bot_env "$PYTHON" -m signal_bot &
|
||||
BOT_PID=$!
|
||||
echo "== booting the management page on :${MANAGE_PORT}, pointed at the bot"
|
||||
with_bot_env "$PYTHON" "$REPO_ROOT/tool/web.py" \
|
||||
--dir "${TEMP_DIR}/pack" --port "$MANAGE_PORT" --no-open &
|
||||
MANAGE_PID=$!
|
||||
wait_for_url "http://127.0.0.1:${BOT_PORT}/healthz" "bot"
|
||||
wait_for_url "${MANAGE_URL}/api/album/pending" "management page"
|
||||
wait_for_subscription
|
||||
|
||||
echo "== replaying scenarios"
|
||||
replay "$REPO_ROOT"/fake_signal/scenarios/*.yaml
|
||||
|
||||
# !dodaj is unwired until photos land in an approval queue instead of
|
||||
# the album (2026-09-04). Its scenarios, the media generation step and
|
||||
# prove_the_add_photo_invariants left with it; git has them at 64852ae.
|
||||
prove_the_add_photo_queue
|
||||
prove_the_table_reloads_without_a_restart
|
||||
prove_a_switch_is_live_without_a_reload
|
||||
prove_an_alias_is_live_after_a_reload
|
||||
@ -195,6 +215,4 @@ main() {
|
||||
enforce_the_time_budget "$STARTED_AT"
|
||||
}
|
||||
|
||||
# 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.
|
||||
main "$@"
|
||||
|
||||
@ -120,6 +120,73 @@ prove_an_alias_is_live_after_a_reload() {
|
||||
# one regression a green suite never reports. Exceeding it fails the run;
|
||||
# whoever exceeded it decides whether the check earned its seconds or the
|
||||
# budget moves. E2E_BUDGET_SECONDS raises it for a loaded machine.
|
||||
# What the !dodaj replies cannot prove on their own: every queued file was
|
||||
# stripped before it waited anywhere, a typed label reached nothing but the
|
||||
# queue, the refused and out-of-scope sends wrote nothing -- and then the one
|
||||
# door into the album, taken through the management page, after which the
|
||||
# bot answers with the photo and recognises a second copy of it.
|
||||
prove_the_add_photo_queue() {
|
||||
local pack="${TEMP_DIR}/pack"
|
||||
|
||||
echo "== checking every queued file was stripped before it waited"
|
||||
"$PYTHON" "$REPO_ROOT/tool/e2e_media.py" --verify-pack "$pack"
|
||||
|
||||
echo "== checking a typed label reached nothing but the queue"
|
||||
if [[ -e "${pack}/custom.json" ]]; then
|
||||
echo "Error: custom.json was written before anything was approved" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -rq "piesek" "$pack"; then
|
||||
echo "Error: a label survived a message that carried no attachment" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== checking exactly the accepted sends are waiting"
|
||||
local queued
|
||||
queued="$(find "${pack}/pending" -type f ! -name '*.json' | wc -l)"
|
||||
if [[ "$queued" != "5" ]]; then
|
||||
echo "Error: expected 5 queued files (person, custom, heic, svg, video), found ${queued}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== checking signal-cli's own copies were deleted, and only those"
|
||||
local left
|
||||
left="$(curl -sf "${FAKE_URL}/_control/attachments")"
|
||||
for taken in att-person att-custom att-heic att-svg att-video att-junk; do
|
||||
if [[ "$left" == *"$taken"* ]]; then
|
||||
echo "Error: ${taken} is still in the container store: ${left}" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
# Out of scope means never fetched: the second group's file is untouched.
|
||||
if [[ "$left" != *"att-second"* ]]; then
|
||||
echo "Error: a send in a group without the album was fetched: ${left}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== approving the queue through the management page"
|
||||
"$PYTHON" "$REPO_ROOT/tool/e2e_approve.py" --manage-url "$MANAGE_URL"
|
||||
|
||||
echo "== checking the album took them whole, and the queue is empty"
|
||||
"$PYTHON" "$REPO_ROOT/tool/e2e_media.py" --verify-pack "$pack"
|
||||
if [[ -n "$(ls -A "${pack}/pending")" ]]; then
|
||||
echo "Error: the queue is not empty after approving everything" >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -qE '"(wakacje|piesek)"[[:space:]]*:' "${pack}/custom.json"; then
|
||||
echo "Error: a typed label became a key in custom.json" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q '"approved_at"' "${pack}/adds.json"; then
|
||||
echo "Error: adds.json does not record the approval" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== replaying what only an approved photo can answer"
|
||||
"$PYTHON" "$REPO_ROOT/tool/e2e_media.py" --upload "$FAKE_URL" >/dev/null
|
||||
replay "$REPO_ROOT"/fake_signal/approved/*.yaml
|
||||
}
|
||||
|
||||
enforce_the_time_budget() {
|
||||
local started="$1"
|
||||
local budget="${E2E_BUDGET_SECONDS:-40}"
|
||||
|
||||
@ -46,18 +46,25 @@ 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.
|
||||
"""Every label !add_photo minted or proposed -- names too, however they got there.
|
||||
|
||||
Read separately from _pack_names because they are a different kind of
|
||||
thing. A people.json trigger is curated by the tagger; a custom label is
|
||||
free text somebody typed into a group chat, and treating arbitrary free
|
||||
text as a forbidden literal is what lets one message wedge this gate
|
||||
permanently. See _repo_vocabulary for the half that makes that safe.
|
||||
|
||||
The approval queue's sidecars (``pending/*.json``) count too: a label
|
||||
waiting there is the same typed text, one click earlier.
|
||||
"""
|
||||
labels: set[str] = set()
|
||||
for entry in _pack_document(directory / "custom.json").values():
|
||||
if isinstance(entry, list):
|
||||
labels |= {str(label).lstrip("!").strip() for label in entry}
|
||||
for sidecar in sorted((directory / "pending").glob("*.json")):
|
||||
proposed = _pack_document(sidecar).get("labels")
|
||||
if isinstance(proposed, list):
|
||||
labels |= {str(label).lstrip("!").strip() for label in proposed}
|
||||
return labels
|
||||
|
||||
|
||||
|
||||
@ -145,6 +145,13 @@
|
||||
"!komendy",
|
||||
"!commands"
|
||||
],
|
||||
"add_photo": [
|
||||
"!dodaj",
|
||||
"!dodaj_zdjecie",
|
||||
"!dodajzdjecie",
|
||||
"!add_photo",
|
||||
"!addphoto"
|
||||
],
|
||||
"random": [
|
||||
"!losowe",
|
||||
"!losuj",
|
||||
@ -189,7 +196,6 @@
|
||||
],
|
||||
"unmute": [
|
||||
"!odcisz",
|
||||
"!gadaj",
|
||||
"!wracaj"
|
||||
]
|
||||
}
|
||||
|
||||
@ -27,6 +27,7 @@ from signal_bot.manage import (
|
||||
routes_analytics,
|
||||
routes_commands,
|
||||
routes_env,
|
||||
routes_pending,
|
||||
routes_status,
|
||||
routes_suggestions,
|
||||
)
|
||||
@ -57,5 +58,6 @@ def build_app(deps: ManageDeps, *, container: str = DEFAULT_CONTAINER) -> FastAP
|
||||
app.include_router(routes_analytics.router(deps))
|
||||
app.include_router(routes_commands.router(deps))
|
||||
app.include_router(routes_aliases.router(deps))
|
||||
app.include_router(routes_pending.router(deps))
|
||||
assets.install(app, deps.dist_dir)
|
||||
return app
|
||||
|
||||
41
signal_bot/manage/bot_reload.py
Normal file
41
signal_bot/manage/bot_reload.py
Normal file
@ -0,0 +1,41 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Asking the running bot to rebuild its command table.
|
||||
|
||||
One function, because two routers need it and each used to carry a copy:
|
||||
the aliases router, which rolls its file back when the bot says no, and the
|
||||
approval queue, which does not (the photo is in the album either way, and
|
||||
the reason the bot gives is worth more on the page than a second copy of
|
||||
the same upload in the queue).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from signal_bot.manage.deps import ManageDeps
|
||||
|
||||
|
||||
async def reload_bot(deps: ManageDeps) -> dict[str, Any]:
|
||||
"""``POST /_reload`` on the bot; anything but ``reloaded`` raises.
|
||||
|
||||
Raises:
|
||||
HTTPException: 502 when the bot did not answer at all; otherwise the
|
||||
bot's own status -- ``kept`` (409) carries its sentence about the
|
||||
data, ``connecting`` (503) why there is no table yet. Both are
|
||||
the bot's words, passed on.
|
||||
"""
|
||||
try:
|
||||
response = await deps.client.post(f"{deps.bot_url}/_reload", timeout=30.0)
|
||||
except httpx.HTTPError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"the bot did not answer: {error}",
|
||||
) from error
|
||||
body: dict[str, Any] = response.json()
|
||||
if response.status_code != status.HTTP_200_OK:
|
||||
raise HTTPException(status_code=response.status_code, detail=body.get("reason", body))
|
||||
return body
|
||||
@ -13,12 +13,12 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from signal_bot.custom_aliases import load, normalise, store
|
||||
from signal_bot.groups import ConfigError
|
||||
from signal_bot.manage.bot_reload import reload_bot
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@ -58,23 +58,6 @@ def router(deps: ManageDeps) -> APIRouter:
|
||||
except ConfigError as error:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||
|
||||
async def reload() -> dict[str, Any]:
|
||||
"""Ask the bot to rebuild its table; anything but ``reloaded`` raises."""
|
||||
try:
|
||||
response = await deps.client.post(f"{deps.bot_url}/_reload", timeout=30.0)
|
||||
except httpx.HTTPError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"the bot did not answer: {error}",
|
||||
) from error
|
||||
body: dict[str, Any] = response.json()
|
||||
if response.status_code != status.HTTP_200_OK:
|
||||
# ``kept`` (409) carries the bot's own sentence about the data,
|
||||
# ``connecting`` (503) why there is no table yet. Both are the
|
||||
# bot's words, passed on.
|
||||
raise HTTPException(status_code=response.status_code, detail=body.get("reason", body))
|
||||
return body
|
||||
|
||||
@api.get("/aliases")
|
||||
async def list_aliases() -> dict[str, dict[str, list[str]]]:
|
||||
"""Every custom alias, by command name."""
|
||||
@ -94,7 +77,7 @@ def router(deps: ManageDeps) -> APIRouter:
|
||||
previous = path.read_bytes() if path.exists() else None
|
||||
store(path, {**aliases, name: words})
|
||||
try:
|
||||
answer = await reload()
|
||||
answer = await reload_bot(deps)
|
||||
except HTTPException:
|
||||
_restore(path, previous)
|
||||
raise
|
||||
|
||||
103
signal_bot/manage/routes_pending.py
Normal file
103
signal_bot/manage/routes_pending.py
Normal file
@ -0,0 +1,103 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""The approval queue on the page: what is waiting, and the two decisions.
|
||||
|
||||
The only route from ``pending/`` into the album. The bot's process never
|
||||
approves anything; it writes the queue and reads the result back through
|
||||
``POST /_reload``, which is asked for here, after the write. A reload the
|
||||
bot refuses is *reported*, not rolled back: the file is already in the
|
||||
album with the tags the approver chose, and the bot's reason (a stale
|
||||
alias, say) is about something else, which the page can now show.
|
||||
|
||||
The media route takes the queued file's name, not an index like the
|
||||
album's, because the queue is re-ordered by every send; but only a name
|
||||
that is in the queue's own listing is ever opened, so nothing typed into a
|
||||
URL reaches the filesystem.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from signal_bot.groups import ConfigError
|
||||
from signal_bot.manage.bot_reload import reload_bot
|
||||
from signal_bot.pack_pending import PENDING_DIR, approve, discard, listing, names
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from signal_bot.manage.deps import ManageDeps
|
||||
|
||||
|
||||
class ApproveRequest(BaseModel):
|
||||
"""The approver's decision: which proposed keys and labels the photo keeps."""
|
||||
|
||||
keys: list[str]
|
||||
labels: list[str]
|
||||
|
||||
|
||||
def router(deps: ManageDeps) -> APIRouter:
|
||||
"""The queue's four routes.
|
||||
|
||||
Args:
|
||||
deps: The running app's album and shared HTTP client.
|
||||
|
||||
Returns:
|
||||
A router to include in the management app.
|
||||
"""
|
||||
api = APIRouter(prefix="/api/album/pending")
|
||||
directory = deps.album.directory
|
||||
|
||||
@api.get("")
|
||||
async def pending() -> dict[str, list[dict[str, Any]]]:
|
||||
"""Everything waiting, oldest first; the sender is not the page's business."""
|
||||
return {
|
||||
"pending": [
|
||||
{
|
||||
"name": entry.name,
|
||||
"keys": list(entry.keys),
|
||||
"labels": list(entry.labels),
|
||||
"at": entry.at,
|
||||
}
|
||||
for entry in listing(directory)
|
||||
]
|
||||
}
|
||||
|
||||
@api.get("/{name}/media")
|
||||
async def media(name: str) -> FileResponse:
|
||||
"""One queued file, by the name the queue itself lists it under."""
|
||||
if name not in names(directory):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
return FileResponse(directory / PENDING_DIR / name)
|
||||
|
||||
@api.post("/{name}/approve")
|
||||
async def approve_one(name: str, request: ApproveRequest) -> dict[str, Any]:
|
||||
"""Move it into the album, then ask the bot to pick it up."""
|
||||
try:
|
||||
keys = approve(directory, name, request.keys, request.labels, int(time.time()))
|
||||
except KeyError as error:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error
|
||||
except (ValueError, ConfigError) as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)
|
||||
) from error
|
||||
try:
|
||||
await reload_bot(deps)
|
||||
except HTTPException as error:
|
||||
return {
|
||||
"status": "approved",
|
||||
"keys": list(keys),
|
||||
"reloaded": False,
|
||||
"reason": error.detail,
|
||||
}
|
||||
return {"status": "approved", "keys": list(keys), "reloaded": True, "reason": None}
|
||||
|
||||
@api.post("/{name}/reject")
|
||||
async def reject_one(name: str) -> dict[str, str]:
|
||||
"""Delete it, and its sidecar. Nothing else to undo: it never reached the album."""
|
||||
discard(directory, name)
|
||||
return {"status": "rejected"}
|
||||
|
||||
return api
|
||||
@ -1,21 +1,24 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""``!add_photo``: put an attachment into the album, tagged, from the chat.
|
||||
"""``!add_photo``: propose an attachment for 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.
|
||||
Send a photo captioned ``!add_photo alfa beta`` and it lands in the album's
|
||||
approval queue under both labels. It reaches the album -- and its commands
|
||||
-- once somebody approves it on the management page; until then the group
|
||||
is told it is waiting, and nothing else.
|
||||
|
||||
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.
|
||||
before the line that logs the command name. A label nobody has becomes a
|
||||
provisional token (``new1``) that means nothing outside this process; the
|
||||
word itself goes into the queue's sidecar, inside the album's 0700, and is
|
||||
minted as a custom tag only when the photo is approved.
|
||||
* **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.
|
||||
* **Nothing reaches the album without a human.** The queue is a directory the
|
||||
bot only ever writes; every path from it into ``media/`` goes through
|
||||
``pack_pending.approve``, which the bot's own process never calls.
|
||||
|
||||
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
|
||||
@ -27,14 +30,20 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from signal_bot.folding import fold
|
||||
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_ingest import ingest
|
||||
from signal_bot.pack_pending import Pending, queue
|
||||
from signal_bot.pack_replies import (
|
||||
ALREADY_QUEUED,
|
||||
ALREADY_THERE,
|
||||
AMBIGUOUS,
|
||||
DUPLICATE_ONLY,
|
||||
NEED_LABEL,
|
||||
NEED_PHOTO,
|
||||
QUEUED,
|
||||
QUEUED_ONLY,
|
||||
REJECTED,
|
||||
TOO_MANY,
|
||||
)
|
||||
@ -43,78 +52,99 @@ if TYPE_CHECKING:
|
||||
from signal_bot.context import CommandContext
|
||||
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.pack_ingest import Ingested
|
||||
from signal_bot.registry import ArgResolver, 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"
|
||||
DESCRIPTION: Final = "zgłasza załączone zdjęcie do albumu z etykietami; trafia tam po zatwierdzeniu"
|
||||
|
||||
# A label nobody has yet travels from the resolver to the handler as one of
|
||||
# these, never as itself. Custom keys are ``t<n>`` and people ``p<n>``, so a
|
||||
# token cannot be mistaken for either.
|
||||
NEW_PREFIX: Final = "new"
|
||||
# How many unclaimed tokens are kept. A token goes unclaimed when the
|
||||
# dispatcher resolves the message and then does not run the handler -- a
|
||||
# muted sender, mostly -- and without a cap those would accrue for ever.
|
||||
PENDING_LIMIT: Final = 64
|
||||
|
||||
|
||||
class AddResolver:
|
||||
"""Turns typed labels into keys, minting one for a label nobody has.
|
||||
"""Turns typed labels into keys, standing in a token 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.
|
||||
``Bot.listen`` runs every frame in its own task, so two messages can be
|
||||
between resolve and handle at once. Each new label therefore gets a
|
||||
token of its own from a counter that never repeats, and the handler
|
||||
claims exactly its own message's tokens; nothing here is per "current
|
||||
message".
|
||||
"""
|
||||
|
||||
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
|
||||
The registry is held so a new label can refuse a word that is already
|
||||
a command. Without that, "!add_photo kot" proposes the label "kot",
|
||||
and scripts/personal_data_sources feeds proposed 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] = {}
|
||||
self._labels: dict[str, str] = {}
|
||||
self._minted = 0
|
||||
|
||||
def __call__(self, words: tuple[str, ...]) -> tuple[str, ...]:
|
||||
"""Resolve every label to a key, recording any mint it implies."""
|
||||
self.pending = {}
|
||||
"""Resolve every label to a key or a token."""
|
||||
pack = self._holder.pack
|
||||
minted = pack.custom
|
||||
tokens: dict[str, str] = {}
|
||||
keys: list[str] = []
|
||||
for word in words:
|
||||
resolved, minted = self._resolve(pack, minted, word)
|
||||
keys.extend(resolved)
|
||||
keys.extend(self._resolve(pack, tokens, word))
|
||||
return tuple(keys)
|
||||
|
||||
def _resolve(self, pack: Pack, minted: CustomTags, word: str) -> tuple[list[str], CustomTags]:
|
||||
def _resolve(self, pack: Pack, tokens: dict[str, str], word: str) -> list[str]:
|
||||
"""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)
|
||||
return [UNKNOWN] if len(options) > 1 else sorted(options[0])
|
||||
existing = pack.custom.resolve(word)
|
||||
if existing is not None:
|
||||
return [existing], minted
|
||||
return [existing]
|
||||
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)
|
||||
return [UNKNOWN]
|
||||
folded = fold(word)
|
||||
if folded not in tokens:
|
||||
tokens[folded] = self._token(word)
|
||||
return [tokens[folded]]
|
||||
|
||||
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 _token(self, word: str) -> str:
|
||||
"""A fresh token for ``word``, dropping the oldest unclaimed one at the cap."""
|
||||
self._minted += 1
|
||||
token = f"{NEW_PREFIX}{self._minted}"
|
||||
self._labels[token] = word
|
||||
while len(self._labels) > PENDING_LIMIT:
|
||||
del self._labels[next(iter(self._labels))]
|
||||
return token
|
||||
|
||||
def take(self, keys: tuple[str, ...]) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
||||
"""Split a message's keys into album keys and the labels behind its tokens."""
|
||||
known: list[str] = []
|
||||
labels: list[str] = []
|
||||
for key in keys:
|
||||
label = self._labels.pop(key, None)
|
||||
if label is None:
|
||||
known.append(key)
|
||||
else:
|
||||
labels.append(label)
|
||||
return tuple(known), tuple(labels)
|
||||
|
||||
|
||||
def add_handler(holder: PackHolder, resolver: AddResolver, stripper: Stripper) -> Handler:
|
||||
@ -122,15 +152,27 @@ def add_handler(holder: PackHolder, resolver: AddResolver, stripper: Stripper) -
|
||||
|
||||
async def handler(ctx: CommandContext) -> None:
|
||||
keys = tuple(dict.fromkeys(ctx.args))
|
||||
# Claimed before any refusal, so a refused message leaves no token behind.
|
||||
known, labels = resolver.take(keys)
|
||||
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)
|
||||
result = await ingest(ctx, holder, stripper)
|
||||
if not result.fresh:
|
||||
await ctx.reply(_nothing_new(result))
|
||||
return
|
||||
await commit(ctx, holder, resolver, keys, fresh, duplicates)
|
||||
for name, body in result.fresh.items():
|
||||
entry = Pending(
|
||||
name=name,
|
||||
keys=known,
|
||||
labels=labels,
|
||||
by=ctx.message.source_uuid,
|
||||
at=int(ctx.now),
|
||||
group=ctx.group_id,
|
||||
)
|
||||
queue(holder.pack.directory, body, entry)
|
||||
await ctx.reply(_receipt(result))
|
||||
|
||||
return handler
|
||||
|
||||
@ -146,6 +188,23 @@ def _refusal(keys: tuple[str, ...], attachments: tuple[str, ...]) -> str | None:
|
||||
return NEED_PHOTO if not attachments else None
|
||||
|
||||
|
||||
def _nothing_new(result: Ingested) -> str:
|
||||
"""Why none of the attachments went into the queue."""
|
||||
if result.duplicates:
|
||||
return DUPLICATE_ONLY
|
||||
return QUEUED_ONLY if result.queued else REJECTED
|
||||
|
||||
|
||||
def _receipt(result: Ingested) -> str:
|
||||
"""Counts only -- never a label, never a filename."""
|
||||
parts = [QUEUED.format(count=len(result.fresh))]
|
||||
if result.duplicates:
|
||||
parts.append(ALREADY_THERE.format(count=result.duplicates))
|
||||
if result.queued:
|
||||
parts.append(ALREADY_QUEUED.format(count=result.queued))
|
||||
return " · ".join(parts)
|
||||
|
||||
|
||||
def register_add_command(
|
||||
registry: CommandRegistry,
|
||||
holder: PackHolder,
|
||||
@ -155,7 +214,7 @@ def register_add_command(
|
||||
"""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.
|
||||
size cap and no daily limit on proposing photos for the album.
|
||||
"""
|
||||
resolver = AddResolver(holder, registry)
|
||||
registry.register(
|
||||
|
||||
@ -1,61 +1,30 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""The one mutable box the pack lives in, so a new photo is visible at once.
|
||||
"""The one box the album's handlers read the pack through.
|
||||
|
||||
``pack_command.pack_handler`` used to close over a frozen ``Pack``. That was
|
||||
right while the only writer was the tagging page 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.
|
||||
``pack_command.pack_handler`` used to close over a frozen ``Pack``; every
|
||||
pack handler now reads ``holder.pack`` at call time instead. It once had a
|
||||
``reload`` of its own, for the days when ``!add_photo`` wrote straight into
|
||||
the album and had to publish the result without a restart. Since the
|
||||
approval queue nothing in the bot's process writes to the album, and a
|
||||
change on disk reaches the commands the way every other data change does:
|
||||
``POST /_reload`` rebuilds the whole registry, holder included
|
||||
(``registry_holder``). The box stays because the handlers are written
|
||||
against it and because a swap that only ever replaces *Pack data* -- never
|
||||
a trigger, never ``Command.groups`` -- is the property to preserve if a
|
||||
faster path is ever wanted again.
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
@ -1,64 +1,74 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Turning the attachments on one message into files inside the album.
|
||||
"""Turning the attachments on one message into stripped, named files.
|
||||
|
||||
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.
|
||||
half does the reading, the sniffing and the stripping. Nothing here writes
|
||||
to the album: since the approval queue, the only place an upload goes is
|
||||
``pending/``, and ``pack_pending`` owns that.
|
||||
|
||||
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.
|
||||
A file the album already has, or one that is already waiting, is counted
|
||||
rather than stored again, and counted separately so the reply can say which
|
||||
it was -- "already there" and "already queued" ask for different things of
|
||||
the sender.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
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,
|
||||
)
|
||||
from signal_bot.pack import MEDIA_DIR
|
||||
from signal_bot.pack_pending import names as queued_names
|
||||
from signal_bot.pack_store import content_name
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from signal_bot.context import CommandContext
|
||||
from signal_bot.media_strip import Stripper
|
||||
from signal_bot.pack_add import AddResolver
|
||||
from signal_bot.pack_holder import PackHolder
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def ingest(
|
||||
ctx: CommandContext, holder: PackHolder, stripper: Stripper
|
||||
) -> tuple[dict[str, bytes], int]:
|
||||
@dataclass(slots=True)
|
||||
class Ingested:
|
||||
"""What one message's attachments turned into."""
|
||||
|
||||
fresh: dict[str, bytes] = field(default_factory=dict)
|
||||
"""Files new to both the album and the queue, by their content name."""
|
||||
duplicates: int = 0
|
||||
"""How many were already in the album."""
|
||||
queued: int = 0
|
||||
"""How many were already waiting for approval."""
|
||||
|
||||
|
||||
async def ingest(ctx: CommandContext, holder: PackHolder, stripper: Stripper) -> Ingested:
|
||||
"""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.
|
||||
The album check is against ``media/`` on disk, not the loaded pack's tag
|
||||
table: a file the tagger has not opened yet is in the album all the same,
|
||||
and the point of the check is that no byte-identical copy is stored twice.
|
||||
"""
|
||||
fresh: dict[str, bytes] = {}
|
||||
duplicates = 0
|
||||
known = set(holder.pack.tags)
|
||||
directory = holder.pack.directory
|
||||
waiting = queued_names(directory)
|
||||
result = Ingested()
|
||||
for attachment_id in ctx.message.attachment_ids:
|
||||
payload = await _fetch(ctx, attachment_id)
|
||||
cleaned = _clean(payload, stripper)
|
||||
if cleaned is not None:
|
||||
if cleaned is None:
|
||||
continue
|
||||
name, body = cleaned
|
||||
if name in known or name in fresh:
|
||||
duplicates += 1
|
||||
if (directory / MEDIA_DIR / name).exists():
|
||||
result.duplicates += 1
|
||||
elif name in waiting or name in result.fresh:
|
||||
# Twice in one message is "already waiting" too: it is, as of a
|
||||
# moment ago.
|
||||
result.queued += 1
|
||||
else:
|
||||
fresh[name] = body
|
||||
return fresh, duplicates
|
||||
result.fresh[name] = body
|
||||
return result
|
||||
|
||||
|
||||
async def _fetch(ctx: CommandContext, attachment_id: str) -> bytes | None:
|
||||
@ -92,43 +102,3 @@ def _clean(payload: bytes | None, stripper: Stripper) -> tuple[str, bytes] | Non
|
||||
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
|
||||
|
||||
193
signal_bot/pack_pending.py
Normal file
193
signal_bot/pack_pending.py
Normal file
@ -0,0 +1,193 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""The approval queue: where a photo sent to the chat waits until somebody looks at it.
|
||||
|
||||
``!add_photo`` used to write straight into the album. Since 2026-09-04 it
|
||||
writes here instead -- ``<album>/pending/``, one stripped file plus one JSON
|
||||
sidecar per upload -- and nothing reaches ``media/`` until the management
|
||||
page approves it. Rejecting deletes both files; approving moves the file
|
||||
into the album with the same four writes and the same roll-back the direct
|
||||
path used, then proves the album still loads.
|
||||
|
||||
The sidecar holds the album keys the sender asked for and the *labels* that
|
||||
matched nothing, as typed. The labels are kept rather than minted at send
|
||||
time because a key minted for a photo nobody approves would sit in
|
||||
``custom.json`` for ever, and because two people can send two new labels at
|
||||
once; minting at approval, one photo at a time, cannot collide. Everything
|
||||
here lives inside the album directory, which is 0700 and outside the
|
||||
repository, so the sidecar is subject to exactly the same handling as the
|
||||
photograph it describes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
from signal_bot.groups import ConfigError
|
||||
from signal_bot.pack import TAGS_FILE
|
||||
from signal_bot.pack_custom import FILE_MODE
|
||||
from signal_bot.pack_custom import write as write_custom
|
||||
from signal_bot.pack_load import load
|
||||
from signal_bot.pack_store import record_adds, restore, snapshot, write_json, write_media
|
||||
from signal_bot.pack_tags import document
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PENDING_DIR: Final = "pending"
|
||||
SIDECAR_SUFFIX: Final = ".json"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Pending:
|
||||
"""One upload waiting for a decision."""
|
||||
|
||||
name: str
|
||||
"""The content-hash filename, the same one it will have in the album."""
|
||||
keys: tuple[str, ...]
|
||||
"""Album keys the sender's labels resolved to: people, and custom tags that exist."""
|
||||
labels: tuple[str, ...]
|
||||
"""Labels that matched nothing, as typed; minted as custom tags on approval."""
|
||||
by: str
|
||||
"""The sender's uuid, for adds.json."""
|
||||
at: int
|
||||
"""When it was sent, unix seconds."""
|
||||
group: str
|
||||
"""The group it was sent in."""
|
||||
|
||||
|
||||
def names(directory: Path) -> frozenset[str]:
|
||||
"""Every file waiting in the queue, so a second send of it is reported as such."""
|
||||
return frozenset(entry.name for entry in listing(directory))
|
||||
|
||||
|
||||
def queue(directory: Path, body: bytes, entry: Pending) -> None:
|
||||
"""Put one stripped upload and its sidecar into the queue, atomically."""
|
||||
folder = directory / PENDING_DIR
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
_write_bytes(folder / entry.name, body)
|
||||
write_json(folder / f"{entry.name}{SIDECAR_SUFFIX}", asdict(entry))
|
||||
|
||||
|
||||
def listing(directory: Path) -> list[Pending]:
|
||||
"""Everything waiting, oldest first.
|
||||
|
||||
A sidecar that cannot be read, or whose file is gone, is skipped with a
|
||||
warning rather than raised: the page must still show the other photos,
|
||||
and the broken pair is visible in the directory to whoever looks.
|
||||
"""
|
||||
folder = directory / PENDING_DIR
|
||||
if not folder.is_dir():
|
||||
return []
|
||||
entries = []
|
||||
for sidecar in folder.glob(f"*{SIDECAR_SUFFIX}"):
|
||||
entry = _entry(sidecar)
|
||||
if entry is None or not (folder / entry.name).is_file():
|
||||
logger.warning("skipping unreadable or orphaned queue entry %s", sidecar.name)
|
||||
continue
|
||||
entries.append(entry)
|
||||
return sorted(entries, key=lambda entry: (entry.at, entry.name))
|
||||
|
||||
|
||||
def discard(directory: Path, name: str) -> None:
|
||||
"""Take one upload out of the queue: the file and its sidecar."""
|
||||
folder = directory / PENDING_DIR
|
||||
(folder / name).unlink(missing_ok=True)
|
||||
(folder / f"{name}{SIDECAR_SUFFIX}").unlink(missing_ok=True)
|
||||
|
||||
|
||||
def approve(
|
||||
directory: Path, name: str, keys: Sequence[str], labels: Sequence[str], now: int
|
||||
) -> tuple[str, ...]:
|
||||
"""Move one queued upload into the album under ``keys`` plus minted ``labels``.
|
||||
|
||||
The approver may have unticked people or labels the sender proposed;
|
||||
what arrives here is the decision. A label not on the sidecar is refused
|
||||
outright, because labels are the one thing this path lets free text into
|
||||
``custom.json`` through, and the chat side already refused the ones that
|
||||
are command words.
|
||||
|
||||
Returns:
|
||||
The keys the file is now tagged with.
|
||||
|
||||
Raises:
|
||||
KeyError: Nothing by that name is waiting.
|
||||
ValueError: A label the sender never proposed.
|
||||
ConfigError: The album does not load -- before the write, or with
|
||||
this file in it, in which case every write is rolled back.
|
||||
"""
|
||||
entry = _find(directory, name)
|
||||
strangers = set(labels) - set(entry.labels)
|
||||
if strangers:
|
||||
msg = f"labels not proposed for {name}: {sorted(strangers)}"
|
||||
raise ValueError(msg)
|
||||
pack = load(directory)
|
||||
custom = pack.custom
|
||||
final = list(keys)
|
||||
for label in labels:
|
||||
key = custom.resolve(label)
|
||||
if key is None:
|
||||
key = custom.next_key()
|
||||
custom = custom.with_label(key, label)
|
||||
final.append(key)
|
||||
tagged = tuple(dict.fromkeys(final))
|
||||
|
||||
previous = snapshot(directory)
|
||||
tags_path = directory / TAGS_FILE
|
||||
tags = document(tags_path) if tags_path.exists() else {}
|
||||
write_media(directory, name, (directory / PENDING_DIR / name).read_bytes())
|
||||
write_json(tags_path, {**tags, name: sorted(tagged)})
|
||||
write_custom(directory, custom)
|
||||
record_adds(directory, {name: {"by": entry.by, "at": entry.at, "approved_at": now}})
|
||||
try:
|
||||
load(directory)
|
||||
except ConfigError:
|
||||
restore(directory, previous, (name,))
|
||||
raise
|
||||
discard(directory, name)
|
||||
return tagged
|
||||
|
||||
|
||||
def _find(directory: Path, name: str) -> Pending:
|
||||
"""The queue entry by that name, or KeyError."""
|
||||
for entry in listing(directory):
|
||||
if entry.name == name:
|
||||
return entry
|
||||
msg = f"{name} is not waiting for approval"
|
||||
raise KeyError(msg)
|
||||
|
||||
|
||||
def _entry(sidecar: Path) -> Pending | None:
|
||||
"""One sidecar parsed, or None when it is not the shape this file writes.
|
||||
|
||||
The name has to be a bare filename: the page opens whatever the listing
|
||||
says, so a sidecar naming a path would be a way out of the album.
|
||||
"""
|
||||
try:
|
||||
raw: dict[str, Any] = json.loads(sidecar.read_text(encoding="utf-8"))
|
||||
name = str(raw["name"])
|
||||
if name != sidecar.name.removesuffix(SIDECAR_SUFFIX):
|
||||
return None
|
||||
return Pending(
|
||||
name=name,
|
||||
keys=tuple(str(key) for key in raw["keys"]),
|
||||
labels=tuple(str(label) for label in raw["labels"]),
|
||||
by=str(raw["by"]),
|
||||
at=int(raw["at"]),
|
||||
group=str(raw["group"]),
|
||||
)
|
||||
except OSError, ValueError, KeyError, TypeError:
|
||||
return None
|
||||
|
||||
|
||||
def _write_bytes(path: Path, payload: bytes) -> None:
|
||||
"""Temp-then-replace, 0600, like every other file the album holds."""
|
||||
temporary = path.with_name(f"{path.name}.tmp")
|
||||
temporary.write_bytes(payload)
|
||||
temporary.chmod(FILE_MODE)
|
||||
temporary.replace(path)
|
||||
@ -1,11 +1,11 @@
|
||||
# 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.
|
||||
One module rather than constants beside the handler, because it is 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 only -- and since a photo now waits for approval, not even the
|
||||
album's per-key counts, which the old receipt printed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -19,12 +19,10 @@ 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."
|
||||
QUEUED_ONLY: Final = "BEEP BOOP TO ZDJĘCIE JUŻ CZEKA NA ZATWIERDZENIE."
|
||||
|
||||
# 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"
|
||||
# What a successful send opens with, then how many of each the batch held.
|
||||
QUEUED: Final = "czeka na zatwierdzenie: {count}"
|
||||
ALREADY_THERE: Final = "{count} już było"
|
||||
ALREADY_QUEUED: Final = "{count} już czeka"
|
||||
|
||||
@ -23,8 +23,11 @@ from signal_bot.food_command import register_food_command
|
||||
from signal_bot.good_bot_command import register_good_bot_command
|
||||
from signal_bot.groups import ConfigError
|
||||
from signal_bot.help_command import register_help_command
|
||||
from signal_bot.media_strip import ExiftoolStripper
|
||||
from signal_bot.mute import UNMUTE_NAME
|
||||
from signal_bot.mute_command import MUTE_NAME, register_mute_commands
|
||||
from signal_bot.pack_add import COMMAND_NAME as ADD_NAME
|
||||
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
|
||||
@ -52,20 +55,21 @@ COOLDOWN_MESSAGE = "BEEP BOOP POCZEKAJ {seconds} {unit}."
|
||||
# the switchboard can say "no cooldown" for them instead of showing the
|
||||
# deployment's number next to an input that would do nothing.
|
||||
# test_wiring_ungated walks the real table to keep this honest.
|
||||
UNGATED: Final = frozenset({MUTE_NAME, UNMUTE_NAME})
|
||||
UNGATED: Final = frozenset({MUTE_NAME, UNMUTE_NAME, ADD_NAME})
|
||||
|
||||
|
||||
def _register_album(registry: CommandRegistry, directory: Path, rate_limit: Decorator) -> None:
|
||||
"""Wire the album's commands, all of them reading one mutable holder.
|
||||
"""Wire the album's commands, all of them reading one holder.
|
||||
|
||||
!random takes the same cooldown as every other photo command. !add_photo
|
||||
is not wired: a photo posted to a group must not reach the album until
|
||||
somebody has approved it, and no approval queue exists yet.
|
||||
has none, and writes only to the album's approval queue: a photo posted
|
||||
to a group reaches the album when the management page approves it.
|
||||
"""
|
||||
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_registry(
|
||||
|
||||
@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from signal_bot.help_command import ALBUM_HEADING, PACK_HEADING, render_help
|
||||
from signal_bot.pack_add import DESCRIPTION as ADD_DESCRIPTION
|
||||
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
|
||||
@ -88,7 +89,7 @@ def test_the_album_commands_get_their_own_block(tmp_path: Path) -> None:
|
||||
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 f"!dodaj — {ADD_DESCRIPTION}" 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
|
||||
|
||||
171
tests/test_manage_pending.py
Normal file
171
tests/test_manage_pending.py
Normal file
@ -0,0 +1,171 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""The approval queue on the page: listed, shown, approved or rejected."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
|
||||
from signal_bot.pack import MEDIA_DIR, TAGS_FILE
|
||||
from signal_bot.pack_custom import CUSTOM_FILE
|
||||
from signal_bot.pack_pending import PENDING_DIR, Pending, listing, queue
|
||||
from tests.manage_fixture import bot_is_down, client_for, deps_at
|
||||
from tests.pack_fixture import PIXEL
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from signal_bot.manage.deps import ManageDeps
|
||||
|
||||
FIRST = Pending("first.png", ("p1",), ("wakacje",), "uuid-alice", 100, "group.X")
|
||||
SECOND = Pending("second.png", (), ("gory",), "uuid-bob", 50, "group.X")
|
||||
RELOADED = {"status": "reloaded", "commands": 25}
|
||||
|
||||
|
||||
def _bot_that_reloads(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/_reload"
|
||||
return httpx.Response(200, json=RELOADED)
|
||||
|
||||
|
||||
def _bot_that_keeps(request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path == "/_reload"
|
||||
return httpx.Response(409, json={"status": "kept", "reason": "aliases for nothing"})
|
||||
|
||||
|
||||
def _queued(deps: ManageDeps) -> Path:
|
||||
root = deps.album.directory
|
||||
queue(root, PIXEL, FIRST)
|
||||
queue(root, PIXEL + b"2", SECOND)
|
||||
return root
|
||||
|
||||
|
||||
def test_the_listing_is_oldest_first_and_names_no_sender(tmp_path: Path) -> None:
|
||||
deps = deps_at(tmp_path, _bot_that_reloads)
|
||||
_queued(deps)
|
||||
with client_for(deps) as client:
|
||||
answer = client.get("/api/album/pending").json()
|
||||
assert answer == {
|
||||
"pending": [
|
||||
{"name": "second.png", "keys": [], "labels": ["gory"], "at": 50},
|
||||
{"name": "first.png", "keys": ["p1"], "labels": ["wakacje"], "at": 100},
|
||||
]
|
||||
}
|
||||
assert "uuid" not in json.dumps(answer)
|
||||
|
||||
|
||||
def test_a_queued_file_is_served_by_its_listed_name_and_nothing_else(tmp_path: Path) -> None:
|
||||
deps = deps_at(tmp_path, _bot_that_reloads)
|
||||
_queued(deps)
|
||||
with client_for(deps) as client:
|
||||
shown = client.get("/api/album/pending/first.png/media")
|
||||
missing = client.get("/api/album/pending/one.png/media")
|
||||
sidecar = client.get("/api/album/pending/first.png.json/media")
|
||||
assert shown.status_code == 200
|
||||
assert shown.content == PIXEL
|
||||
# one.png is in the album, and the sidecar is in the queue directory; neither is queued.
|
||||
assert missing.status_code == 404
|
||||
assert sidecar.status_code == 404
|
||||
|
||||
|
||||
def test_approving_moves_it_into_the_album_and_reloads_the_bot(tmp_path: Path) -> None:
|
||||
deps = deps_at(tmp_path, _bot_that_reloads)
|
||||
root = _queued(deps)
|
||||
with client_for(deps) as client:
|
||||
answer = client.post(
|
||||
"/api/album/pending/first.png/approve", json={"keys": ["p1"], "labels": ["wakacje"]}
|
||||
)
|
||||
assert answer.status_code == 200
|
||||
assert answer.json() == {
|
||||
"status": "approved",
|
||||
"keys": ["p1", "t1"],
|
||||
"reloaded": True,
|
||||
"reason": None,
|
||||
}
|
||||
assert (root / MEDIA_DIR / "first.png").read_bytes() == PIXEL
|
||||
assert json.loads((root / TAGS_FILE).read_text(encoding="utf-8"))["first.png"] == ["p1", "t1"]
|
||||
assert json.loads((root / CUSTOM_FILE).read_text(encoding="utf-8")) == {"t1": ["wakacje"]}
|
||||
assert [entry.name for entry in listing(root)] == ["second.png"]
|
||||
|
||||
|
||||
def test_a_reload_the_bot_refuses_is_reported_not_rolled_back(tmp_path: Path) -> None:
|
||||
"""The photo is in the album; the bot's reason is about something else."""
|
||||
deps = deps_at(tmp_path, _bot_that_keeps)
|
||||
root = _queued(deps)
|
||||
with client_for(deps) as client:
|
||||
answer = client.post(
|
||||
"/api/album/pending/first.png/approve", json={"keys": ["p1"], "labels": []}
|
||||
)
|
||||
assert answer.status_code == 200
|
||||
assert answer.json() == {
|
||||
"status": "approved",
|
||||
"keys": ["p1"],
|
||||
"reloaded": False,
|
||||
"reason": "aliases for nothing",
|
||||
}
|
||||
assert (root / MEDIA_DIR / "first.png").exists()
|
||||
|
||||
|
||||
def test_a_bot_that_is_down_is_reported_the_same_way(tmp_path: Path) -> None:
|
||||
deps = deps_at(tmp_path, bot_is_down)
|
||||
_queued(deps)
|
||||
with client_for(deps) as client:
|
||||
answer = client.post(
|
||||
"/api/album/pending/first.png/approve", json={"keys": [], "labels": []}
|
||||
)
|
||||
assert answer.status_code == 200
|
||||
assert answer.json()["reloaded"] is False
|
||||
assert "did not answer" in answer.json()["reason"]
|
||||
|
||||
|
||||
def test_approving_what_is_not_queued_is_404(tmp_path: Path) -> None:
|
||||
deps = deps_at(tmp_path, _bot_that_reloads)
|
||||
with client_for(deps) as client:
|
||||
answer = client.post(
|
||||
"/api/album/pending/ghost.png/approve", json={"keys": [], "labels": []}
|
||||
)
|
||||
assert answer.status_code == 404
|
||||
assert "not waiting" in answer.json()["detail"]
|
||||
|
||||
|
||||
def test_a_label_the_sender_never_proposed_is_400(tmp_path: Path) -> None:
|
||||
deps = deps_at(tmp_path, _bot_that_reloads)
|
||||
root = _queued(deps)
|
||||
with client_for(deps) as client:
|
||||
answer = client.post(
|
||||
"/api/album/pending/first.png/approve", json={"keys": [], "labels": ["zmyslone"]}
|
||||
)
|
||||
assert answer.status_code == 400
|
||||
assert "not proposed" in answer.json()["detail"]
|
||||
assert not (root / MEDIA_DIR / "first.png").exists()
|
||||
|
||||
|
||||
def test_an_album_that_would_not_load_is_400_and_the_photo_stays_queued(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
deps = deps_at(tmp_path, _bot_that_reloads)
|
||||
root = _queued(deps)
|
||||
with client_for(deps) as client:
|
||||
answer = client.post(
|
||||
"/api/album/pending/first.png/approve", json={"keys": ["p9"], "labels": []}
|
||||
)
|
||||
assert answer.status_code == 400
|
||||
assert "names somebody" in answer.json()["detail"]
|
||||
assert not (root / MEDIA_DIR / "first.png").exists()
|
||||
assert "first.png" in {entry.name for entry in listing(root)}
|
||||
|
||||
|
||||
def test_rejecting_deletes_the_file_and_its_sidecar(tmp_path: Path) -> None:
|
||||
deps = deps_at(tmp_path, _bot_that_reloads)
|
||||
root = _queued(deps)
|
||||
with client_for(deps) as client:
|
||||
answer = client.post("/api/album/pending/first.png/reject", json={})
|
||||
again = client.post("/api/album/pending/first.png/reject", json={})
|
||||
assert answer.json() == {"status": "rejected"}
|
||||
assert again.status_code == 200
|
||||
assert sorted(path.name for path in (root / PENDING_DIR).iterdir()) == [
|
||||
"second.png",
|
||||
"second.png.json",
|
||||
]
|
||||
assert not (root / MEDIA_DIR / "first.png").exists()
|
||||
@ -109,26 +109,21 @@ def test_two_albums_may_share_a_trigger_when_their_groups_do_not_overlap(
|
||||
assert first is not second
|
||||
|
||||
|
||||
def test_random_exists_separately_in_each_album(
|
||||
def test_add_photo_and_random_exist_separately_in_each_album(
|
||||
tmp_path: Path,
|
||||
config: Config,
|
||||
store: Store,
|
||||
transport: FakeTransport,
|
||||
directory: GroupDirectory,
|
||||
) -> None:
|
||||
"""!losowe carries the same triggers in both, by construction.
|
||||
|
||||
!dodaj used to be asserted here too. It is unwired until an approval
|
||||
queue exists (2026-09-04), and a word nothing registers is the same in
|
||||
every group: the scoping this test guards is the album's, not its.
|
||||
"""
|
||||
"""Both carry the same triggers in both albums, by construction."""
|
||||
real = _album(tmp_path / "real", WATCHED_GROUP)
|
||||
test = _album(tmp_path / "test", SECOND_GROUP, key="p9", trigger="!zeta")
|
||||
registry = _registry(config, store, transport, directory, (real, test))
|
||||
|
||||
for group in (WATCHED_GROUP, SECOND_GROUP):
|
||||
assert _claim(registry, "!losowe", group) is not None
|
||||
assert _claim(registry, "!dodaj alfa", group) is None
|
||||
assert _claim(registry, "!dodaj alfa", group) is not None
|
||||
|
||||
|
||||
def test_two_albums_sharing_a_group_are_refused_at_startup(
|
||||
|
||||
@ -1,23 +1,24 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""!add_photo: what lands in the album, and what is refused before it can."""
|
||||
"""!add_photo: what lands in the queue, 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 import MEDIA_DIR, TAGS_FILE
|
||||
from signal_bot.pack_custom import CUSTOM_FILE
|
||||
from signal_bot.pack_pending import PENDING_DIR, listing
|
||||
from signal_bot.pack_replies import (
|
||||
AMBIGUOUS,
|
||||
DUPLICATE_ONLY,
|
||||
NEED_LABEL,
|
||||
NEED_PHOTO,
|
||||
NEW_TAG_HINT,
|
||||
QUEUED_ONLY,
|
||||
REJECTED,
|
||||
TOO_MANY,
|
||||
)
|
||||
from signal_bot.pack_store import ADDS_FILE
|
||||
from tests.conftest import SECOND_GROUP
|
||||
from signal_bot.pack_store import ADDS_FILE, content_name
|
||||
from tests.conftest import SECOND_GROUP, WATCHED_GROUP
|
||||
from tests.pack_add_fixture import (
|
||||
JPEG_ONE,
|
||||
JPEG_TWO,
|
||||
@ -38,55 +39,78 @@ __all__ = ["holder"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# The happy paths
|
||||
# The happy paths: everything goes to the queue, nothing to the album
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_a_photo_tagged_with_a_person_is_retrievable_at_once(
|
||||
async def test_a_photo_tagged_with_a_person_waits_in_the_queue(
|
||||
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}"
|
||||
(entry,) = listing(holder.pack.directory)
|
||||
assert entry.keys == ("p1",)
|
||||
assert entry.labels == ()
|
||||
assert entry.by == "uuid-alice"
|
||||
assert entry.at == 1756600000
|
||||
assert entry.group == WATCHED_GROUP
|
||||
assert (holder.pack.directory / PENDING_DIR / entry.name).read_bytes() == JPEG_ONE
|
||||
assert transport.texts[-1] == "czeka na zatwierdzenie: 1"
|
||||
assert len(holder.pack.matching(frozenset({"p1"}))) == before
|
||||
|
||||
|
||||
async def test_an_unknown_label_mints_a_custom_tag_and_says_how_to_reach_it(
|
||||
async def test_an_unknown_label_is_proposed_not_minted(
|
||||
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
"""custom.json must not appear: the label is minted when the photo is approved."""
|
||||
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]
|
||||
(entry,) = listing(holder.pack.directory)
|
||||
assert entry.keys == ()
|
||||
assert entry.labels == ("wakacje",)
|
||||
assert not (holder.pack.directory / CUSTOM_FILE).exists()
|
||||
|
||||
|
||||
async def test_two_labels_tag_both(
|
||||
async def test_two_labels_propose_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
|
||||
(entry,) = listing(holder.pack.directory)
|
||||
assert (entry.keys, entry.labels) == (("p1",), ("wakacje",))
|
||||
|
||||
|
||||
async def test_a_whole_batch_is_stored(
|
||||
async def test_a_whole_batch_is_queued(
|
||||
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
|
||||
assert len(listing(holder.pack.directory)) == 2
|
||||
assert transport.texts[-1] == "czeka na zatwierdzenie: 2"
|
||||
|
||||
|
||||
async def test_a_combination_trigger_tags_everybody_in_it(
|
||||
async def test_a_combination_trigger_proposes_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
|
||||
(entry,) = listing(holder.pack.directory)
|
||||
assert entry.keys == ("p1", "p2")
|
||||
|
||||
|
||||
async def test_the_album_manifests_are_untouched_by_a_send(
|
||||
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
root = holder.pack.directory
|
||||
tags_before = (root / TAGS_FILE).read_text(encoding="utf-8")
|
||||
media_before = sorted(p.name for p in (root / MEDIA_DIR).iterdir())
|
||||
transport.payloads["a1"] = JPEG_ONE
|
||||
await _run(Wiring(holder, transport, store, config), "!dodaj alfa wakacje", attachments=("a1",))
|
||||
assert (root / TAGS_FILE).read_text(encoding="utf-8") == tags_before
|
||||
assert sorted(p.name for p in (root / MEDIA_DIR).iterdir()) == media_before
|
||||
assert not (root / ADDS_FILE).exists()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
@ -94,24 +118,23 @@ async def test_a_combination_trigger_tags_everybody_in_it(
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_no_label_stores_nothing(
|
||||
async def test_no_label_queues_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
|
||||
assert listing(holder.pack.directory) == []
|
||||
|
||||
|
||||
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 / PENDING_DIR).exists()
|
||||
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(
|
||||
@ -138,14 +161,13 @@ async def test_a_file_that_is_not_media_is_refused_on_its_bytes(
|
||||
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()
|
||||
assert not (holder.pack.directory / PENDING_DIR).exists()
|
||||
|
||||
|
||||
async def test_a_file_the_stripper_refuses_is_not_stored(
|
||||
async def test_a_file_the_stripper_refuses_is_not_queued(
|
||||
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",
|
||||
@ -153,7 +175,7 @@ async def test_a_file_the_stripper_refuses_is_not_stored(
|
||||
attachments=("a1",),
|
||||
)
|
||||
assert transport.texts[-1] == REJECTED
|
||||
assert len(holder.pack.tags) == before
|
||||
assert listing(holder.pack.directory) == []
|
||||
|
||||
|
||||
async def test_an_attachment_the_container_lost_is_refused(
|
||||
@ -178,51 +200,51 @@ async def test_it_does_not_exist_in_another_group(
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Duplicates
|
||||
# Duplicates: of the album, and of the queue
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_a_photo_already_in_the_album_is_reported_and_nothing_changes(
|
||||
async def test_a_photo_already_in_the_album_is_a_duplicate_even_when_untagged(
|
||||
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
"""Checked against media/ on disk, not the tag table the tagger has not reached."""
|
||||
(holder.pack.directory / MEDIA_DIR / content_name(JPEG_ONE, ".jpg")).write_bytes(JPEG_ONE)
|
||||
transport.payloads["a1"] = JPEG_ONE
|
||||
await _run(Wiring(holder, transport, store, config), "!dodaj alfa", attachments=("a1",))
|
||||
assert transport.texts[-1] == DUPLICATE_ONLY
|
||||
assert listing(holder.pack.directory) == []
|
||||
|
||||
|
||||
async def test_a_photo_already_waiting_is_reported_as_waiting(
|
||||
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
|
||||
assert transport.texts[-1] == QUEUED_ONLY
|
||||
(entry,) = listing(holder.pack.directory)
|
||||
assert entry.keys == ("p1",)
|
||||
|
||||
|
||||
async def test_a_duplicate_only_message_mints_nothing(
|
||||
async def test_a_mixed_batch_queues_the_new_ones_and_counts_the_rest(
|
||||
holder: PackHolder, transport: FakeTransport, store: Store, config: Config
|
||||
) -> None:
|
||||
transport.payloads["a1"] = JPEG_ONE
|
||||
(holder.pack.directory / MEDIA_DIR / content_name(JPEG_ONE, ".jpg")).write_bytes(JPEG_ONE)
|
||||
transport.payloads["a1"] = JPEG_TWO
|
||||
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
|
||||
transport.payloads.update({"a2": JPEG_ONE, "a3": JPEG_TWO, "a4": b"\xff\xd8\xff\xe0three"})
|
||||
await _run(
|
||||
Wiring(holder, transport, store, config), "!dodaj beta", attachments=("a2", "a3", "a4")
|
||||
)
|
||||
assert transport.texts[-1] == "czeka na zatwierdzenie: 1 · 1 już było · 1 już czeka"
|
||||
assert len(listing(holder.pack.directory)) == 2
|
||||
|
||||
|
||||
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]
|
||||
assert len(listing(holder.pack.directory)) == 1
|
||||
assert transport.texts[-1] == "czeka na zatwierdzenie: 1 · 1 już czeka"
|
||||
|
||||
@ -18,14 +18,12 @@ 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_add import COMMAND_NAME, NEW_PREFIX, PENDING_LIMIT, 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.pack_pending import listing
|
||||
from signal_bot.registry import CommandRegistry
|
||||
from signal_bot.registry_holder import RegistryHolder
|
||||
from tests import pack_fixture
|
||||
@ -83,12 +81,15 @@ async def test_a_typed_label_reaches_no_log_line_no_row_and_no_reply(
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
await dispatcher.handle(frame)
|
||||
|
||||
assert "dodano" in transport.texts[-1]
|
||||
assert "czeka na zatwierdzenie" 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
|
||||
# The word went exactly one place: the sidecar, inside the album's 0700.
|
||||
(entry,) = listing(holder.pack.directory)
|
||||
assert entry.labels == ("sekretnaetykieta",)
|
||||
|
||||
|
||||
async def test_the_container_copy_is_deleted_whether_accepted_or_refused(
|
||||
@ -103,94 +104,31 @@ async def test_the_container_copy_is_deleted_whether_accepted_or_refused(
|
||||
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."""
|
||||
"""The queue 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 len(listing(holder.pack.directory)) == 1
|
||||
assert "could not delete" in caplog.text
|
||||
|
||||
|
||||
async def test_the_sender_and_group_ride_along_for_the_approver_and_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",))
|
||||
(sidecar,) = (holder.pack.directory / "pending").glob("*.json")
|
||||
document = json.loads(sidecar.read_text(encoding="utf-8"))
|
||||
assert document["by"] == "uuid-alice"
|
||||
assert document["group"] == WATCHED_GROUP
|
||||
assert document["at"] == 1756600000
|
||||
|
||||
|
||||
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."""
|
||||
"""kuhy: NO CAP, NO LIMITS -- no cooldown, no size cap and no daily limit."""
|
||||
registry, _ = _registry(holder, FakeStripper())
|
||||
command = registry.commands[0]
|
||||
assert command.name == COMMAND_NAME
|
||||
@ -198,10 +136,65 @@ def test_no_decorator_is_applied_so_there_is_no_cap(holder: PackHolder) -> None:
|
||||
assert command.arg_resolver is not None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# The resolver on its own
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_new_label_travels_as_a_token_and_is_claimed_by_its_own_message(
|
||||
holder: PackHolder,
|
||||
) -> None:
|
||||
"""Frames are handled concurrently, so message A must not claim message B's label."""
|
||||
resolver = AddResolver(holder, CommandRegistry())
|
||||
first = resolver(("wakacje",))
|
||||
second = resolver(("gory",))
|
||||
assert first == (f"{NEW_PREFIX}1",)
|
||||
assert second == (f"{NEW_PREFIX}2",)
|
||||
assert resolver.take(second) == ((), ("gory",))
|
||||
assert resolver.take(first) == ((), ("wakacje",))
|
||||
# Claimed once: a second take of the same token is not a label any more.
|
||||
assert resolver.take(first) == (first, ())
|
||||
|
||||
|
||||
def test_two_new_labels_in_one_message_get_different_tokens(holder: PackHolder) -> None:
|
||||
resolver = AddResolver(holder, CommandRegistry())
|
||||
keys = resolver(("wakacje", "gory"))
|
||||
assert keys == (f"{NEW_PREFIX}1", f"{NEW_PREFIX}2")
|
||||
assert resolver.take(keys) == ((), ("wakacje", "gory"))
|
||||
|
||||
|
||||
def test_the_same_new_label_twice_in_one_message_is_one_token(holder: PackHolder) -> None:
|
||||
"""Spelt two ways that fold to one word, so the dedupe is by the folded form."""
|
||||
resolver = AddResolver(holder, CommandRegistry())
|
||||
assert resolver(("Wakacje", "wakacje")) == (f"{NEW_PREFIX}1", f"{NEW_PREFIX}1")
|
||||
|
||||
|
||||
def test_unclaimed_tokens_are_capped(holder: PackHolder) -> None:
|
||||
"""A muted sender resolves but never handles; those must not accrue for ever."""
|
||||
resolver = AddResolver(holder, CommandRegistry())
|
||||
oldest = resolver(("etykieta0",))
|
||||
for n in range(1, PENDING_LIMIT + 1):
|
||||
resolver((f"etykieta{n}",))
|
||||
assert resolver.take(oldest) == (oldest, ())
|
||||
newest = (f"{NEW_PREFIX}{PENDING_LIMIT + 1}",)
|
||||
assert resolver.take(newest) == ((), (f"etykieta{PENDING_LIMIT}",))
|
||||
|
||||
|
||||
def test_a_known_custom_label_resolves_without_a_token(tmp_path: Path) -> None:
|
||||
"""A label the album already has is reused, never proposed a second time."""
|
||||
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.take(("t1",)) == (("t1",), ())
|
||||
|
||||
|
||||
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
|
||||
scripts/personal_data_sources feeds proposed labels to the gate. Queuing
|
||||
"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.
|
||||
@ -211,16 +204,15 @@ def test_a_label_that_is_already_a_command_is_refused(holder: PackHolder) -> Non
|
||||
resolver = AddResolver(holder, registry)
|
||||
|
||||
assert resolver(("kot",)) == (UNKNOWN,)
|
||||
assert resolver.pending == {}
|
||||
assert resolver.take((UNKNOWN,)) == ((UNKNOWN,), ())
|
||||
|
||||
|
||||
def test_a_label_that_is_not_a_command_still_mints(holder: PackHolder) -> None:
|
||||
def test_a_label_that_is_not_a_command_still_gets_a_token(holder: PackHolder) -> None:
|
||||
registry = CommandRegistry()
|
||||
register_animal_commands(registry, _fetcher())
|
||||
resolver = AddResolver(holder, registry)
|
||||
|
||||
assert resolver(("wakacje",)) == ("t1",)
|
||||
assert resolver.pending == {"t1": "wakacje"}
|
||||
assert resolver(("wakacje",)) == (f"{NEW_PREFIX}1",)
|
||||
|
||||
|
||||
def _fetcher() -> ImageFetcher:
|
||||
|
||||
@ -1,56 +0,0 @@
|
||||
# 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
|
||||
|
||||
# Half a write: exactly what a page interrupted mid-save leaves behind.
|
||||
# (A tag naming a vanished file used to be the example here; since
|
||||
# 2026-09-04 that is skipped with a warning rather than refused.)
|
||||
(root / "tags.json").write_text('{"alfa.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
|
||||
179
tests/test_pack_pending.py
Normal file
179
tests/test_pack_pending.py
Normal file
@ -0,0 +1,179 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""The approval queue: in, listed, out -- and into the album only whole."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import stat
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from signal_bot.groups import ConfigError
|
||||
from signal_bot.pack import MEDIA_DIR, PEOPLE_FILE, TAGS_FILE
|
||||
from signal_bot.pack_custom import CUSTOM_FILE
|
||||
from signal_bot.pack_load import load
|
||||
from signal_bot.pack_pending import (
|
||||
PENDING_DIR,
|
||||
Pending,
|
||||
approve,
|
||||
discard,
|
||||
listing,
|
||||
names,
|
||||
queue,
|
||||
)
|
||||
from signal_bot.pack_store import ADDS_FILE
|
||||
from tests import pack_fixture
|
||||
from tests.conftest import WATCHED_GROUP
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
ONE = Pending("one.jpg", ("p1",), ("wakacje",), "uuid-alice", 100, WATCHED_GROUP)
|
||||
TWO = Pending("two.jpg", (), ("gory", "morze"), "uuid-bob", 50, WATCHED_GROUP)
|
||||
BODY = b"\xff\xd8\xff\xe0one"
|
||||
|
||||
|
||||
def _album(tmp_path: Path) -> Path:
|
||||
root = pack_fixture.build(tmp_path / "pack")
|
||||
queue(root, BODY, ONE)
|
||||
queue(root, b"\xff\xd8\xff\xe0two", TWO)
|
||||
return root
|
||||
|
||||
|
||||
def test_queued_entries_come_back_oldest_first_with_their_bytes(tmp_path: Path) -> None:
|
||||
root = _album(tmp_path)
|
||||
assert listing(root) == [TWO, ONE]
|
||||
assert names(root) == frozenset({"one.jpg", "two.jpg"})
|
||||
assert (root / PENDING_DIR / "one.jpg").read_bytes() == BODY
|
||||
for path in (root / PENDING_DIR).iterdir():
|
||||
assert stat.S_IMODE(path.stat().st_mode) == 0o600
|
||||
|
||||
|
||||
def test_an_album_with_no_queue_yet_has_nothing_waiting(tmp_path: Path) -> None:
|
||||
root = pack_fixture.build(tmp_path / "pack")
|
||||
assert listing(root) == []
|
||||
assert names(root) == frozenset()
|
||||
|
||||
|
||||
def test_a_broken_or_orphaned_sidecar_is_skipped_with_a_warning(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""The page must still show the other photos."""
|
||||
root = _album(tmp_path)
|
||||
(root / PENDING_DIR / "broken.jpg.json").write_text("{not json", encoding="utf-8")
|
||||
(root / PENDING_DIR / "short.jpg.json").write_text('{"name": "short.jpg"}', encoding="utf-8")
|
||||
(root / PENDING_DIR / "two.jpg").unlink()
|
||||
# A sidecar whose name is not its own filename could point the page anywhere.
|
||||
escaped = json.dumps(
|
||||
{**json.loads((root / PENDING_DIR / "one.jpg.json").read_text()), "name": "../people.json"}
|
||||
)
|
||||
(root / PENDING_DIR / "escaped.jpg.json").write_text(escaped, encoding="utf-8")
|
||||
with caplog.at_level(logging.WARNING, logger="signal_bot.pack_pending"):
|
||||
assert listing(root) == [ONE]
|
||||
assert caplog.text.count("skipping unreadable or orphaned queue entry") == 4
|
||||
|
||||
|
||||
def test_discard_removes_both_files_and_tolerates_absence(tmp_path: Path) -> None:
|
||||
root = _album(tmp_path)
|
||||
discard(root, "one.jpg")
|
||||
discard(root, "never-queued.jpg")
|
||||
assert listing(root) == [TWO]
|
||||
assert not (root / PENDING_DIR / "one.jpg.json").exists()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Approval
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_approving_moves_the_file_into_the_album_and_records_who_sent_it(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
root = _album(tmp_path)
|
||||
assert approve(root, "one.jpg", ("p1",), (), now=200) == ("p1",)
|
||||
assert (root / MEDIA_DIR / "one.jpg").read_bytes() == BODY
|
||||
assert json.loads((root / TAGS_FILE).read_text(encoding="utf-8"))["one.jpg"] == ["p1"]
|
||||
adds = json.loads((root / ADDS_FILE).read_text(encoding="utf-8"))
|
||||
assert adds["one.jpg"] == {"by": "uuid-alice", "at": 100, "approved_at": 200}
|
||||
assert listing(root) == [TWO]
|
||||
assert "one.jpg" in load(root).matching(frozenset({"p1"}))
|
||||
|
||||
|
||||
def test_a_proposed_label_is_minted_on_approval_and_reused_after(tmp_path: Path) -> None:
|
||||
root = _album(tmp_path)
|
||||
assert approve(root, "one.jpg", ("p1",), ("wakacje",), now=200) == ("p1", "t1")
|
||||
assert json.loads((root / CUSTOM_FILE).read_text(encoding="utf-8")) == {"t1": ["wakacje"]}
|
||||
|
||||
queue(root, b"\xff\xd8\xff\xe0three", Pending("three.jpg", (), ("wakacje",), "u", 1, "g"))
|
||||
assert approve(root, "three.jpg", (), ("wakacje",), now=201) == ("t1",)
|
||||
assert load(root).matching(frozenset({"t1"})) == ("one.jpg", "three.jpg")
|
||||
|
||||
|
||||
def test_the_approver_may_keep_fewer_labels_than_were_proposed(tmp_path: Path) -> None:
|
||||
root = _album(tmp_path)
|
||||
assert approve(root, "two.jpg", (), ("morze",), now=200) == ("t1",)
|
||||
assert json.loads((root / CUSTOM_FILE).read_text(encoding="utf-8")) == {"t1": ["morze"]}
|
||||
|
||||
|
||||
def test_a_label_nobody_proposed_is_refused_before_anything_is_written(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
root = _album(tmp_path)
|
||||
with pytest.raises(ValueError, match="not proposed"):
|
||||
approve(root, "one.jpg", ("p1",), ("wakacje", "wymyslona"), now=200)
|
||||
assert not (root / MEDIA_DIR / "one.jpg").exists()
|
||||
assert not (root / CUSTOM_FILE).exists()
|
||||
assert listing(root) == [TWO, ONE]
|
||||
|
||||
|
||||
def test_a_name_that_is_not_waiting_is_a_key_error(tmp_path: Path) -> None:
|
||||
root = _album(tmp_path)
|
||||
with pytest.raises(KeyError, match="not waiting"):
|
||||
approve(root, "ghost.jpg", (), (), now=200)
|
||||
|
||||
|
||||
def test_an_album_that_no_longer_loads_after_the_write_is_rolled_back(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A key the album has no person for: written, unloadable, undone -- and still queued."""
|
||||
root = _album(tmp_path)
|
||||
tags_before = (root / TAGS_FILE).read_bytes()
|
||||
with pytest.raises(ConfigError):
|
||||
approve(root, "one.jpg", ("p99",), (), now=200)
|
||||
assert not (root / MEDIA_DIR / "one.jpg").exists()
|
||||
assert (root / TAGS_FILE).read_bytes() == tags_before
|
||||
assert not (root / CUSTOM_FILE).exists()
|
||||
assert not (root / ADDS_FILE).exists()
|
||||
assert listing(root) == [TWO, ONE]
|
||||
|
||||
|
||||
def test_an_album_that_does_not_load_to_begin_with_writes_nothing(tmp_path: Path) -> None:
|
||||
root = _album(tmp_path)
|
||||
(root / PEOPLE_FILE).write_text("{broken", encoding="utf-8")
|
||||
with pytest.raises(ConfigError):
|
||||
approve(root, "one.jpg", ("p1",), (), now=200)
|
||||
assert not (root / MEDIA_DIR / "one.jpg").exists()
|
||||
assert listing(root) == [TWO, ONE]
|
||||
|
||||
|
||||
def test_rows_the_loader_drops_survive_an_approval(tmp_path: Path) -> None:
|
||||
"""tags.json is edited as a document: the tagger's "never opened" rows must stay."""
|
||||
root = _album(tmp_path)
|
||||
document = json.loads((root / TAGS_FILE).read_text(encoding="utf-8"))
|
||||
document["alfa.png"] = []
|
||||
(root / TAGS_FILE).write_text(json.dumps(document), encoding="utf-8")
|
||||
approve(root, "one.jpg", ("p1",), (), now=200)
|
||||
after = json.loads((root / TAGS_FILE).read_text(encoding="utf-8"))
|
||||
assert after["alfa.png"] == []
|
||||
assert after["one.jpg"] == ["p1"]
|
||||
|
||||
|
||||
def test_an_album_the_tagger_has_not_reached_gets_its_first_tags_file(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
root = pack_fixture.build(tmp_path / "pack", write_tags=False)
|
||||
queue(root, BODY, ONE)
|
||||
approve(root, "one.jpg", ("p1",), (), now=200)
|
||||
assert json.loads((root / TAGS_FILE).read_text(encoding="utf-8")) == {"one.jpg": ["p1"]}
|
||||
@ -156,7 +156,10 @@ def test_it_is_scoped_and_named_by_pseudonym(holder: PackHolder) -> None:
|
||||
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."""
|
||||
"""The resolver reads the holder, so a swap is enough -- no re-register.
|
||||
|
||||
The swap is what ``POST /_reload`` does through the registry rebuild.
|
||||
"""
|
||||
root = holder.pack.directory
|
||||
(root / CUSTOM_FILE).write_text(
|
||||
json.dumps({"t1": ["wakacje"], "t2": ["gory"], "t3": ["nowe"]}), encoding="utf-8"
|
||||
@ -164,7 +167,7 @@ async def test_a_tag_minted_after_registration_still_resolves(
|
||||
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
|
||||
holder.pack = load(root)
|
||||
|
||||
await _run(holder, transport, store, config, "!losowe nowe")
|
||||
assert transport.sent[-1].filename == "gamma.png"
|
||||
|
||||
@ -109,6 +109,19 @@ def test_a_people_trigger_is_never_excused_by_the_vocabulary(
|
||||
assert "kot" in _words(sources, root, monkeypatch)
|
||||
|
||||
|
||||
def test_a_label_still_waiting_for_approval_is_forbidden_already(
|
||||
sources: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The queue's sidecar is the same typed text, one click before custom.json."""
|
||||
root = _album(tmp_path / "pack")
|
||||
pending = root / "pending"
|
||||
pending.mkdir()
|
||||
sidecar = {"name": "x.jpg", "keys": [], "labels": ["wujek_stefan"], "by": "u", "at": 1}
|
||||
(pending / "x.jpg.json").write_text(json.dumps(sidecar), encoding="utf-8")
|
||||
(pending / "broken.jpg.json").write_text("{ not json", encoding="utf-8")
|
||||
assert "wujek_stefan" in _words(sources, root, monkeypatch)
|
||||
|
||||
|
||||
def test_an_album_with_no_custom_file_still_works(
|
||||
sources: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
|
||||
@ -3,14 +3,18 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from signal_bot.config import DEFAULT_COMMONS_API_URL
|
||||
from signal_bot.portraits import PortraitCache
|
||||
from signal_bot.wiring import UNGATED, build_registry
|
||||
from tests import pack_fixture
|
||||
from tests.test_help_command import _offline_fetcher
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from signal_bot.config import Config
|
||||
from signal_bot.registry import Handler
|
||||
|
||||
@ -34,9 +38,11 @@ def _has_cooldown(handler: Handler) -> bool:
|
||||
|
||||
|
||||
def test_ungated_names_exactly_the_commands_registered_without_a_cooldown(
|
||||
config: Config,
|
||||
config: Config, tmp_path: Path
|
||||
) -> None:
|
||||
"""With an album wired, because !add_photo is one of the ungated ones."""
|
||||
fetcher = _offline_fetcher()
|
||||
registry = build_registry(config, fetcher, PortraitCache(fetcher, DEFAULT_COMMONS_API_URL))
|
||||
with_album = replace(config, photo_pack_dirs=(pack_fixture.build(tmp_path / "pack"),))
|
||||
registry = build_registry(with_album, fetcher, PortraitCache(fetcher, DEFAULT_COMMONS_API_URL))
|
||||
ungated = {c.name for c in registry.commands if not _has_cooldown(c.handler)}
|
||||
assert ungated == set(UNGATED)
|
||||
|
||||
64
tool/e2e_approve.py
Executable file
64
tool/e2e_approve.py
Executable file
@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
||||
"""Approve everything in the album's queue, the way the approver would.
|
||||
|
||||
Through the management page's own routes, not by calling pack_pending: the
|
||||
point of the run is that a photo sent to the chat reaches the album by the
|
||||
one door that exists, and that the bot answers with it immediately after,
|
||||
with no restart. Each item is approved with exactly the people and labels
|
||||
the sender proposed.
|
||||
|
||||
--manage-url <base-url> the management app scripts/e2e.sh booted
|
||||
|
||||
Exits non-zero if nothing was waiting, if any approval was refused, or if
|
||||
the bot did not rebuild its table afterwards.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
# The guard on the management app refuses any POST without this header.
|
||||
JSON_HEADERS = {"content-type": "application/json"}
|
||||
|
||||
|
||||
def approve_all(manage_url: str) -> int:
|
||||
"""Approve every queued item; report each; fail on any refusal."""
|
||||
with httpx.Client(base_url=manage_url, timeout=30) as client:
|
||||
waiting: list[dict[str, Any]] = client.get("/api/album/pending").json()["pending"]
|
||||
if not waiting:
|
||||
sys.stderr.write("Error: nothing is waiting for approval\n")
|
||||
return 1
|
||||
failures = 0
|
||||
for item in waiting:
|
||||
answer = client.post(
|
||||
f"/api/album/pending/{item['name']}/approve",
|
||||
json={"keys": item["keys"], "labels": item["labels"]},
|
||||
headers=JSON_HEADERS,
|
||||
)
|
||||
body = answer.json()
|
||||
if answer.status_code != httpx.codes.OK or not body.get("reloaded"):
|
||||
sys.stderr.write(f"Error: {item['name']}: {answer.status_code} {body}\n")
|
||||
failures += 1
|
||||
continue
|
||||
sys.stdout.write(f" approved {item['name']} as {body['keys']}\n")
|
||||
left = client.get("/api/album/pending").json()["pending"]
|
||||
if left:
|
||||
sys.stderr.write(f"Error: still waiting after approval: {left}\n")
|
||||
return 1
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Parse the one argument and approve the queue behind it."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--manage-url", required=True, metavar="BASE_URL")
|
||||
return approve_all(parser.parse_args().manage_url)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@ -16,6 +16,12 @@ from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
from signal_bot.external import run
|
||||
from signal_bot.media_strip import locate
|
||||
|
||||
# Resolved the way the bot resolves it: Arch keeps exiftool under
|
||||
# /usr/bin/vendor_perl, which a non-login shell's PATH does not have, and a
|
||||
# fixture builder that cannot find the stripper's own tool proves nothing.
|
||||
EXIFTOOL: Final = locate() or "exiftool"
|
||||
|
||||
# Warsaw, in an actual GPS tag. If any of this survives, the run must fail.
|
||||
LATITUDE: Final = "52.2297"
|
||||
@ -77,7 +83,7 @@ def _jpeg(workspace: Path, name: str, colour: str) -> bytes:
|
||||
)
|
||||
run_tool(
|
||||
[
|
||||
"exiftool",
|
||||
EXIFTOOL,
|
||||
"-q",
|
||||
"-overwrite_original",
|
||||
f"-GPSLatitude={LATITUDE}",
|
||||
@ -118,7 +124,7 @@ def _mp4(workspace: Path) -> bytes:
|
||||
)
|
||||
run_tool(
|
||||
[
|
||||
"exiftool",
|
||||
EXIFTOOL,
|
||||
"-q",
|
||||
"-overwrite_original",
|
||||
f"-QuickTime:GPSCoordinates={LATITUDE} {LONGITUDE} 0",
|
||||
@ -156,7 +162,7 @@ def _heic(workspace: Path) -> bytes:
|
||||
run_tool(["heif-enc", str(source), "-o", str(path)])
|
||||
run_tool(
|
||||
[
|
||||
"exiftool",
|
||||
EXIFTOOL,
|
||||
"-q",
|
||||
"-overwrite_original",
|
||||
f"-GPSLatitude={LATITUDE}",
|
||||
|
||||
@ -25,7 +25,7 @@ from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import httpx
|
||||
from e2e_fixtures import PROBE_CREATOR, PROBE_MAKE, build, run_tool
|
||||
from e2e_fixtures import EXIFTOOL, PROBE_CREATOR, PROBE_MAKE, build, run_tool
|
||||
|
||||
from signal_bot.media_tags import is_allowed
|
||||
|
||||
@ -50,7 +50,7 @@ def upload(base_url: str) -> int:
|
||||
|
||||
def _survivors(path: Path) -> list[str]:
|
||||
"""Every non-structural tag one stored file still reports."""
|
||||
report = run_tool(["exiftool", "-G0", "-s", "-all", str(path)])
|
||||
report = run_tool([EXIFTOOL, "-G0", "-s", "-all", str(path)])
|
||||
return [name for name, allowed in _tags(report) if not allowed]
|
||||
|
||||
|
||||
@ -66,12 +66,24 @@ def _tag(line: str) -> tuple[str, bool]:
|
||||
return f"{group}:{tag.strip()}", is_allowed(group, tag.strip(), value.strip())
|
||||
|
||||
|
||||
def _stored(directory: Path) -> list[Path]:
|
||||
"""Every media file the album holds: in the album, and waiting to be."""
|
||||
folders = (directory / "media", directory / "pending")
|
||||
return sorted(
|
||||
path
|
||||
for folder in folders
|
||||
if folder.is_dir()
|
||||
for path in folder.iterdir()
|
||||
# The queue keeps a JSON sidecar beside each file; it is not media.
|
||||
if path.suffix != ".json"
|
||||
)
|
||||
|
||||
|
||||
def verify_pack(directory: Path) -> int:
|
||||
"""Fail unless every strippable file in the album came out clean."""
|
||||
album = directory / "media"
|
||||
"""Fail unless every strippable file, stored or queued, came out clean."""
|
||||
failures: list[str] = []
|
||||
checked = 0
|
||||
for path in sorted(album.iterdir()):
|
||||
for path in _stored(directory):
|
||||
if path.suffix.lower() in UNSTRIPPED:
|
||||
sys.stdout.write(f" {path.name}: stored unstripped by decision, not checked\n")
|
||||
continue
|
||||
|
||||
@ -12,11 +12,13 @@ import type {
|
||||
AlbumState,
|
||||
Aliases,
|
||||
AliasesSaved,
|
||||
Approved,
|
||||
Commands,
|
||||
CommandSetting,
|
||||
EnvironmentView,
|
||||
Export,
|
||||
Group,
|
||||
PendingList,
|
||||
Restart,
|
||||
SettingBody,
|
||||
Status,
|
||||
@ -96,13 +98,23 @@ export const api = {
|
||||
get<Overview>(scoped("/api/analytics/overview", group)),
|
||||
analyticsTime: async (group: null | string): Promise<Times> =>
|
||||
get<Times>(scoped("/api/analytics/time", group)),
|
||||
approve: async (name: string, keys: string[], labels: string[]): Promise<Approved> => {
|
||||
return send<Approved>(`/api/album/pending/${encodeURIComponent(name)}/approve`, "POST", {
|
||||
keys,
|
||||
labels,
|
||||
});
|
||||
},
|
||||
commands: async (): Promise<Commands> => get<Commands>("/api/commands"),
|
||||
env: async (): Promise<EnvironmentView> => get<EnvironmentView>("/api/env"),
|
||||
exportSuggestions: async (): Promise<Export> =>
|
||||
send<Export>("/api/suggestions/export", "POST", {}),
|
||||
groups: async (): Promise<{ groups: Group[] }> => get<{ groups: Group[] }>("/api/groups"),
|
||||
pending: async (): Promise<PendingList> => get<PendingList>("/api/album/pending"),
|
||||
person: async (triggers: string[]): Promise<{ key: string }> =>
|
||||
send<{ key: string }>("/api/album/person", "POST", { triggers }),
|
||||
// An empty body, not none: the guard wants application/json on every POST.
|
||||
reject: async (name: string): Promise<unknown> =>
|
||||
send(`/api/album/pending/${encodeURIComponent(name)}/reject`, "POST", {}),
|
||||
restart: async (): Promise<Restart> =>
|
||||
send<Restart>("/api/restart-container", "POST", {}),
|
||||
saveAliases: async (name: string, triggers: string[]): Promise<AliasesSaved> =>
|
||||
|
||||
@ -55,6 +55,27 @@ export interface AlbumState {
|
||||
untagged_only: boolean;
|
||||
}
|
||||
|
||||
// One photo somebody sent with !dodaj, waiting for the approver. `keys` are
|
||||
// the people/labels the album already knew; `labels` are new words the
|
||||
// sender typed, which exist nowhere but the queue until one is approved.
|
||||
export interface PendingItem {
|
||||
at: number;
|
||||
keys: string[];
|
||||
labels: string[];
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface PendingList {
|
||||
pending: PendingItem[];
|
||||
}
|
||||
|
||||
export interface Approved {
|
||||
keys: string[];
|
||||
reason: null | string;
|
||||
reloaded: boolean;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface Restart {
|
||||
ok: boolean;
|
||||
stderr: string;
|
||||
|
||||
@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
|
||||
import { App } from "./app.tsx";
|
||||
import { ALBUM, ALIASES, COMMANDS, ENV, HEALTHY } from "./test/fixtures.ts";
|
||||
import { ALBUM, ALIASES, COMMANDS, ENV, HEALTHY, NOTHING_PENDING } from "./test/fixtures.ts";
|
||||
import { serve } from "./test/http.ts";
|
||||
|
||||
afterEach(() => {
|
||||
@ -12,6 +12,7 @@ afterEach(() => {
|
||||
|
||||
function everythingAnswers(): void {
|
||||
serve({
|
||||
"/api/album/pending": { body: NOTHING_PENDING },
|
||||
"/api/album/state": { body: ALBUM },
|
||||
"/api/aliases": { body: ALIASES },
|
||||
"/api/commands": { body: COMMANDS },
|
||||
|
||||
@ -8,6 +8,7 @@ import type {
|
||||
Aliases,
|
||||
Commands,
|
||||
EnvironmentView as EnvironmentView,
|
||||
PendingList,
|
||||
Status,
|
||||
Suggestions as SuggestionsView,
|
||||
} from "../api/types.ts";
|
||||
@ -71,6 +72,17 @@ export const FRESH: AlbumState = {
|
||||
untagged_only: false,
|
||||
};
|
||||
|
||||
// Two photos sent from the chat and not yet approved: one tagged with a
|
||||
// person the album knows plus a new word, one with a new word only.
|
||||
export const PENDING: PendingList = {
|
||||
pending: [
|
||||
{ at: 50, keys: [], labels: ["gory"], name: "second.png" },
|
||||
{ at: 100, keys: ["p1"], labels: ["wakacje"], name: "first.png" },
|
||||
],
|
||||
};
|
||||
|
||||
export const NOTHING_PENDING: PendingList = { pending: [] };
|
||||
|
||||
// One filed idea and one mistyped word. The word is a real typo of a real
|
||||
// command so the fixture reads the way the live table does; the bodies are
|
||||
// invented, like every other value in this file.
|
||||
|
||||
@ -2,7 +2,7 @@ import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
|
||||
import { ALBUM, FRESH, GROUP } from "../test/fixtures.ts";
|
||||
import { ALBUM, FRESH, GROUP, NOTHING_PENDING, PENDING } from "../test/fixtures.ts";
|
||||
import { serve } from "../test/http.ts";
|
||||
import { Album } from "./album.tsx";
|
||||
|
||||
@ -11,7 +11,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
it("says it is opening before the album arrives", () => {
|
||||
serve({ "/api/album/state": { body: ALBUM } });
|
||||
serve({ "/api/album/pending": { body: NOTHING_PENDING }, "/api/album/state": { body: ALBUM } });
|
||||
render(<Album />);
|
||||
expect(screen.getByText("Opening the album…")).toBeInTheDocument();
|
||||
});
|
||||
@ -20,7 +20,7 @@ it("asks who is in the album before asking you to tag it", async () => {
|
||||
// An album with no people.json has an empty roster, so the checkboxes
|
||||
// would be an empty list. The tagger gated its setup form on `configured`
|
||||
// and so does this.
|
||||
serve({ "/api/album/state": { body: FRESH } });
|
||||
serve({ "/api/album/pending": { body: NOTHING_PENDING }, "/api/album/state": { body: FRESH } });
|
||||
render(<Album />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "Set up the album" })).toBeInTheDocument();
|
||||
@ -29,7 +29,7 @@ it("asks who is in the album before asking you to tag it", async () => {
|
||||
});
|
||||
|
||||
it("goes straight to tagging once the album is configured", async () => {
|
||||
serve({ "/api/album/state": { body: ALBUM } });
|
||||
serve({ "/api/album/pending": { body: NOTHING_PENDING }, "/api/album/state": { body: ALBUM } });
|
||||
render(<Album />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("img")).toHaveAttribute("src", "/api/album/media/0");
|
||||
@ -38,6 +38,7 @@ it("goes straight to tagging once the album is configured", async () => {
|
||||
|
||||
it("sends a tag to the server when a person is ticked", async () => {
|
||||
const fetcher = serve({
|
||||
"/api/album/pending": { body: NOTHING_PENDING },
|
||||
"/api/album/state": { body: ALBUM },
|
||||
"/api/album/tag": { body: { status: "ok" } },
|
||||
});
|
||||
@ -49,3 +50,32 @@ it("sends a tag to the server when a person is ticked", async () => {
|
||||
const [, init] = fetcher.mock.calls.at(-1) as [string, { body: string }];
|
||||
expect(JSON.parse(init.body)).toEqual({ file: "one.png", keys: ["p1", "p2"] });
|
||||
});
|
||||
|
||||
it("puts the approval queue above the tagger while something is waiting", async () => {
|
||||
serve({ "/api/album/pending": { body: PENDING }, "/api/album/state": { body: ALBUM } });
|
||||
render(<Album />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("heading", { name: "Waiting for approval — 2" })).toBeInTheDocument();
|
||||
});
|
||||
const [queue, tagger] = screen.getAllByRole("heading", { level: 2 });
|
||||
expect(queue).toHaveTextContent("Waiting for approval");
|
||||
expect(tagger).toHaveTextContent("Photos");
|
||||
});
|
||||
|
||||
it("takes the queue down again once the last decision is made", async () => {
|
||||
const routes = {
|
||||
"/api/album/pending": { body: PENDING },
|
||||
"/api/album/pending/second.png/reject": { body: { status: "rejected" } },
|
||||
"/api/album/state": { body: ALBUM },
|
||||
};
|
||||
serve(routes);
|
||||
render(<Album />);
|
||||
await screen.findByRole("heading", { name: "Waiting for approval — 2" });
|
||||
// The server's queue is empty after this reject; the page asks it again.
|
||||
routes["/api/album/pending"] = { body: NOTHING_PENDING };
|
||||
await userEvent.click(screen.getAllByRole("button", { name: "Reject" })[0]!);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/Waiting for approval/)).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole("heading", { name: /Photos/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { AlbumFile, AlbumState } from "../api/types.ts";
|
||||
import type { AlbumFile, AlbumState, PendingItem } from "../api/types.ts";
|
||||
|
||||
import { api } from "../api/client.ts";
|
||||
import { Pending } from "./pending.tsx";
|
||||
import { Roster } from "./roster.tsx";
|
||||
import { Tagger } from "./tagger.tsx";
|
||||
|
||||
@ -12,9 +13,13 @@ import { Tagger } from "./tagger.tsx";
|
||||
// tagger gated its setup form on exactly this and so does this.
|
||||
export function Album(): React.JSX.Element {
|
||||
const [state, setState] = useState<AlbumState | null>(null);
|
||||
const [pending, setPending] = useState<PendingItem[]>([]);
|
||||
|
||||
const load = useCallback(() => {
|
||||
void api.album().then(setState);
|
||||
void api.pending().then((listed) => {
|
||||
setPending(listed.pending);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(load, [load]);
|
||||
@ -31,5 +36,12 @@ export function Album(): React.JSX.Element {
|
||||
if (!state.configured) {
|
||||
return <Roster known={state.known_groups} onSaved={load} people={state.people} />;
|
||||
}
|
||||
return <Tagger onAdded={load} onTag={tag} state={state} />;
|
||||
return (
|
||||
<>
|
||||
{/* The queue first: it is what somebody else is waiting on, and it is
|
||||
empty most of the time, so the tagger is where it always was. */}
|
||||
{pending.length > 0 && <Pending items={pending} onDecided={load} people={state.people} />}
|
||||
<Tagger onAdded={load} onTag={tag} state={state} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
132
web/src/views/pending.test.tsx
Normal file
132
web/src/views/pending.test.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
|
||||
import { ALBUM, PENDING } from "../test/fixtures.ts";
|
||||
import { serve } from "../test/http.ts";
|
||||
import { Pending } from "./pending.tsx";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const APPROVE = "/api/album/pending/first.png/approve";
|
||||
const REJECT = "/api/album/pending/first.png/reject";
|
||||
const DONE = { keys: ["p1", "t1"], reason: null, reloaded: true, status: "approved" };
|
||||
|
||||
function show(): ReturnType<typeof vi.fn> {
|
||||
const onDecided = vi.fn();
|
||||
render(<Pending items={PENDING.pending} onDecided={onDecided} people={ALBUM.people} />);
|
||||
return onDecided;
|
||||
}
|
||||
|
||||
function card(name: string): HTMLElement {
|
||||
return screen.getByTestId(`pending-${name}`);
|
||||
}
|
||||
|
||||
function lastBody(fetcher: ReturnType<typeof vi.fn>): unknown {
|
||||
const [, init] = fetcher.mock.calls.at(-1) as [string, { body: string }];
|
||||
return JSON.parse(init.body);
|
||||
}
|
||||
|
||||
it("shows every queued photo with what the sender proposed already ticked", () => {
|
||||
show();
|
||||
expect(screen.getByRole("heading", { name: "Waiting for approval — 2" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("img", { name: "waiting: first.png" })).toHaveAttribute(
|
||||
"src",
|
||||
"/api/album/pending/first.png/media",
|
||||
);
|
||||
const first = card("first.png");
|
||||
expect(first.querySelector<HTMLInputElement>("input")).toBeChecked();
|
||||
expect(screen.getAllByLabelText("new label: wakacje")[0]).toBeChecked();
|
||||
// A half-added person has no trigger yet; the key is what there is to show.
|
||||
expect(first).toHaveTextContent("p3");
|
||||
// Nothing proposed for second.png but its new word, so nobody is ticked.
|
||||
expect(card("second.png").querySelector<HTMLInputElement>("input")).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("approves with exactly the boxes left ticked", async () => {
|
||||
const fetcher = serve({ [APPROVE]: { body: DONE } });
|
||||
const onDecided = show();
|
||||
const first = card("first.png");
|
||||
const [alfa, beta] = [...first.querySelectorAll("input")];
|
||||
await userEvent.click(alfa!);
|
||||
await userEvent.click(beta!);
|
||||
await userEvent.click(screen.getAllByRole("button", { name: "Approve" })[1]!);
|
||||
await waitFor(() => {
|
||||
expect(onDecided).toHaveBeenCalled();
|
||||
});
|
||||
expect(fetcher.mock.calls.at(-1)?.[0]).toBe(APPROVE);
|
||||
expect(lastBody(fetcher)).toEqual({ keys: ["p2"], labels: ["wakacje"] });
|
||||
expect(screen.queryByTestId("pending-notice")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the bot's reason on the page when the photo is in but the bot did not reload", async () => {
|
||||
serve({ [APPROVE]: { body: { ...DONE, reason: "aliases for nothing", reloaded: false } } });
|
||||
const onDecided = show();
|
||||
await userEvent.click(screen.getAllByLabelText("new label: wakacje")[0]!);
|
||||
await userEvent.click(screen.getAllByRole("button", { name: "Approve" })[1]!);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("pending-notice")).toHaveTextContent("did not reload");
|
||||
});
|
||||
expect(screen.getByTestId("pending-notice")).toHaveTextContent("aliases for nothing");
|
||||
expect(onDecided).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says so when the bot did not reload and gave no reason either", async () => {
|
||||
serve({ [APPROVE]: { body: { ...DONE, reloaded: false } } });
|
||||
show();
|
||||
await userEvent.click(screen.getAllByRole("button", { name: "Approve" })[1]!);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("pending-notice")).toHaveTextContent("no reason given");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the server's refusal on the card and keeps it", async () => {
|
||||
serve({ [APPROVE]: { body: { detail: "labels not proposed for first.png" }, status: 400 } });
|
||||
const onDecided = show();
|
||||
await userEvent.click(screen.getAllByRole("button", { name: "Approve" })[1]!);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("pending-error")).toHaveTextContent("not proposed");
|
||||
});
|
||||
expect(onDecided).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says 'could not approve' when the failure is not the server's", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new TypeError("offline"))));
|
||||
show();
|
||||
await userEvent.click(screen.getAllByRole("button", { name: "Approve" })[1]!);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("pending-error")).toHaveTextContent("could not approve");
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects with an empty JSON body, which the guard requires", async () => {
|
||||
const fetcher = serve({ [REJECT]: { body: { status: "rejected" } } });
|
||||
const onDecided = show();
|
||||
await userEvent.click(screen.getAllByRole("button", { name: "Reject" })[1]!);
|
||||
await waitFor(() => {
|
||||
expect(onDecided).toHaveBeenCalled();
|
||||
});
|
||||
expect(fetcher.mock.calls.at(-1)?.[0]).toBe(REJECT);
|
||||
expect(lastBody(fetcher)).toEqual({});
|
||||
});
|
||||
|
||||
it("says 'could not reject' when the failure is not the server's", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(() => Promise.reject(new TypeError("offline"))));
|
||||
const onDecided = show();
|
||||
await userEvent.click(screen.getAllByRole("button", { name: "Reject" })[1]!);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("pending-error")).toHaveTextContent("could not reject");
|
||||
});
|
||||
expect(onDecided).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the server's reason for a reject it refused", async () => {
|
||||
serve({ [REJECT]: { body: { detail: "application/json is required" }, status: 400 } });
|
||||
show();
|
||||
await userEvent.click(screen.getAllByRole("button", { name: "Reject" })[1]!);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("pending-error")).toHaveTextContent("application/json");
|
||||
});
|
||||
});
|
||||
128
web/src/views/pending.tsx
Normal file
128
web/src/views/pending.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import type { PendingItem, Person } from "../api/types.ts";
|
||||
|
||||
import { api, ApiError } from "../api/client.ts";
|
||||
|
||||
// Everything !dodaj has queued, oldest first. Each card is one decision:
|
||||
// the photo goes into the album with the ticked people and labels, or it
|
||||
// goes away. A label the sender typed is a word that exists nowhere but
|
||||
// this queue until it is ticked here, which is the whole point of the queue.
|
||||
export function Pending(
|
||||
props: Readonly<{ items: PendingItem[]; onDecided: () => void; people: Person[] }>,
|
||||
): React.JSX.Element {
|
||||
// Kept up here because a decision removes its card: the bot's reason for
|
||||
// not reloading would vanish with the card that produced it.
|
||||
const [notice, setNotice] = useState("");
|
||||
|
||||
const decided = (note: string): void => {
|
||||
setNotice(note);
|
||||
props.onDecided();
|
||||
};
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2>Waiting for approval — {props.items.length}</h2>
|
||||
{notice !== "" && <p data-testid="pending-notice">{notice}</p>}
|
||||
{props.items.map((item) => {
|
||||
return (
|
||||
<Card item={item} key={item.name} onDecided={decided} people={props.people} />
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function flip(held: string[], value: string): string[] {
|
||||
return held.includes(value) ? held.filter((each) => each !== value) : [...held, value];
|
||||
}
|
||||
|
||||
function Card(
|
||||
props: Readonly<{ item: PendingItem; onDecided: (note: string) => void; people: Person[] }>,
|
||||
): React.JSX.Element {
|
||||
const [keys, setKeys] = useState<string[]>(props.item.keys);
|
||||
const [labels, setLabels] = useState<string[]>(props.item.labels);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const approve = (): void => {
|
||||
setError("");
|
||||
void api
|
||||
.approve(props.item.name, keys, labels)
|
||||
.then((answer) => {
|
||||
// The photo is in the album either way; a reload the bot refused is
|
||||
// its own sentence about something else, shown rather than undone.
|
||||
props.onDecided(
|
||||
answer.reloaded
|
||||
? ""
|
||||
: `in the album, but the bot did not reload: ${answer.reason ?? "no reason given"}`,
|
||||
);
|
||||
})
|
||||
.catch((error_: unknown) => {
|
||||
setError(error_ instanceof ApiError ? error_.message : "could not approve");
|
||||
});
|
||||
};
|
||||
|
||||
const reject = (): void => {
|
||||
setError("");
|
||||
void api
|
||||
.reject(props.item.name)
|
||||
.then(() => {
|
||||
props.onDecided("");
|
||||
})
|
||||
.catch((error_: unknown) => {
|
||||
setError(error_ instanceof ApiError ? error_.message : "could not reject");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<article data-testid={`pending-${props.item.name}`}>
|
||||
{/* By the name the queue lists it under, which the server checks
|
||||
against its own listing before opening anything. */}
|
||||
<img
|
||||
alt={`waiting: ${props.item.name}`}
|
||||
src={`/api/album/pending/${encodeURIComponent(props.item.name)}/media`}
|
||||
/>
|
||||
<ul>
|
||||
{props.people.map((person) => {
|
||||
return (
|
||||
<li key={person.key}>
|
||||
<label>
|
||||
<input
|
||||
checked={keys.includes(person.key)}
|
||||
onChange={() => {
|
||||
setKeys((current) => flip(current, person.key));
|
||||
}}
|
||||
type="checkbox"
|
||||
/>
|
||||
{person.triggers[0] ?? person.key}
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{props.item.labels.map((label) => {
|
||||
return (
|
||||
<li key={label}>
|
||||
<label>
|
||||
<input
|
||||
checked={labels.includes(label)}
|
||||
onChange={() => {
|
||||
setLabels((current) => flip(current, label));
|
||||
}}
|
||||
type="checkbox"
|
||||
/>
|
||||
new label: {label}
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<button onClick={approve} type="button">
|
||||
Approve
|
||||
</button>
|
||||
<button onClick={reject} type="button">
|
||||
Reject
|
||||
</button>
|
||||
{error !== "" && <p data-testid="pending-error">{error}</p>}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user