feat: one page to manage the bot, and the tagger folded into it
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run

./web.sh serves health, the configuration and the photo album on
127.0.0.1:8140 -- the port tool/tagger.py vacates. React 19, Vite 8,
TypeScript 6.0.3, ESLint 10 with strictTypeChecked + stylisticTypeChecked +
unicorn + sonarjs + perfectionist, Vitest 4 at 100% on all four metrics.

No coverage exclude list, including for main.tsx. This repository treats a
coverage omit as a suppression, and "the DOM bootstrap is awkward to assert"
is the same sentence as "this code is untested" -- so the bootstrap is a shim
thin enough to test in jsdom, and the album view holds its file list apart
from the album so the update path stopped carrying a "what if it is null"
branch that cannot happen. An untestable branch is an untested one.

TypeScript stays at 6.0.3: typescript-eslint 8.68.0 declares
typescript >=4.8.4 <6.1.0, and the fleet-wide allowlist entry says the
compiler waits for the linter. The freshness gate then caught two pins the
reference repo was carrying stale -- eslint-plugin-unicorn 73 -> 74 and a
floating node-version: "24" in CI -- both bumped rather than allowlisted.

Traps the gates found, all of them real:
- .gitignore has a bare lib/, which matches at any depth, so web/src/lib/
  would have been silently untracked. The source lives in src/api/.
- CI's shellcheck glob was `run.sh scripts/*.sh`, so tag.sh was unlinted in
  CI for its whole life and web.sh would have inherited that. Now ./*.sh.
- tsconfig.json is strict JSON, not JSONC: check-json is generic and
  tsconfig being commentable is a convention, not a requirement.
- The personal-data gate refused an invented phone number in a test, which
  is exactly what it is for.

The album's warning that names a command moved with the command, and every
path that pointed at tool/tagger.py now points at ./web.sh. The historical
mentions stay: they explain why the code is shaped as it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01416TegJ3sa8xL5qGGgrygP
This commit is contained in:
Krzysztof Rudnicki 2026-08-31 14:54:49 +02:00
parent 0b539fc327
commit a24d0d5905
44 changed files with 4421 additions and 568 deletions

4
.binary-allowlist Normal file
View File

@ -0,0 +1,4 @@
# The management page's only non-text asset. An SVG so it stays reviewable
# in a diff: the gate blocks the extension, not the content, and a PNG would
# need a negation in .gitignore as well as an entry here.
web/public/*.svg

View File

@ -74,7 +74,7 @@ SELFTEST_GROUPS=
# Optional, and private. The album the per-person photo commands draw
# from: a directory holding people.json, tags.json and media/. Everything
# identifying lives in there and nothing about it is in this repository --
# see DOCS-photo-pack.md, and tool/tagger.py for how it gets filled in.
# see DOCS-photo-pack.md, and ./web.sh for how it gets filled in.
#
# Leave it empty and the bot looks in $STATE_DIRECTORY/pack (systemd) and
# then $XDG_DATA_HOME/signal-bot/pack. Not finding one is normal: the

View File

@ -54,7 +54,7 @@ jobs:
- name: shellcheck
# --severity=style is the floor CI enforces; the local pre-commit hook
# uses the same flag so a version skew cannot pass locally and fail here.
run: shellcheck -x --severity=style run.sh scripts/*.sh
run: shellcheck -x --severity=style ./*.sh scripts/*.sh
- name: pytest (100% branch coverage)
run: .venv/bin/python -m pytest -q
@ -68,3 +68,24 @@ jobs:
# for eight hours on 2026-08-30.
- name: cold start
run: ./scripts/cold_start.sh
# The management page. Its own job rather than more steps on `check`: it
# needs a Node toolchain that the Python job has no use for, and the two
# halves fail for unrelated reasons.
web:
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "24.20.0"
- run: corepack enable
- name: install (exact pins, from the lockfile)
run: pnpm install --frozen-lockfile
- name: tsc --noEmit and eslint
run: pnpm lint
- name: vitest (100% coverage, no excludes)
run: pnpm coverage

6
.gitignore vendored
View File

@ -194,3 +194,9 @@ adds.json
# Wine/Bottles scratch dir, never part of the bot.
bottles/
# The management page's build inputs and reports. dist/ is already covered by
# the bare entry above, which matches at any depth.
node_modules/
web/coverage/
*.tsbuildinfo

View File

@ -104,7 +104,7 @@ answers that directly. Do not collapse the two jobs back together.
still reporting one has a metadata track the re-mux should have removed, and
refusing it is the correct outcome.
**Both entry points obey this.** `tool/tagger.py --import` used to be a
**Both entry points obey this.** `./web.sh --import` used to be a
verbatim `shutil.copy2`; since 2026-08-31 it runs the same stripper, so the
guarantee on this page covers the whole album and not just the chat path.

View File

@ -175,13 +175,13 @@ 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`**.
## Filling it in: tool/tagger.py
## Filling it in: ./web.sh
```sh
./tag.sh --import ~/photos # dedup, strip, import, then tag
./tag.sh --import ~/photos --dry-run # the report only
./tag.sh --untagged # only items with no tags yet
./tag.sh # carry on where you left off
./web.sh --import ~/photos # dedup, strip, import, then tag
./web.sh --import ~/photos --dry-run # the report only
./web.sh --untagged # only items with no tags yet
./web.sh # carry on where you left off
```
`tag.sh` sets up `.venv` on first use, so this works on a fresh clone with

152
DOCS-web-ui.md Normal file
View File

@ -0,0 +1,152 @@
# The management page
`./web.sh` serves one page on `127.0.0.1:8140`: whether the bot is alive, what
it is configured with, and the photo album's tagger. It replaces
`tool/tagger.py`, which served the album half of this on the same port.
```sh
./web.sh # the configured pack
./web.sh --dir ~/pack --port 8140
./web.sh --import ~/photos # dedup, strip and import, then serve
./web.sh --import ~/photos --dry-run # the report, changing nothing
./web.sh --untagged # only items with no tags yet
```
## Why it is started by hand
A systemd unit would be more convenient and is the wrong shape. This page can
rewrite `.env` — the file that decides which groups the bot answers in and who
may add it to one — and a unit would keep that writable on loopback all day
for the sake of a bookmark. Started by hand, the window is the few minutes
somebody is actually looking at it.
It is also a **second process**, not more routes on the bot. The bot's own
port already serves `POST /_selftest`, which posts for real into a real group;
putting a configuration writer beside it would put both behind one mistake.
## Why loopback is not the whole defence
Binding to `127.0.0.1` stops the network. It does not stop the browser already
running on this machine, and two attacks reach a loopback port from an
ordinary web page. `signal_bot/manage/guard.py` closes both, in about forty
lines, with nothing to configure and no session to keep:
- **Cross-origin form posts.** Any site can POST a `<form>` at
`127.0.0.1:8140` without reading the reply. A form can only send
`application/x-www-form-urlencoded`, `multipart/form-data` or `text/plain`,
so every mutating request must declare `application/json` — which a form
cannot. Anything else is **415**.
- **DNS rebinding.** A hostname the attacker controls is re-pointed at
`127.0.0.1` after the page loads, which makes the browser treat this app as
same-origin. The rebound request still carries the attacker's hostname, so
a `Host` that is not loopback is **421**.
Neither is authentication, and neither pretends to be. They are the two
cheapest things that make "only this machine" mean it.
## Editing .env
Three refusals, in this order, and the order is the point.
1. **A key that may not be written is refused before anything is parsed**, so
a read-only key cannot be smuggled past by making the rest of the file
invalid. `403`, naming the key.
2. **The whole candidate file is validated by the function startup uses** —
`load_config(candidate, use_dotenv=False)`. An edit this bot would refuse
at boot is refused at the form instead, with the journal's own sentence.
`400`.
3. **Only then is anything written.** A rejected edit leaves `.env`
byte-identical.
The write itself is line-by-line: only the `KEY=` line of a key that actually
changed is replaced, so the paragraphs explaining each setting survive an edit
made in a browser. A key the file never had is appended under a marker rather
than dropped among comments that explain something else. Temp-then-replace
with mode `0600` reapplied before the swap, because this file holds the
account's number and is edited from a tab that can be closed mid-save.
### What is read-only, and why
| Key | Why the page will not write it |
|---|---|
| `PHONE_NUMBER` | Identifies the account; the container holds the matching registration. |
| `API_BASE_URL` | Repointing it sends every outgoing message and attachment fetch elsewhere at the next restart. Strictly worse than changing the number. |
| `DATABASE_PATH` | Repointing it silently orphans the whole statistical history. |
| `PHOTO_PACK_DIR` | **Cannot be validated.** `_photo_pack_dirs()` reads `os.environ` directly rather than the mapping it is handed — deliberately, since that is what keeps the test suite away from a real album. |
| `BIND_HOST` / `BIND_PORT` | Moving the bot's port from a page that proxies to it. |
### Group settings need a group listing
`GROUP_IDS` and `SELFTEST_GROUPS` are editable, but only while `/api/groups`
has actually returned something. `parse_group_ids` checks *syntax*; startup
additionally requires each listed group to be one the bot really joined,
judged against a listing that was really read. So with the container down, a
syntactically valid write can name a group the next startup rejects — turning
a scoping mistake into a unit that will not start. The page says so and leaves
the other keys editable.
Group ids are **picked, never typed**, for the same reason the tagger picked
them: an id is 44 characters of base64.
> `SELFTEST_GROUPS` is editable and worth understanding before you widen it.
> It is what keeps `POST /_selftest` fail-closed; every group named in it is a
> group the bot can be made to post arbitrary text into.
## Routes
Everything under `/api`. The page itself is served from `web/dist` at every
other path, so a reload on a sub-view works.
| Method | Path | What it does |
|---|---|---|
| GET | `/api/status` | Proxies the bot's `/healthz`. Unreachable is a state, never an error. |
| GET | `/api/groups` | The live listing, or `[]` when the container is down. |
| GET | `/api/config` | The resolved config. The number is cut to its last four digits. |
| GET / PUT | `/api/env` | Read and write `.env`, per the rules above. |
| POST | `/api/selftest` | Proxied to the bot verbatim, including its 403. |
| POST | `/api/restart-container` | `docker restart`, through `signal_bot/external.py`. |
| GET | `/api/album/state` | The album, the roster and the groups to pick from. |
| POST | `/api/album/tag` | Record who is in one file. |
| GET | `/api/album/summary` | What each command would answer with. |
| POST | `/api/album/setup` | Write the roster, then prove the bot could load it. |
| POST | `/api/album/person` | Add one person mid-session. |
| GET | `/api/album/media/{index}` | One item, **by position**. |
That last one is not a style choice. A filename supplied by a client is a
path, and this directory holds private photographs; the route takes an `int`,
so FastAPI refuses a name with **422** before any handler code runs.
## Building it
`dist/` is a build output and this repository does not commit those.
`scripts/install_service.sh` builds it, and verifies the artefact rather than
trusting the exit code. Until it has been built the page answers **503** with
the command to run, because `StaticFiles` raises at construction on a missing
directory — mounting it unconditionally would take the app down on a fresh
clone, and in CI, where pytest never runs `pnpm build`.
```sh
pnpm --dir web install --frozen-lockfile
pnpm --dir web build
pnpm --dir web lint # tsc --noEmit + eslint strictTypeChecked
pnpm --dir web coverage # vitest, 100% with no exclude list
```
React 19, Vite 8, TypeScript **6.0.3**, ESLint 10 with `strictTypeChecked` +
`stylisticTypeChecked` + unicorn + sonarjs + perfectionist, Vitest 4 at 100%
on all four metrics. TypeScript stays on 6.0.3 because
`typescript-eslint@8.68.0` declares `typescript: ">=4.8.4 <6.1.0"`; the pin is
fleet-wide in `~/utils/dependency-freshness.allowlist.yaml` and clears itself
when the linter catches up. The compiler waits for the linter.
`web/tsconfig.json` is strict JSON rather than the usual JSONC: this
repository's `check-json` hook is generic and tsconfig being commentable is a
convention, not a requirement. The two things the comments used to say: `lib`
is `ESNext` while `target` stays `ES2023`, so iterator helpers are declared
without being emitted for; and every strictness flag is on, including
`noUncheckedIndexedAccess` and `exactOptionalPropertyTypes`.
There is **no coverage exclude list**, including for `src/main.tsx`. A
coverage omit is a suppression, and "the DOM bootstrap is awkward to assert"
is the same sentence as "this code is untested" — so the bootstrap is a shim
thin enough to test in jsdom instead.

View File

@ -170,18 +170,12 @@ its own number and no access to your chats at all.
### Why not Shelter / a second Signal app on the phone
Two independent reasons, both verified rather than assumed:
1. **A second app instance is not a second account.** Every Signal account
needs its own phone number, so Shelter never removed the actual blocker.
Signal's phone-number-free "Signal Login" is announced but not shipped.
2. **It cannot be installed anyway** on a phone with a Device Owner. Setting a
Device Owner makes Android enable `no_add_managed_profile`,
`no_add_private_profile` and `no_add_clone_profile` on every user — killing
Shelter, Island, Private Space and app cloning alike. Verified on-device:
`pm create-user --profileOf 0 --managed` returns
`Cannot add user: no_add_managed_profile is enabled`. This is the Android 11
COMP deprecation and is unchanged through Android 16.
Two reasons, both verified rather than assumed. **A second app instance is not
a second account** — every Signal account needs its own phone number, so
Shelter never removed the actual blocker. And **it cannot be installed anyway**
on a phone with a Device Owner: Android then enables `no_add_managed_profile`
and its two siblings on every user, killing Shelter, Island, Private Space and
cloning alike. Unchanged through Android 16.
## When it cannot hear, and who may add it
@ -231,10 +225,16 @@ at 0, up to three highlights. Sundays close a week, month ends a month. Ask it
things: `!staty` `!wczoraj` `!tydzień` `!miesiąc` `!rok` `!rekordy` `!korona`
`!dni` `!ja`. Full behaviour: `DOCS-statistics.md`.
## Managing it
`./web.sh` serves one page on `127.0.0.1:8140`: health, the configuration
(editable) and the album's tagger. Started by hand and stopped again, because
it can rewrite `.env`: [DOCS-web-ui.md](DOCS-web-ui.md).
## Develop
```sh
pytest # 708 tests, 100% branch coverage, no network
pytest # 100% branch coverage, no network
pre-commit run --all-files # ruff ALL + mypy strict + 250-line cap + gates
./scripts/e2e.sh # the real bot against the fake server
```
@ -243,7 +243,7 @@ No `# noqa`, no `# type: ignore`, no coverage omits. Never bump `SCHEMA_VERSION`
read `signal_bot/schema.py` first; a bump DROPs every table and throws the live tallies away, while
`CREATE TABLE IF NOT EXISTS` is additive and safe. Wire format: [DOCS-signal-envelopes.md](DOCS-signal-envelopes.md).
Releases are named after the popes, in order and in Latin — 0.1.4 is «Eleutherius» — and a bump publishes to the group as the pope's portrait, name and article: [DOCS-changelog.md](DOCS-changelog.md). Latency: [DOCS-latency.md](DOCS-latency.md).
Signal never delivers an account its own sends: `scripts/selftest.sh testbot '!papież' 3` drives the live dispatcher instead, [DOCS-selftest.md](DOCS-selftest.md).
Signal never delivers an account its own sends: `scripts/selftest.sh testbot '!papież' 3` drives the live dispatcher instead, [DOCS-selftest.md](DOCS-selftest.md). The page has its own gates: `pnpm --dir web lint` and `pnpm --dir web coverage`, both at 100% with no excludes.
## Licence

View File

@ -87,10 +87,30 @@ ensure_ffmpeg() {
fi
}
# The management page is built, not committed: dist/ is a build output and
# this repository does not carry those. Verified by looking at the artefact
# rather than trusting the exit code, the same way exiftool is.
build_web() {
local web="${REPO_ROOT}/web"
if ! command -v pnpm >/dev/null; then
echo "Installing pnpm (the management page is built with it)..."
if ! sudo pacman -S --needed --noconfirm pnpm; then
die "could not install pnpm; run: sudo pacman -S pnpm"
fi
fi
command -v node >/dev/null || die "node not found; the page needs node >= 24"
echo "Building the management page..."
(cd "$web" && pnpm install --frozen-lockfile && pnpm build) ||
die "pnpm build failed in ${web}"
[[ -f "${web}/dist/index.html" ]] ||
die "pnpm build reported success but ${web}/dist/index.html is not there"
}
validate_requirements() {
command -v systemctl >/dev/null || die "systemctl not found; this needs systemd"
ensure_exiftool
ensure_ffmpeg
build_web
[[ -f "${REPO_ROOT}/.env" ]] ||
die "no ${REPO_ROOT}/.env -- run scripts/register_bot.sh first"
[[ -x "${REPO_ROOT}/run.sh" ]] || die "no executable ${REPO_ROOT}/run.sh"

View File

@ -8,7 +8,7 @@ people and custom tags in different files is what makes that guarantee cheap:
``people.json`` is edited by the tagger and defines commands, ``custom.json``
is appended to from the chat and defines no commands at all.
Written atomically, temp-then-replace, exactly as ``tool/tagger.py::Album.write``
Written atomically, temp-then-replace, exactly as ``pack_album.Album.write``
already does, because the alternative is a half-written manifest that fails the
next load and takes startup down.

View File

@ -2,7 +2,7 @@
"""The one mutable box the pack lives in, so a new photo is visible at once.
``pack_command.pack_handler`` used to close over a frozen ``Pack``. That was
right while the only writer was ``tool/tagger.py`` and a restart followed
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.

View File

@ -1,7 +1,7 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Importing a folder of photos into the album without importing it twice.
``tool/tagger.py --import`` used to be a filename check and ``shutil.copy2``.
``./web.sh --import`` used to be a filename check and ``shutil.copy2``.
Pointed at a phone export that overlaps what is already tagged, that copies
the same photo in again under whatever name the exporter gave it that time,
and copies the metadata in with it. Both are fixed here:

View File

@ -225,7 +225,7 @@ def _tags(directory: Path, people: set[str]) -> dict[str, frozenset[str]]:
"""
path = directory / TAGS_FILE
if not path.exists():
logger.warning("photo pack %s has no %s yet; run tool/tagger.py", directory, TAGS_FILE)
logger.warning("photo pack %s has no %s yet; run ./web.sh", directory, TAGS_FILE)
return {}
document = _document(path)
tags: dict[str, frozenset[str]] = {}

View File

@ -2,7 +2,7 @@
"""Putting a file into the album, and taking it back out when the swap fails.
Every write here is temp-then-``Path.replace``, the pattern
``tool/tagger.py::Album.write`` already uses, because a manifest that is half
``pack_album.Album.write`` already uses, because a manifest that is half
written is a manifest that fails the next load -- and with ``Restart=always``
a startup-time ConfigError is a loop, not an error message.

View File

@ -151,10 +151,11 @@ def test_one_entry_may_spell_itself_two_ways_that_fold_the_same(tmp_path: Path)
def test_an_untagged_pack_loads_and_warns(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
"""Not an error: that is an album waiting for the tagger, not a broken one."""
"""Not an error: that is an album waiting to be tagged, not a broken one."""
pack = _load(tmp_path, write_tags=False)
assert pack.tags == {}
assert "tagger" in caplog.text
# The warning names the command to run, so it has to move with it.
assert "./web.sh" in caplog.text
def test_a_tag_naming_a_file_that_is_gone_is_refused(tmp_path: Path) -> None:

View File

@ -1,5 +1,5 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Every script under tool/ imports, and the tagger answers --help.
"""Every script under tool/ imports, and the launcher answers --help.
This exists because ``tool/tagger.py`` shipped an unconditional ImportError
and survived two releases with it: commit 48957a1 renamed
@ -66,11 +66,11 @@ def test_the_module_imports(path: Path) -> None:
sys.modules.pop(spec.name, None)
def test_the_tagger_answers_help() -> None:
def test_the_launcher_answers_help() -> None:
# Through signal_bot.external, like every other subprocess here: S603 is
# suppressed for exactly one file and widening it is what that entry
# exists to prevent.
result = run([sys.executable, str(TOOL / "tagger.py"), "--help"])
result = run([sys.executable, str(TOOL / "web.py"), "--help"])
assert result.ok, result.stderr
for flag in ("--import", "--untagged", "--dry-run"):
assert flag in result.stdout

View File

@ -1,327 +0,0 @@
<!doctype html>
<!-- Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
The tagger's whole interface. Served only on 127.0.0.1, by tool/tagger.py.
No framework and no network calls but its own: this page shows private
photos, and the only thing it may talk to is the process that opened it. -->
<html lang="pl">
<head>
<meta charset="utf-8">
<title>Tagowanie zdjęć</title>
<style>
:root { --bg: #1b1d21; --fg: #f2f3f5; --dim: #9aa0a6; --accent: #7aa2f7; }
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg);
font: 16px/1.5 system-ui, sans-serif; }
main { max-width: 900px; margin: 0 auto; padding: 24px; }
h1 { font-size: 20px; margin: 0 0 16px; }
.stage { display: flex; align-items: center; justify-content: center;
height: 62vh; background: #101214; border-radius: 12px; }
.stage img, .stage video { max-width: 100%; max-height: 62vh; border-radius: 12px; }
.people { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0; }
.person { border: 1px solid #33363d; border-radius: 999px; padding: 8px 14px;
cursor: pointer; user-select: none; background: #24262b; }
.person.on { background: var(--accent); color: #10131a; border-color: var(--accent); }
.bar { display: flex; align-items: center; justify-content: space-between;
gap: 16px; color: var(--dim); }
button { background: #24262b; color: var(--fg); border: 1px solid #33363d;
border-radius: 8px; padding: 8px 14px; cursor: pointer; }
textarea { width: 100%; min-height: 140px; background: #101214; color: var(--fg);
border: 1px solid #33363d; border-radius: 8px; padding: 12px;
font-family: ui-monospace, monospace; }
.hint { color: var(--dim); font-size: 14px; }
.addperson { display: flex; gap: 8px; align-items: center; margin: 4px 0 16px; }
.addperson input { background: #10131a; color: inherit; border: 1px solid #33363d;
border-radius: 8px; padding: 8px 12px; font: inherit; }
.err { color: #f7768e; }
label.group { display: block; padding: 4px 0; }
pre.sample { background: #101214; border: 1px solid #33363d; border-radius: 8px;
padding: 12px; overflow-x: auto; }
.dim { color: #6b7280; }
</style>
</head>
<body>
<main>
<div id="setup" hidden>
<h1>Kto jest w tej paczce?</h1>
<p class="hint">
<b>Jedna osoba na linię</b> — Enter po każdej. Bez wykrzyknika.
Przecinek to <b>inna nazwa tej samej osoby</b>, nie następna osoba.
</p>
<pre class="hint sample">ala
basia
celina, cela <span class="dim">← jedna osoba, dwie nazwy</span>
darek
oboje = ala + basia <span class="dim">← zdjęcie, na którym są obie</span>
ktokolwiek = ala | basia <span class="dim">← losowo jedna z nich</span></pre>
<textarea id="names" spellcheck="false"></textarea>
<h1>W której grupie działają?</h1>
<div id="groups" class="hint">szukam grup…</div>
<p><button id="save">Zapisz i zacznij tagowanie</button></p>
<p id="error" class="err"></p>
</div>
<div id="done" hidden>
<h1>Gotowe.</h1>
<p class="hint">Wszystko zapisane. Komendy i ile mają zdjęć:</p>
<pre class="hint sample" id="counts"></pre>
<p><button id="again">← wróć do zdjęć</button></p>
</div>
<div id="tagger" hidden>
<div class="bar">
<h1 id="progress"></h1>
<span class="hint">1–9 (0 = dziesiąta) = osoba, → dalej, ← wstecz</span>
</div>
<div class="stage" id="stage"></div>
<div class="people" id="people"></div>
<div class="addperson">
<input id="newperson" placeholder="nowa osoba: imie, ksywka" autocomplete="off">
<button id="addperson">dodaj osobę</button>
<span id="addederror" class="hint"></span>
</div>
<div class="bar">
<button id="prev">← wstecz</button>
<span id="saved" class="hint"></span>
<button id="next">dalej →</button>
</div>
</div>
</main>
<script>
const api = (path, body) => fetch(path, body ? {
method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(body),
} : undefined).then(async (r) => {
if (!r.ok) { throw new Error((await r.json()).detail || r.statusText); }
return r.json();
});
let state = null;
let at = 0;
function parseSetup(text) {
const people = [], combos = [];
const byName = new Map();
for (const raw of text.split("\n").map((l) => l.trim()).filter(Boolean)) {
if (raw.includes("=")) { continue; }
const triggers = raw.split(",")
.map((t) => "!" + t.trim().replace(/^!/, ""))
.filter((t) => t.length > 1);
if (!triggers.length) { continue; }
// Reuse the key this person already has. Handing out p{length+1} on
// every save renumbers everyone after an insertion, and every tags.json
// entry then points at the wrong person, silently.
const existing = (state.people || [])
.find((q) => q.triggers.some((t) => triggers.includes(t)));
const k = existing ? existing.key : `p${nextKey(people)}`;
people.push({key: k, triggers});
// Every alias, not just the first: a combination may name a person by
// whichever spelling its author remembers.
for (const trigger of triggers) { byName.set(trigger.slice(1), k); }
}
let n = 1;
for (const raw of text.split("\n").map((l) => l.trim()).filter(Boolean)) {
if (!raw.includes("=")) { continue; }
const [left, right] = raw.split("=");
const every = right.includes("+");
const parts = right.split(every ? "+" : "|").map((p) => p.trim().replace(/^!/, ""));
const unknown = parts.filter((p) => !byName.has(p));
if (unknown.length) { throw new Error(`nie znam: ${unknown.join(", ")}`); }
const keys = parts.map((p) => byName.get(p));
const triggers = left.split(",")
.map((t) => "!" + t.trim().replace(/^!/, ""))
.filter((t) => t.length > 1);
combos.push(every
? {key: `c${n}`, triggers, all_of: keys}
: {key: `c${n}`, triggers, any_of: keys});
n += 1;
}
return {people, combos};
}
// One past the highest number in use, never the count: deleting p3 by hand
// must not let a later add reuse the key and inherit its photos. Mirrors
// pack_people.next_key on the server, which is the authority.
function nextKey(pending) {
const used = [...(state.people || []), ...pending]
.map((p) => Number(String(p.key || "").replace(/^p/, "")))
.filter((n) => Number.isInteger(n) && n > 0);
return Math.max(0, ...used) + 1;
}
function drawGroups(known, chosen) {
const box = document.getElementById("groups");
if (!known.length) {
box.innerHTML = '<input id="rawgroup" style="width:100%" placeholder="group.…=">';
return;
}
// Nothing is pre-selected. The first group in the listing is whichever
// one the account happens to return first -- checking it by default is a
// one-click way to scope a private album to the wrong chat.
box.innerHTML = known.map((g) => `<label class="group">
<input type="radio" name="group" value="${g.id}" ${chosen.includes(g.id) ? "checked" : ""}>
${g.name || "(bez nazwy)"} <span class="hint">${g.id.slice(0, 18)}…</span></label>`).join("");
}
function chosenGroups() {
const raw = document.getElementById("rawgroup");
if (raw) { return [raw.value.trim()]; }
const picked = document.querySelector('input[name="group"]:checked');
return picked ? [picked.value] : [];
}
function entries() {
return [...state.people];
}
// One expression, used by both the chip label and the key handler. Those
// used to be two independent expressions -- "i === 9 ? 0 : i + 1" here and
// "key === '0' ? 9 : Number(key) - 1" in the handler -- encoding one mapping
// twice. They agreed, and would not have survived an edit.
const HOTKEYS = [..."1234567890", ..."qwertyuiop"];
// .mp4 alone left .mov/.m4v/.webm rendering as a broken <img>.
const VIDEO = [".mp4", ".mov", ".m4v", ".webm"];
function isVideo(name) {
const lower = name.toLowerCase();
return VIDEO.some((suffix) => lower.endsWith(suffix));
}
// Which items this session presents. The server never filters: /media/{index}
// and /api/tag both index the full list, so a shorter list here would show
// item i of the album while tagging item i of the filtered set.
// Anything seen this session stays navigable even once it has tags. Without
// this, tagging an item removes it from the set and the left arrow jumps
// straight over your own work -- so a mis-tag is unreachable and the only way
// back is to restart without --untagged.
const visited = new Set();
function shown(index) {
return !state.untagged_only || !state.files[index].keys.length || visited.has(index);
}
function step(from, direction) {
for (let i = from + direction; i >= 0 && i < state.files.length; i += direction) {
if (shown(i)) { return i; }
}
return -1;
}
function draw() {
visited.add(at);
const item = state.files[at];
document.getElementById("progress").textContent =
`${at + 1} / ${state.files.length} — ${item.name}`;
const stage = document.getElementById("stage");
stage.innerHTML = isVideo(item.name)
? `<video src="/media/${at}" controls autoplay muted loop></video>`
: `<img src="/media/${at}" alt="">`;
document.getElementById("people").innerHTML = entries().map((p, i) => `
<div class="person ${item.keys.includes(p.key) ? "on" : ""}" data-key="${p.key}">
${HOTKEYS[i] ? HOTKEYS[i] + ". " : ""}${p.triggers[0].slice(1)}
</div>`).join("");
for (const node of document.querySelectorAll(".person")) {
node.onclick = () => toggle(node.dataset.key);
}
}
async function toggle(k) {
const item = state.files[at];
item.keys = item.keys.includes(k) ? item.keys.filter((x) => x !== k) : [...item.keys, k];
draw();
await api("/api/tag", {file: item.name, keys: item.keys});
document.getElementById("saved").textContent = "zapisano";
setTimeout(() => { document.getElementById("saved").textContent = ""; }, 800);
}
async function move(direction) {
const next = step(at, direction);
if (next < 0) {
if (direction < 0) { return; }
// The end of the album is a screen, not a button that stops working.
await finish();
return;
}
at = next;
draw();
}
async function finish() {
const {lines} = await api("/api/summary");
document.getElementById("counts").textContent = lines.join("\n");
document.getElementById("tagger").hidden = true;
document.getElementById("done").hidden = false;
}
document.addEventListener("keydown", (event) => {
if (document.getElementById("tagger").hidden) { return; }
if (event.key === "ArrowRight" || event.key === "Enter") { move(1); }
else if (event.key === "ArrowLeft") { move(-1); }
else if (event.target.tagName !== "INPUT") {
const index = HOTKEYS.indexOf(event.key);
const person = index < 0 ? null : entries()[index];
if (person) { toggle(person.key); }
}
});
async function start() {
state = await api("/api/state");
if (state.configured) {
document.getElementById("tagger").hidden = false;
at = Math.max(0, state.files.findIndex((f) => !f.keys.length));
if (!shown(at)) {
const first = step(-1, 1);
if (first < 0) { await finish(); return; }
at = first;
}
draw();
return;
}
document.getElementById("setup").hidden = false;
drawGroups(state.known_groups, state.groups);
document.getElementById("save").onclick = async () => {
try {
const {people, combos} = parseSetup(document.getElementById("names").value);
if (!people.length) { throw new Error("dodaj przynajmniej jedną osobę"); }
if (!chosenGroups().filter(Boolean).length) { throw new Error("wybierz grupę"); }
await api("/api/setup", {groups: chosenGroups(), people, combos});
location.reload();
} catch (error) {
document.getElementById("error").textContent = error.message;
}
};
}
async function addPerson() {
const field = document.getElementById("newperson");
const error = document.getElementById("addederror");
error.textContent = "";
const triggers = field.value.split(",").map((t) => t.trim()).filter(Boolean);
if (!triggers.length) { return; }
try {
await api("/api/person", {triggers});
// Re-read rather than patch in place: the server assigns the key, and
// guessing it here is how the two ends drift apart.
const fresh = await api("/api/state");
state.people = fresh.people;
field.value = "";
draw();
} catch (err) {
error.textContent = err.message;
}
}
document.getElementById("addperson").onclick = addPerson;
document.getElementById("newperson").addEventListener("keydown", (event) => {
if (event.key === "Enter") { event.stopPropagation(); addPerson(); }
});
document.getElementById("prev").onclick = () => move(-1);
document.getElementById("next").onclick = () => move(1);
document.getElementById("again").onclick = () => {
document.getElementById("done").hidden = true;
document.getElementById("tagger").hidden = false;
draw();
};
start();
</script>
</body>
</html>

View File

@ -1,201 +0,0 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Tag a private photo album, in a browser, on this machine only.
Sixty-odd photos and seven people is not a hand-edited JSON file. This serves
one page on 127.0.0.1 that shows each item in turn with a numbered checkbox
per person, saves on every keystroke, and never leaves the machine.
Names are typed *into the page*, never as arguments: an argument lands in the
shell history, and the whole design of the pack is that the names exist in
one directory and nowhere else.
Either entry point sets up ``.venv`` on first use -- the dependencies are not
in the system interpreter, and a script that tells you to go and install them
yourself is a script that does not own its dependencies.
python tool/tagger.py --untagged # bootstraps, then runs
./tag.sh # the configured pack
./tag.sh --dir ~/pack --port 8140
./tag.sh --import ~/photos # dedup, strip and import, then tag
./tag.sh --import ~/photos --dry-run # the report, changing nothing
./tag.sh --untagged # only items with no tags yet
It prints a summary when it stops: how many items are untagged, and how many
photos each command would have. A command with zero is dead on arrival, and
this is where that shows up rather than in the group.
"""
from __future__ import annotations
# Importing this sets up .venv if the interpreter running us cannot import
# what follows. It has to come first, and it is an import rather than a call
# so that nothing here needs an E402 suppression. "python tool/tagger.py"
# puts tool/ on sys.path, which is what makes the plain name resolve.
from venv_bootstrap import REPO_ROOT
import argparse
import sys
import webbrowser
from pathlib import Path
from typing import TYPE_CHECKING, Any, Final
import httpx
import uvicorn
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, HTMLResponse
from signal_bot.config_env import load_config
from signal_bot.groups import ConfigError
from signal_bot.media_strip import ExiftoolStripper
from signal_bot.pack import MEDIA_DIR, default_dirs
from signal_bot.pack_album import Album
from signal_bot.pack_import import plan_import, run_import
if TYPE_CHECKING:
from signal_bot.pack_report import ImportReport
PAGE: Final = REPO_ROOT / "tool" / "tagger.html"
DEFAULT_PORT: Final = 8140
def live_groups() -> list[dict[str, str]]:
"""The groups this account is in, so an id is picked and never typed."""
try:
config = load_config()
response = httpx.get(config.groups_url, timeout=5.0)
response.raise_for_status()
listing = response.json()
except ConfigError, httpx.HTTPError, ValueError:
# No container, no .env, no network: none of them stop the tagging,
# they only mean the group id has to be pasted instead of picked.
return []
return [
{"id": str(entry.get("id", "")), "name": str(entry.get("name", ""))}
for entry in listing
if entry.get("id")
]
def roster_routes(app: FastAPI, album: Album) -> None:
"""The two endpoints that write people.json.
Split from build_app to keep it under the complexity gate, along the
seam that matters: everything here can refuse, and refusing has to reach
the page rather than the next restart.
"""
@app.post("/api/setup")
async def setup(payload: dict[str, Any]) -> dict[str, str]:
try:
album.setup(
list(payload.get("groups", [])),
list(payload.get("people", [])),
list(payload.get("combos", [])),
)
except ConfigError as error:
# Straight into the page: a manifest the bot would refuse should
# be fixed here, in the form, not found at the next restart.
raise HTTPException(status_code=400, detail=str(error)) from error
return {"status": "ok"}
@app.post("/api/person")
async def person(payload: dict[str, Any]) -> dict[str, str]:
# Mid-session, not only at setup: the reason to add somebody is
# usually a photo of them already on the screen.
try:
key = album.add_person([str(t) for t in payload.get("triggers", [])])
except ConfigError as error:
raise HTTPException(status_code=400, detail=str(error)) from error
return {"status": "ok", "key": key}
def build_app(album: Album) -> FastAPI:
"""The one-page application, bound to one album."""
app = FastAPI()
roster_routes(app, album)
@app.get("/", response_class=HTMLResponse)
async def page() -> str:
return PAGE.read_text(encoding="utf-8")
@app.get("/api/state")
async def state() -> dict[str, Any]:
return {**album.state(), "known_groups": live_groups()}
@app.post("/api/tag")
async def tag(payload: dict[str, Any]) -> dict[str, str]:
try:
album.tag(str(payload["file"]), [str(key) for key in payload.get("keys", [])])
except KeyError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
return {"status": "ok"}
@app.get("/api/summary")
async def summary() -> dict[str, list[str]]:
# The same lines the tagger prints when it stops. Shown in the page
# too, because "I tagged everything, did it save?" is a question the
# last photo used to answer by doing nothing at all.
return {"lines": album.summary()}
@app.get("/media/{index}")
async def media(index: int) -> FileResponse:
# By index, never by a name the page sends back: a filename from a
# client is a path, and portraits.path_for exists for the same reason.
files = album.files
if not 0 <= index < len(files):
raise HTTPException(status_code=404, detail="no such item")
return FileResponse(album.directory / MEDIA_DIR / files[index])
return app
def main() -> int:
"""Serve the tagger until it is stopped, then print the summary."""
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("--dir", type=Path, default=None, help="the pack directory")
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
parser.add_argument(
"--import", dest="source", type=Path, default=None, help="import media from here first"
)
parser.add_argument(
"--dry-run", action="store_true", help="report what --import would do, change nothing"
)
parser.add_argument(
"--untagged", action="store_true", help="present only items with no tags yet"
)
parser.add_argument("--no-open", action="store_true", help="do not open a browser")
arguments = parser.parse_args()
directory = arguments.dir or default_dirs()[0][0]
if arguments.source is not None:
report = _import(arguments.source, directory, dry_run=arguments.dry_run)
sys.stdout.write("\n".join(report.lines()) + "\n\n")
if arguments.dry_run:
# Nothing was written, so there is nothing to tag and no reason to
# open a browser at a server this run is not going to start.
return 0
album = Album(directory, untagged_only=arguments.untagged)
sys.stdout.write(f"== tagging {len(album.files)} item(s) in {directory}\n")
url = f"http://127.0.0.1:{arguments.port}/"
sys.stdout.write(f"== open {url} -- ctrl-c here when you are done\n")
if not arguments.no_open:
webbrowser.open(url)
uvicorn.run(build_app(album), host="127.0.0.1", port=arguments.port, log_level="warning")
sys.stdout.write("\n".join(album.summary()) + "\n")
return 0
def _import(source: Path, directory: Path, *, dry_run: bool) -> ImportReport:
"""Import a folder, or work out what importing it would do."""
stripper = ExiftoolStripper()
if dry_run:
return plan_import(source, directory, stripper)
return run_import(source, directory, stripper)
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -2,13 +2,13 @@
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Make a script work with whatever interpreter you started it with.
``python tool/tagger.py`` failed with ``ModuleNotFoundError: fastapi`` because
``python tool/web.py`` failed with ``ModuleNotFoundError: fastapi`` because
the dependencies live in ``.venv`` and nothing said so. So instead of reporting
that, this fixes it: if the running interpreter cannot import what the script
needs, it creates ``.venv`` if there is none, installs the project into it, and
re-executes the same command line through it.
``tag.sh`` and ``run.sh`` get there through ``scripts/ensure_venv.sh``. This
``web.sh`` and ``run.sh`` get there through ``scripts/ensure_venv.sh``. This
module is the same job for the case where somebody runs the ``.py`` directly,
which is what the file's own docstring used to tell them to do.
@ -108,7 +108,7 @@ def _venv_creator() -> list[str]:
def _install() -> bool:
"""Install the project editable, so a later git pull reaches the tagger."""
"""Install the project editable, so a later git pull reaches the page."""
uv = shutil.which("uv")
target = f"{REPO_ROOT}[dev]"
if uv is not None:

123
tool/web.py Executable file
View File

@ -0,0 +1,123 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Manage the bot, in a browser, on this machine only.
Health, configuration and the photo album in one page. It replaces the
tagger, which served the album half of this on the same port; everything it
did is still here, and the routes it did it with now live in
``signal_bot/manage/`` where the coverage gate can see them.
Started by hand and stopped again, deliberately. This page can rewrite
``.env`` -- the file that decides which groups the bot answers in and who may
add it to one -- and a unit would keep that writable on loopback all day for
the sake of a bookmark.
./web.sh # the configured pack
./web.sh --dir ~/pack --port 8140
./web.sh --import ~/photos # dedup, strip and import, then serve
./web.sh --import ~/photos --dry-run # the report, changing nothing
./web.sh --untagged # only items with no tags yet
It prints the album summary when it stops: how many items are untagged, and
how many photos each command would have. A command with zero is dead on
arrival, and this is where that shows up rather than in the group.
"""
from __future__ import annotations
# Importing this sets up .venv if the interpreter running us cannot import
# what follows. It has to come first, and it is an import rather than a call
# so that nothing here needs an E402 suppression.
from venv_bootstrap import REPO_ROOT
import argparse
import sys
import webbrowser
from pathlib import Path
from typing import TYPE_CHECKING, Final
import httpx
import uvicorn
from signal_bot.config_env import load_config
from signal_bot.manage.app import build_app
from signal_bot.manage.deps import ManageDeps
from signal_bot.media_strip import ExiftoolStripper
from signal_bot.pack import default_dirs
from signal_bot.pack_album import Album
from signal_bot.pack_import import plan_import, run_import
from signal_bot.portcheck import assert_port_free
if TYPE_CHECKING:
from signal_bot.pack_report import ImportReport
DEFAULT_PORT: Final = 8140
DIST: Final = REPO_ROOT / "web" / "dist"
def _arguments() -> argparse.Namespace:
"""What the launcher was asked to do."""
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("--dir", type=Path, default=None, help="the pack directory")
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
parser.add_argument(
"--import", dest="source", type=Path, default=None, help="import media from here first"
)
parser.add_argument(
"--dry-run", action="store_true", help="report what --import would do, change nothing"
)
parser.add_argument(
"--untagged", action="store_true", help="present only items with no tags yet"
)
parser.add_argument("--no-open", action="store_true", help="do not open a browser")
return parser.parse_args()
def main() -> int:
"""Serve the management page until it is stopped, then print the summary."""
arguments = _arguments()
directory = arguments.dir or default_dirs()[0][0]
if arguments.source is not None:
report = _import(arguments.source, directory, dry_run=arguments.dry_run)
sys.stdout.write("\n".join(report.lines()) + "\n\n")
if arguments.dry_run:
# Nothing was written, so there is nothing to serve and no reason to
# open a browser at a server this run is not going to start.
return 0
# Before the browser opens, not after: a stale instance on this port
# should be a refusal naming the process, not a tab showing somebody
# else's album. signal_bot/__main__.py does the same for the bot.
assert_port_free("127.0.0.1", arguments.port)
album = Album(directory, untagged_only=arguments.untagged)
deps = ManageDeps(
config=load_config(),
env_path=REPO_ROOT / ".env",
dist_dir=DIST,
album=album,
client=httpx.AsyncClient(),
port=arguments.port,
)
url = f"http://127.0.0.1:{arguments.port}/"
sys.stdout.write(f"== {len(album.files)} item(s) in {directory}\n")
sys.stdout.write(f"== open {url} -- ctrl-c here when you are done\n")
if not arguments.no_open:
webbrowser.open(url)
uvicorn.run(build_app(deps), host="127.0.0.1", port=arguments.port, log_level="warning")
sys.stdout.write("\n".join(album.summary()) + "\n")
return 0
def _import(source: Path, directory: Path, *, dry_run: bool) -> ImportReport:
"""Import a folder, or work out what importing it would do."""
stripper = ExiftoolStripper()
if dry_run:
return plan_import(source, directory, stripper)
return run_import(source, directory, stripper)
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -1,11 +1,11 @@
#!/bin/bash
# ============================================================================
# Tag the photo album, in a browser.
# Manage the bot, in a browser: health, configuration and the album.
#
# ./tag.sh --untagged # only items with no tags yet
# ./tag.sh --import ~/photos --untagged # import that folder first
# ./tag.sh --import ~/photos --dry-run # the report, changing nothing
# ./web.sh --untagged # only items with no tags yet
# ./web.sh --import ~/photos --untagged # import that folder first
# ./web.sh --import ~/photos --dry-run # the report, changing nothing
#
# Sets up .venv on first run, so this works on a clone with nothing installed.
# ============================================================================
@ -20,7 +20,7 @@ source "${REPO_ROOT}/scripts/ensure_venv.sh"
main() {
ensure_venv "$REPO_ROOT"
exec "${REPO_ROOT}/.venv/bin/python" "${REPO_ROOT}/tool/tagger.py" "$@"
exec "${REPO_ROOT}/.venv/bin/python" "${REPO_ROOT}/tool/web.py" "$@"
}
main "$@"

183
web/eslint.config.js Normal file
View File

@ -0,0 +1,183 @@
// Maximally aggressive, type-aware flat config.
//
// Baseline is typescript-eslint's *type-checked* strict + stylistic presets, so
// anything they flag is an error, not a warning. On top of that: unicorn (all),
// sonarjs (cognitive complexity / duplicate logic), perfectionist (sorting), and
// react-hooks. Unused disable directives are themselves errors, so every
// `eslint-disable` in this repo must be load-bearing.
//
// Deliberately absent:
// - eslint-plugin-react : 7.37.x still calls context.getFilename(), removed in
// ESLint 10, so it crashes the run.
// - eslint-plugin-jsx-a11y: 6.10.2 declares peer ESLint "^3 || ... || ^9" — no
// ESLint 10 support yet. a11y is enforced by review +
// testing-library queries (getByLabelText) instead.
//
// TypeScript is pinned to 6.0.x, not 7.x: typescript-eslint requires
// "typescript >=4.8.4 <6.1.0", and losing every type-aware rule would be a far
// bigger regression than gaining the Go-native compiler.
import js from "@eslint/js";
import perfectionist from "eslint-plugin-perfectionist";
import reactHooks from "eslint-plugin-react-hooks";
import sonarjs from "eslint-plugin-sonarjs";
import unicorn from "eslint-plugin-unicorn";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist", "coverage"] },
{ linterOptions: { reportUnusedDisableDirectives: "error" } },
js.configs.recommended,
...tseslint.configs.strictTypeChecked,
...tseslint.configs.stylisticTypeChecked,
unicorn.configs.all,
sonarjs.configs.recommended,
perfectionist.configs["recommended-natural"],
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
plugins: { "react-hooks": reactHooks },
rules: {
...reactHooks.configs.recommended.rules,
// Numbers in template literals are unambiguous and pervasive here
// (counts, star totals, grades).
"@typescript-eslint/restrict-template-expressions": [
"error",
{ allowNumber: true },
],
// Keep the rule, widen its vocabulary. `passesFilters(server, filter)`
// and `matchesQuery(...)` are predicates that read as English; forcing
// them to `isPassingFilters` would be worse prose in service of a prefix
// list. `passes`/`matches` are added, nothing is removed.
"unicorn/consistent-boolean-name": [
"error",
{ prefixes: { matches: true, passes: true } },
],
// Kebab-case files exporting PascalCase components: house convention.
"unicorn/filename-case": ["error", { case: "kebabCase" }],
// Keep the rule, drop one replacement: `props` is React's own word.
// `interface FilterBarProps` is the convention every React codebase and
// every reader expects; `FilterBarProperties` would be unidiomatic in
// service of a generic dictionary. Every other replacement stays on.
"unicorn/name-replacements": ["error", { replacements: { props: false } }],
},
},
// House overrides, applied to every file including this config itself.
//
// Each rule below is switched off deliberately: these are the cases where
// "more aggressive" makes the code worse rather than safer. A rule you must
// disable at every use site is worse than no rule at all, because
// reportUnusedDisableDirectives then turns the disables into their own debt.
{
rules: {
// Directly contradicts unicorn/consistent-class-member-order: one wants
// a private field before the getter that reads it, the other wants the
// getter first, and satisfying either re-triggers the other. unicorn's
// wins because "declare the field before the code that uses it" is a
// reason, whereas alphabetical order within a class is not.
"perfectionist/sort-classes": "off",
// Sorts Map entries by key. The marker table in parse-readme.ts is keyed
// by emoji codepoint, so "sorted" would mean ordered by
// U+1F396 < U+1F3CE < ... — meaningless to a reader, and it would shred
// the legend's own grouping (official, languages, scope, OS) into noise.
"perfectionist/sort-maps": "off",
// Alphabetises declarations within a file, which actively fights reading
// order: it would scatter `GRADES -> gradeRank -> isTripleA` into
// `compositeRank -> GRADES -> gradeCoverage`. Alphabetical order is not
// narrative order. Every *other* perfectionist rule stays on — sorting
// imports, union members and object keys destroys no narrative.
"perfectionist/sort-modules": "off",
// Wants JSDoc blocks written without leading `*`, which is neither the
// TypeScript convention nor what editors/typedoc render.
"unicorn/no-asterisk-prefix-in-documentation-comments": "off",
// Flags hand-wrapped comment prose. Comments here are wrapped to 80
// columns on purpose; the rule would force one long line per paragraph.
"unicorn/no-manually-wrapped-comments": "off",
// `null` is load-bearing here and cannot be replaced by `undefined`:
// 1. The pipeline's output is JSON, and JSON has no `undefined`. A
// 2. The two values mean different things in this domain — `null` is
// "Glama never indexed this server", `undefined` is "indexed, but
// this axis was not graded". Collapsing them would lose information
// the UI displays differently (not-indexed vs graded-partial).
"unicorn/no-null": "off",
// `Temporal` does not exist in this project's runtime: Node 26 reports
// `typeof Temporal === "undefined"`. The rule is unfollowable until it
// ships. The inputs here are GitHub's ISO-8601 timestamps, which
// Date.parse handles identically across engines — the cross-engine
// inconsistency the rule warns about is for non-ISO strings.
"unicorn/prefer-temporal": "off",
// The domain vocabulary is abbreviated on purpose: `os`, `spdx`, `repo`,
// `env` are the names the source data uses, and expanding them to
// `operatingSystem` / `repository` would obscure the mapping.
"unicorn/prevent-abbreviations": "off",
},
},
{
rules: {
// Caps a try block at ONE statement. Unfollowable where the whole point
// is that several steps can throw: loadPresets must wrap getItem (throws
// in private browsing), JSON.parse (throws on junk) and the shape check
// together, because any of them failing means the same thing — fall back
// to no presets. Splitting them into three try blocks would be strictly
// worse code. The rule has no maximum option, so it is all or nothing.
"unicorn/try-complexity": "off",
},
},
// Tests: relax the rules that fight with mocking and fixtures.
{
rules: {
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/require-await": "off",
"@typescript-eslint/unbound-method": "off",
"sonarjs/no-duplicate-string": "off",
// Test helpers live beside the tests that use them; hoisting every
// fixture factory to module scope to satisfy a scoping rule would
// scatter each test's setup away from the test.
"unicorn/consistent-function-scoping": "off",
// A no-op stub is the point of a stub.
"unicorn/no-empty-file": "off",
},
},
// The vitest setup file exists *to* perform top-level side effects: it patches
// globals (ResizeObserver, layout metrics, pointer capture) that jsdom lacks,
// before any test imports run. These two rules cannot be satisfied by a file
// whose entire contract is "mutate the environment on import".
{
files: ["src/test/setup.ts"],
rules: {
// The stubs are property getters installed on DOM prototypes, so `this`
// is the element being measured — there is no class to put them in.
"unicorn/no-this-outside-of-class": "off",
"unicorn/no-top-level-assignment-in-function": "off",
"unicorn/no-top-level-side-effects": "off",
},
},
// Plain JS config files get no type-aware linting.
{
extends: [tseslint.configs.disableTypeChecked],
files: ["**/*.{js,mjs,cjs}"],
},
);

20
web/index.html Normal file
View File

@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- No remote anything. This page shows private photographs and the
bot's configuration, and the only thing it may talk to is the
process that served it -- the same rule tool/tagger.html carried. -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'"
/>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>signal-bot</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

41
web/package.json Normal file
View File

@ -0,0 +1,41 @@
{
"name": "signal-bot-web",
"private": true,
"type": "module",
"packageManager": "pnpm@11.24.0",
"engines": { "node": ">=24", "pnpm": ">=10" },
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "tsc --noEmit && eslint .",
"test": "vitest run",
"coverage": "vitest run --coverage"
},
"dependencies": {
"@kuhyx/web-ui": "github:kuhyx/utils#web_ui-v0.3.1&path:/web_ui",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@eslint/js": "10.0.1",
"@testing-library/jest-dom": "7.0.1",
"@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.6.6",
"@types/node": "26.4.0",
"@types/react": "19.2.18",
"@types/react-dom": "19.2.5",
"@vitejs/plugin-react": "6.1.1",
"@vitest/coverage-v8": "4.1.11",
"eslint": "10.9.1",
"eslint-plugin-perfectionist": "5.10.1",
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-sonarjs": "4.2.0",
"eslint-plugin-unicorn": "74.0.0",
"jsdom": "30.0.1",
"typescript": "6.0.3",
"typescript-eslint": "8.68.0",
"vite": "8.2.2",
"vitest": "4.1.11"
}
}

2862
web/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

6
web/public/favicon.svg Normal file
View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="signal-bot">
<rect width="32" height="32" rx="7" fill="#1b1d21"/>
<path d="M16 6a10 10 0 0 0-8.7 14.9L6 26l5.3-1.3A10 10 0 1 0 16 6Z"
fill="none" stroke="#7aa2f7" stroke-width="2.2" stroke-linejoin="round"/>
<circle cx="16" cy="16" r="2.4" fill="#7aa2f7"/>
</svg>

After

Width:  |  Height:  |  Size: 361 B

View File

@ -0,0 +1,61 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { serve } from "../test/http.ts";
import { api, ApiError } from "./client.ts";
afterEach(() => {
vi.unstubAllGlobals();
});
describe("the api client", () => {
it("returns the body of a successful read", async () => {
serve({ "/api/status": { body: { bot: {}, reachable: true } } });
await expect(api.status()).resolves.toEqual({ bot: {}, reachable: true });
});
it("raises the server's own sentence, not one of its own", async () => {
// The detail is usually load_config's message -- what the journal would
// have shown at the next restart. Rewording it here would make two
// different sentences for one rule.
serve({
"/api/env": { body: { detail: "REPORT_HOUR out of range" }, status: 400 },
});
await expect(api.saveEnv({ REPORT_HOUR: "99" })).rejects.toThrow(
"REPORT_HOUR out of range",
);
});
it("falls back to the status text when there is no detail", async () => {
serve({ "/api/status": { body: { nothing: true }, status: 500 } });
await expect(api.status()).rejects.toBeInstanceOf(ApiError);
});
it("treats a path the server does not serve as a 404", async () => {
serve({});
await expect(api.status()).rejects.toMatchObject({ status: 404 });
});
it("carries the status code so a caller can tell 403 from 400", async () => {
serve({ "/api/env": { body: { detail: "nope" }, status: 403 } });
await expect(api.saveEnv({})).rejects.toMatchObject({ status: 403 });
});
it("declares json on every write, which is what refuses a form post", async () => {
const fetcher = serve({ "/api/album/tag": { body: { status: "ok" } } });
await api.tag("one.png", ["p1"]);
const [, init] = fetcher.mock.calls[0] as [string, RequestInit];
expect(init.headers).toMatchObject({ "content-type": "application/json" });
expect(init.method).toBe("POST");
});
it("reads the album, the groups and the restart through the same wrapper", async () => {
serve({
"/api/album/state": { body: { files: [] } },
"/api/groups": { body: { groups: [] } },
"/api/restart-container": { body: { ok: true } },
});
await expect(api.album()).resolves.toEqual({ files: [] });
await expect(api.groups()).resolves.toEqual({ groups: [] });
await expect(api.restart()).resolves.toEqual({ ok: true });
});
});

59
web/src/api/client.ts Normal file
View File

@ -0,0 +1,59 @@
import type { AlbumState, EnvironmentView, Group, Restart, Status } from "./types.ts";
// Every mutating call sends application/json, and it is not decoration: the
// server refuses anything else, because a cross-origin <form> cannot set
// this header and that is what stops a web page from writing .env.
const JSON_HEADERS = { "content-type": "application/json" };
export class ApiError extends Error {
readonly status: number;
constructor(message: string, options: ErrorOptions & { status: number }) {
super(message, options);
this.name = "ApiError";
this.status = options.status;
}
}
async function unwrap<T>(response: Response): Promise<T> {
const body: unknown = await response.json();
if (!response.ok) {
// FastAPI puts the reason in `detail`, and it is usually load_config's
// own message -- the sentence the journal would have shown at the next
// restart. Showing anything else would be inventing a second wording.
const detail =
typeof body === "object" && body !== null && "detail" in body
? String((body).detail)
: response.statusText;
throw new ApiError(detail, { status: response.status });
}
return body as T;
}
async function get<T>(path: string): Promise<T> {
return unwrap<T>(await fetch(path));
}
async function send<T>(path: string, method: string, body: unknown): Promise<T> {
return unwrap<T>(
await fetch(path, {
body: JSON.stringify(body),
headers: JSON_HEADERS,
method,
}),
);
}
export const api = {
album: async (): Promise<AlbumState> => get<AlbumState>("/api/album/state"),
env: async (): Promise<EnvironmentView> => get<EnvironmentView>("/api/env"),
groups: async (): Promise<{ groups: Group[] }> =>
get<{ groups: Group[] }>("/api/groups"),
restart: async (): Promise<Restart> =>
send<Restart>("/api/restart-container", "POST", {}),
saveEnv: async (values: Record<string, string>): Promise<unknown> =>
send("/api/env", "PUT", { values }),
status: async (): Promise<Status> => get<Status>("/api/status"),
tag: async (file: string, keys: string[]): Promise<unknown> =>
send("/api/album/tag", "POST", { file, keys }),
};

57
web/src/api/types.ts Normal file
View File

@ -0,0 +1,57 @@
// What the management app answers with. Hand-written rather than generated:
// the backend is eleven small modules in this same repository, and a
// generator would be a build step to keep working for no new information.
export interface BotHealth {
papacy?: string;
pope?: string;
reason?: string;
status?: string;
version?: string;
}
export interface Status {
bot: BotHealth;
reachable: boolean;
}
export interface Group {
id: string;
name: string;
}
export interface EnvironmentKey {
editable: boolean;
key: string;
value: string;
}
export interface EnvironmentView {
groups_known: boolean;
keys: EnvironmentKey[];
needs_groups: string[];
}
export interface AlbumFile {
keys: string[];
name: string;
}
export interface Person {
key: string;
triggers: string[];
}
export interface AlbumState {
combos: Person[];
configured: boolean;
files: AlbumFile[];
known_groups: Group[];
people: Person[];
}
export interface Restart {
ok: boolean;
stderr: string;
stdout: string;
}

45
web/src/app.test.tsx Normal file
View File

@ -0,0 +1,45 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { App } from "./app.tsx";
import { ALBUM, ENV, HEALTHY } from "./test/fixtures.ts";
import { serve } from "./test/http.ts";
afterEach(() => {
vi.unstubAllGlobals();
});
function everythingAnswers(): void {
serve({
"/api/album/state": { body: ALBUM },
"/api/env": { body: ENV },
"/api/status": { body: HEALTHY },
});
}
it("opens on health, because that is the question being asked", async () => {
everythingAnswers();
render(<App />);
await waitFor(() => {
expect(screen.getByTestId("reachable")).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: "Health" })).toHaveAttribute(
"aria-current",
"true",
);
});
it("switches to settings and to the photos", async () => {
everythingAnswers();
render(<App />);
await userEvent.click(screen.getByRole("button", { name: "Settings" }));
await waitFor(() => {
expect(screen.getByLabelText("COOLDOWN_SECONDS")).toBeInTheDocument();
});
await userEvent.click(screen.getByRole("button", { name: "Photos" }));
await waitFor(() => {
expect(screen.getByRole("img")).toBeInTheDocument();
});
});

42
web/src/app.tsx Normal file
View File

@ -0,0 +1,42 @@
import { useState } from "react";
import { Album } from "./views/album.tsx";
import { Health } from "./views/health.tsx";
import { Settings } from "./views/settings.tsx";
const VIEWS = {
Health: Health,
Photos: Album,
Settings: Settings,
} as const;
type ViewName = keyof typeof VIEWS;
const NAMES = Object.keys(VIEWS) as ViewName[];
export function App(): React.JSX.Element {
const [view, setView] = useState<ViewName>("Health");
const Current = VIEWS[view];
return (
<main>
<h1>signal-bot</h1>
<nav>
{NAMES.map((name) => {
return (
<button
aria-current={name === view}
key={name}
onClick={() => {
setView(name);
}}
type="button"
>
{name}
</button>
);
})}
</nav>
<Current />
</main>
);
}

30
web/src/main.test.tsx Normal file
View File

@ -0,0 +1,30 @@
import { afterEach, expect, it, vi } from "vitest";
import { HEALTHY } from "./test/fixtures.ts";
import { serve } from "./test/http.ts";
afterEach(() => {
vi.unstubAllGlobals();
vi.resetModules();
document.body.replaceChildren();
});
it("mounts the app into the root the page provides", async () => {
serve({ "/api/status": { body: HEALTHY } });
const root = document.createElement("div");
root.id = "root";
document.body.replaceChildren(root);
await import("./main.tsx");
await vi.waitFor(() => {
expect(document.querySelector("h1")).toHaveTextContent("signal-bot");
});
});
it("does nothing at all when there is no root to mount into", async () => {
// Not a hypothetical: this module is imported by its own test file, and a
// bootstrap that assumed the element existed would throw during collection
// rather than at run time.
serve({ "/api/status": { body: HEALTHY } });
await import("./main.tsx");
expect(document.querySelector("h1")).toBeNull();
});

15
web/src/main.tsx Normal file
View File

@ -0,0 +1,15 @@
import { createRoot } from "react-dom/client";
import { App } from "./app.tsx";
// Thin enough to test rather than exclude. This repository treats a coverage
// omit as a suppression, and "the DOM bootstrap is awkward to assert" is the
// same sentence as "this code is untested".
export function mount(container: Element): void {
createRoot(container).render(<App />);
}
const root = document.querySelector("#root");
if (root !== null) {
mount(root);
}

51
web/src/test/fixtures.ts Normal file
View File

@ -0,0 +1,51 @@
// Every value here is invented. Two constraints beyond "do not use real
// data": the repository's personal-data gate refuses any file containing a
// string matching group.<16+ base64 chars>, so the fixture group ids are
// short and obviously fake; and the real album's people are private, so the
// names here are letters of the NATO alphabet, as tests/pack_fixture.py does.
import type { AlbumState, EnvironmentView as EnvironmentView, Status } from "../api/types.ts";
export const GROUP = { id: "group.FIXTURE-ALPHA", name: "alfa" };
export const HEALTHY: Status = {
bot: { papacy: "nr 1", pope: "Petrus", status: "ok", version: "0.0.0" },
reachable: true,
};
export const DEAF: Status = {
bot: { reason: "no frame in 120s", status: "deaf" },
reachable: true,
};
export const UNREACHABLE: Status = { bot: {}, reachable: false };
// Answering, but with nothing to say for itself. Older releases predate the
// version/pope fields, so the page must not render "undefined" at somebody.
export const TERSE: Status = { bot: {}, reachable: true };
export const ENV: EnvironmentView = {
groups_known: true,
keys: [
{ editable: true, key: "GROUP_IDS", value: "" },
{ editable: true, key: "COOLDOWN_SECONDS", value: "10" },
{ editable: false, key: "PHONE_NUMBER", value: "+15550000000" },
],
needs_groups: ["GROUP_IDS"],
};
export const ALBUM: AlbumState = {
combos: [],
configured: true,
files: [
{ keys: ["p1"], name: "one.png" },
{ keys: [], name: "two.png" },
],
known_groups: [GROUP],
people: [
{ key: "p1", triggers: ["!alfa"] },
{ key: "p2", triggers: ["!beta"] },
// Half-added, which the tagger allows: add_person writes the key first
// and the page has to show something for it.
{ key: "p3", triggers: [] },
],
};

25
web/src/test/http.ts Normal file
View File

@ -0,0 +1,25 @@
import { vi } from "vitest";
export interface Reply {
body: unknown;
status?: number;
}
/**
Answer each request by path, so a test says what the server is doing.
*/
export function serve(routes: Record<string, Reply>): ReturnType<typeof vi.fn> {
const fetcher = vi.fn((input: string) => {
const { pathname: path } = new URL(input, "http://127.0.0.1:8140");
const reply = routes[path] ?? { body: {}, status: 404 };
const status = reply.status ?? 200;
return Promise.resolve({
json: () => Promise.resolve(reply.body),
ok: status < 400,
status,
statusText: `status ${status}`,
} as Response);
});
vi.stubGlobal("fetch", fetcher);
return fetcher;
}

1
web/src/test/setup.ts Normal file
View File

@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";

View File

@ -0,0 +1,103 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { ALBUM } from "../test/fixtures.ts";
import { serve } from "../test/http.ts";
import { Album } from "./album.tsx";
afterEach(() => {
vi.unstubAllGlobals();
});
it("says it is opening before the album arrives", () => {
serve({ "/api/album/state": { body: ALBUM } });
render(<Album />);
expect(screen.getByText("Opening the album…")).toBeInTheDocument();
});
it("says so when there is nothing to tag yet", async () => {
serve({ "/api/album/state": { body: { ...ALBUM, files: [] } } });
render(<Album />);
await waitFor(() => {
expect(screen.getByTestId("empty")).toBeInTheDocument();
});
});
it("asks for media by position, never by name", async () => {
// The server takes an int for exactly this reason: a filename from a
// client is a path, and this directory holds private photographs.
serve({ "/api/album/state": { body: ALBUM } });
render(<Album />);
await waitFor(() => {
expect(screen.getByRole("img")).toHaveAttribute("src", "/api/album/media/0");
});
});
it("shows who is already tagged on the current item", async () => {
serve({ "/api/album/state": { body: ALBUM } });
render(<Album />);
await waitFor(() => {
expect(screen.getByLabelText("1. !alfa")).toBeChecked();
});
expect(screen.getByLabelText("2. !beta")).not.toBeChecked();
});
it("shows a half-added person by key rather than by nothing", async () => {
serve({ "/api/album/state": { body: ALBUM } });
render(<Album />);
await waitFor(() => {
expect(screen.getByLabelText("3. p3")).toBeInTheDocument();
});
});
it("saves on the click rather than on a done button", async () => {
// The tab gets closed. A "done" button is a way to lose an evening's work.
const fetcher = serve({
"/api/album/state": { body: ALBUM },
"/api/album/tag": { body: { status: "ok" } },
});
render(<Album />);
await userEvent.click(await screen.findByLabelText("2. !beta"));
await waitFor(() => {
expect(screen.getByLabelText("2. !beta")).toBeChecked();
});
const [path, init] = fetcher.mock.calls.at(-1) as [string, { body: string }];
expect(path).toBe("/api/album/tag");
expect(JSON.parse(init.body)).toEqual({
file: "one.png",
keys: ["p1", "p2"],
});
});
it("untags somebody who was tagged by mistake", async () => {
const fetcher = serve({
"/api/album/state": { body: ALBUM },
"/api/album/tag": { body: { status: "ok" } },
});
render(<Album />);
await userEvent.click(await screen.findByLabelText("1. !alfa"));
await waitFor(() => {
expect(screen.getByLabelText("1. !alfa")).not.toBeChecked();
});
const [, init] = fetcher.mock.calls.at(-1) as [string, { body: string }];
expect(JSON.parse(init.body)).toEqual({ file: "one.png", keys: [] });
});
it("walks the album forwards and back", async () => {
serve({ "/api/album/state": { body: ALBUM } });
render(<Album />);
const previous = await screen.findByRole("button", { name: "Previous" });
expect(previous).toBeDisabled();
await userEvent.click(screen.getByRole("button", { name: "Next" }));
await waitFor(() => {
expect(screen.getByRole("img")).toHaveAttribute("src", "/api/album/media/1");
});
expect(screen.getByRole("button", { name: "Next" })).toBeDisabled();
await userEvent.click(screen.getByRole("button", { name: "Previous" }));
await waitFor(() => {
expect(screen.getByRole("img")).toHaveAttribute("src", "/api/album/media/0");
});
});

93
web/src/views/album.tsx Normal file
View File

@ -0,0 +1,93 @@
import { useCallback, useEffect, useState } from "react";
import type { AlbumFile, Person } from "../api/types.ts";
import { api } from "../api/client.ts";
// The file list is held apart from the rest of the album state so that
// recording a tag is a plain list update. Folding it back into a nullable
// object would add a "what if it is null" branch that cannot happen -- this
// only renders once the album has loaded -- and an untestable branch is the
// same thing as an untested one.
function tagged(files: AlbumFile[], name: string, keys: string[]): AlbumFile[] {
return files.map((entry) => (entry.name === name ? { ...entry, keys } : entry));
}
export function Album(): React.JSX.Element {
const [people, setPeople] = useState<null | Person[]>(null);
const [files, setFiles] = useState<AlbumFile[]>([]);
const [at, setAt] = useState(0);
useEffect(() => {
void api.album().then((state) => {
setPeople(state.people);
setFiles(state.files);
});
}, []);
const toggle = useCallback((file: AlbumFile, personKey: string) => {
const keys = file.keys.includes(personKey)
? file.keys.filter((held) => held !== personKey)
: [...file.keys, personKey];
// Saved on the click, not on a "done" button: the tab gets closed, and
// a "done" button is a way to lose an evening's work.
void api.tag(file.name, keys).then(() => {
setFiles((current) => tagged(current, file.name, keys));
});
}, []);
if (people === null) {
return <p>Opening the album…</p>;
}
// One list, one index space. The server serves media by position and tags
// by name against the same ordering, so filtering here would show item i
// of the album while tagging item i of a shorter list.
const file = files[at];
if (file === undefined) {
return <p data-testid="empty">This album has no items yet.</p>;
}
return (
<section>
<h2>
Photos — {at + 1} of {files.length}
</h2>
<img alt={`album item ${at + 1}`} src={`/api/album/media/${at}`} />
<ul>
{people.map((person, index) => {
return (
<li key={person.key}>
<label>
<input
checked={file.keys.includes(person.key)}
onChange={() => {
toggle(file, person.key);
}}
type="checkbox"
/>
{index + 1}. {person.triggers[0] ?? person.key}
</label>
</li>
);
})}
</ul>
<button
disabled={at === 0}
onClick={() => {
setAt((current) => current - 1);
}}
type="button"
>
Previous
</button>
<button
disabled={at >= files.length - 1}
onClick={() => {
setAt((current) => current + 1);
}}
type="button"
>
Next
</button>
</section>
);
}

View File

@ -0,0 +1,79 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { DEAF, HEALTHY, TERSE, UNREACHABLE } from "../test/fixtures.ts";
import { serve } from "../test/http.ts";
import { Health } from "./health.tsx";
afterEach(() => {
vi.unstubAllGlobals();
});
it("says it is asking before the answer arrives", () => {
serve({ "/api/status": { body: HEALTHY } });
render(<Health />);
expect(screen.getByText("Asking the bot…")).toBeInTheDocument();
});
it("shows the release the bot is actually running", async () => {
serve({ "/api/status": { body: HEALTHY } });
render(<Health />);
await waitFor(() => {
expect(screen.getByTestId("reachable")).toHaveTextContent("0.0.0");
});
});
it("never renders undefined at somebody", async () => {
// A release older than the version/pope fields still answers /healthz.
serve({ "/api/status": { body: TERSE } });
render(<Health />);
await waitFor(() => {
expect(screen.getByTestId("reachable")).toHaveTextContent("?");
});
expect(screen.getByTestId("reachable")).not.toHaveTextContent("undefined");
});
it("says plainly when the bot is not answering", async () => {
// The single most important thing this page can tell you, so it must not
// be the thing that stops the page rendering.
serve({ "/api/status": { body: UNREACHABLE } });
render(<Health />);
await waitFor(() => {
expect(screen.getByTestId("reachable")).toHaveTextContent("not answering");
});
});
it("shows why a bot that is up has gone deaf", async () => {
serve({ "/api/status": { body: DEAF } });
render(<Health />);
await waitFor(() => {
expect(screen.getByTestId("reason")).toHaveTextContent("no frame in 120s");
});
});
it("reports a restart that worked", async () => {
serve({
"/api/restart-container": { body: { ok: true, stderr: "", stdout: "" } },
"/api/status": { body: HEALTHY },
});
render(<Health />);
await userEvent.click(await screen.findByRole("button"));
await waitFor(() => {
expect(screen.getByTestId("restart")).toHaveTextContent("restarted");
});
});
it("does not let a restart that failed look like success", async () => {
serve({
"/api/restart-container": {
body: { ok: false, stderr: "no such container", stdout: "" },
},
"/api/status": { body: HEALTHY },
});
render(<Health />);
await userEvent.click(await screen.findByRole("button"));
await waitFor(() => {
expect(screen.getByTestId("restart")).toHaveTextContent("no such container");
});
});

51
web/src/views/health.tsx Normal file
View File

@ -0,0 +1,51 @@
import { useCallback, useEffect, useState } from "react";
import type { Restart, Status } from "../api/types.ts";
import { api } from "../api/client.ts";
// The page is most useful exactly when something is down, so every read here
// degrades to a state rather than an error.
export function Health(): React.JSX.Element {
const [status, setStatus] = useState<null | Status>(null);
const [restart, setRestart] = useState<null | Restart>(null);
const [busy, setBusy] = useState(false);
const refresh = useCallback(() => {
void api.status().then(setStatus);
}, []);
useEffect(refresh, [refresh]);
const doRestart = useCallback(() => {
setBusy(true);
void api.restart().then((result) => {
setRestart(result);
setBusy(false);
refresh();
});
}, [refresh]);
if (status === null) {
return <p>Asking the bot…</p>;
}
return (
<section>
<h2>Health</h2>
<p data-testid="reachable">
{status.reachable
? `${status.bot.status ?? "?"} — ${status.bot.version ?? "?"} «${status.bot.pope ?? "?"}»`
: "the bot is not answering on its loopback port"}
</p>
{status.bot.reason !== undefined && <p data-testid="reason">{status.bot.reason}</p>}
<button disabled={busy} onClick={doRestart} type="button">
Restart signal-api
</button>
{restart !== null && (
<p data-testid="restart">
{restart.ok ? "restarted" : `failed: ${restart.stderr}`}
</p>
)}
</section>
);
}

View File

@ -0,0 +1,82 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { ENV } from "../test/fixtures.ts";
import { serve } from "../test/http.ts";
import { Settings } from "./settings.tsx";
afterEach(() => {
vi.unstubAllGlobals();
});
it("says it is reading before the answer arrives", () => {
serve({ "/api/env": { body: ENV } });
render(<Settings />);
expect(screen.getByText("Reading .env…")).toBeInTheDocument();
});
it("will not let a key the server refuses even be typed into", async () => {
serve({ "/api/env": { body: ENV } });
render(<Settings />);
await waitFor(() => {
expect(screen.getByLabelText("PHONE_NUMBER")).toBeDisabled();
});
expect(screen.getByLabelText("COOLDOWN_SECONDS")).toBeEnabled();
});
it("locks the group fields while no listing has been read", async () => {
// parse_group_ids only checks syntax. Writing one blind with the container
// down turns a scoping mistake into a unit that will not start.
serve({ "/api/env": { body: { ...ENV, groups_known: false } } });
render(<Settings />);
await waitFor(() => {
expect(screen.getByTestId("groups-locked")).toBeInTheDocument();
});
expect(screen.getByLabelText("GROUP_IDS")).toBeDisabled();
});
it("asks for a restart once something has been written", async () => {
serve({ "/api/env": { body: ENV } });
render(<Settings />);
await userEvent.type(await screen.findByLabelText("COOLDOWN_SECONDS"), "5");
await userEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(screen.getByTestId("note")).toHaveTextContent("Restart the bot");
});
});
it("shows the server's refusal verbatim", async () => {
serve({ "/api/env": { body: ENV } });
render(<Settings />);
await userEvent.type(await screen.findByLabelText("COOLDOWN_SECONDS"), "5");
serve({
"/api/env": { body: { detail: "COOLDOWN_SECONDS must be an integer" }, status: 400 },
});
await userEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(screen.getByTestId("note")).toHaveTextContent("must be an integer");
});
});
it("reports a failure that is not the server's own sentence", async () => {
serve({ "/api/env": { body: ENV } });
render(<Settings />);
await userEvent.type(await screen.findByLabelText("COOLDOWN_SECONDS"), "5");
vi.stubGlobal(
"fetch",
vi.fn(() => Promise.reject(new Error("network is down"))),
);
await userEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(screen.getByTestId("note")).toHaveTextContent("could not save");
});
});
it("has nothing to save until something is edited", async () => {
serve({ "/api/env": { body: ENV } });
render(<Settings />);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
});
});

View File

@ -0,0 +1,74 @@
import { useCallback, useEffect, useState } from "react";
import type { EnvironmentView as EnvironmentView } from "../api/types.ts";
import { api, ApiError } from "../api/client.ts";
// Editing .env from a browser is the whole reason the server refuses
// cross-origin forms. The form's own job is smaller: never offer a key the
// server would refuse, so a refusal is a bug rather than a normal outcome.
export function Settings(): React.JSX.Element {
const [view, setView] = useState<EnvironmentView | null>(null);
const [edits, setEdits] = useState<Record<string, string>>({});
const [note, setNote] = useState("");
useEffect(() => {
void api.env().then(setView);
}, []);
const save = useCallback(() => {
void api
.saveEnv(edits)
.then(() => {
// The running process loaded the old values and keeps using them.
// Saying so is the whole contract; restarting is done from the
// terminal this page was started in.
setNote("Written. Restart the bot for it to take effect.");
setEdits({});
})
.catch((error: unknown) => {
setNote(error instanceof ApiError ? error.message : "could not save");
});
}, [edits]);
if (view === null) {
return <p>Reading .env…</p>;
}
return (
<section>
<h2>Settings</h2>
{!view.groups_known && (
<p data-testid="groups-locked">
The container is not reachable, so group settings are read-only: a
group id can only be checked against a listing that was really read.
</p>
)}
{view.keys.map((entry) => {
const isLocked =
!entry.editable ||
(view.needs_groups.includes(entry.key) && !view.groups_known);
return (
<label key={entry.key}>
{entry.key}
<input
disabled={isLocked}
onChange={(event) => {
setEdits((current) => {
return {
...current,
[entry.key]: event.target.value,
};
});
}}
value={edits[entry.key] ?? entry.value}
/>
</label>
);
})}
<button disabled={Object.keys(edits).length === 0} onClick={save} type="button">
Save
</button>
{note !== "" && <p data-testid="note">{note}</p>}
</section>
);
}

39
web/tsconfig.json Normal file
View File

@ -0,0 +1,39 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": [
"ESNext",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"types": [
"vitest/globals",
"node"
],
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"exactOptionalPropertyTypes": true,
"useUnknownInCatchVariables": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"noEmit": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true
},
"include": [
"src",
"*.ts",
"*.config.ts"
]
}

35
web/vite.config.ts Normal file
View File

@ -0,0 +1,35 @@
/// <reference types="vitest/config" />
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
// The page is served from the root of its own loopback port, and from
// nowhere else, so there is no subpath to be agnostic about.
export default defineConfig({
build: { outDir: "dist" },
plugins: [react()],
server: {
// `pnpm dev` talks to the real management app rather than a mock: the
// guard refuses anything whose Host is not loopback, so the proxy has
// to preserve it.
proxy: { "/api": "http://127.0.0.1:8140" },
},
test: {
coverage: {
// No exclude list. This repository's rule is that coverage omits are
// suppressions, and "the bootstrap is hard to test" is the same
// sentence as "this line is untested" -- so main.tsx is a shim thin
// enough to test in jsdom instead.
include: ["src/**/*.{ts,tsx}"],
provider: "v8",
thresholds: {
branches: 100,
functions: 100,
lines: 100,
statements: 100,
},
},
environment: "jsdom",
globals: true,
setupFiles: ["./src/test/setup.ts"],
},
});