mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 20:27:17 +01:00
This repository is public, and five things in it were not invented. The worst was mine, today: the tagger's help text used the group's actual first names and nicknames as its worked example, in a file that is on GitHub. It uses ala/basia/celina now, like every other example here. The rest predate this work. DOCS-signal-envelopes.md carried the operator's own phone number and the bot's, captured from a live envelope; conftest.py had the same number as its trusted inviter; and test_trust.py pinned a group member's full Signal uuid as a fixture. Numbers keep their shape so the documents still show what a real envelope looks like, but no digit of them belongs to anybody. Comments that reached for a real first name to illustrate the diacritic case now use an invented one. None of this removes them from the history, which is a separate decision. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017w1HZ3cZ7neL3rFdQ6VMBA
241 lines
9.3 KiB
HTML
241 lines
9.3 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; }
|
||
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="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; }
|
||
const k = `p${people.length + 1}`;
|
||
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};
|
||
}
|
||
|
||
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];
|
||
}
|
||
|
||
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 === 9 ? 0 : 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);
|
||
}
|
||
|
||
async function move(step) {
|
||
if (step > 0 && at === state.files.length - 1) {
|
||
// The end of the album is a screen, not a button that stops working.
|
||
await finish();
|
||
return;
|
||
}
|
||
at = Math.min(state.files.length - 1, Math.max(0, at + step));
|
||
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 (/^[0-9]$/.test(event.key)) {
|
||
// 0 is the tenth, so a pack of ten is still entirely keyboard-driven.
|
||
const index = event.key === "0" ? 9 : Number(event.key) - 1;
|
||
const person = 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));
|
||
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;
|
||
}
|
||
};
|
||
}
|
||
|
||
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>
|