mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 17:14:09 +01:00
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
198 lines
7.6 KiB
HTML
198 lines
7.6 KiB
HTML
<!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>franek, kranek</code><br>
|
||
Kombinacje po znaku <code>=</code>: <code>faron = michal + marek</code>
|
||
(zdjęcie z obojgiem) albo <code>ktokolwiek = michal | 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>
|