signal-bot/tool/tagger.html
Krzysztof kuhy Rudnicki 35102be863 release: 0.2.0 «Urbanus I» — a private photo album, and a tagger for it
Per-person photo commands, where the mechanism is public and the album is
not. Nothing identifying is in this repository: who the people are, what
fires their photos and which file shows whom all live in one directory
outside it, and the tests, the docs and the offline e2e run use three
invented people instead.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017w1HZ3cZ7neL3rFdQ6VMBA
2026-08-30 22:35:41 +02:00

198 lines
7.6 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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; }
.err { color: #f7768e; }
label.group { display: block; padding: 4px 0; }
</style>
</head>
<body>
<main>
<div id="setup" hidden>
<h1>Kto jest w tej paczce?</h1>
<p class="hint">
Jedna osoba na linię: nazwy komend po przecinku, bez wykrzyknika.<br>
<code>celina, cela</code><br>
Kombinacje po znaku <code>=</code>: <code>oboje = ala + marek</code>
(zdjęcie z obojgiem) albo <code>ktokolwiek = ala | marek</code>
(losowo jedno z nich).
</p>
<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="tagger" hidden>
<div class="bar">
<h1 id="progress"></h1>
<span class="hint">1–9 = osoba, → dalej, ← wstecz</span>
</div>
<div class="stage" id="stage"></div>
<div class="people" id="people"></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 key(name, index) { return name || `p${index + 1}`; }
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; }
const k = `p${people.length + 1}`;
people.push({key: k, triggers});
byName.set(triggers[0].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 keys = parts.map((p) => byName.get(p)).filter(Boolean);
if (keys.length !== parts.length) { throw new Error(`nie znam kogoś z: ${right.trim()}`); }
const triggers = left.split(",").map((t) => "!" + t.trim().replace(/^!/, ""));
combos.push(every ? {key: `c${n}`, triggers, all_of: keys} : {key: `c${n}`, triggers, any_of: keys});
n += 1;
}
return {people, combos};
}
function drawGroups(known, chosen) {
const box = document.getElementById("groups");
if (!known.length) {
box.innerHTML = '<input id="rawgroup" style="width:100%" placeholder="group.…=">';
return;
}
box.innerHTML = known.map((g, i) => `<label class="group">
<input type="radio" name="group" value="${g.id}" ${chosen.includes(g.id) || (!chosen.length && i === 0) ? "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];
}
function draw() {
const item = state.files[at];
document.getElementById("progress").textContent =
`${at + 1} / ${state.files.length} — ${item.name}`;
const stage = document.getElementById("stage");
stage.innerHTML = item.name.toLowerCase().endsWith(".mp4")
? `<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}">
${i + 1}. ${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);
}
function move(step) {
at = Math.min(state.files.length - 1, Math.max(0, at + step));
draw();
}
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 (/^[1-9]$/.test(event.key)) {
const person = entries()[Number(event.key) - 1];
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));
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ę"); }
await api("/api/setup", {groups: chosenGroups(), people, combos});
location.reload();
} catch (error) {
document.getElementById("error").textContent = error.message;
}
};
}
document.getElementById("prev").onclick = () => move(-1);
document.getElementById("next").onclick = () => move(1);
start();
</script>
</body>
</html>