feat: button-driven Telegram menu (reply keyboard + commands), reachability diagnostic, How-To update

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
This commit is contained in:
2026-07-23 20:12:27 +00:00
parent 5105c7d529
commit 7608bc62da
4 changed files with 148 additions and 29 deletions
+11 -1
View File
@@ -64,6 +64,15 @@ export default function AdminApp() {
const r = await fetch('/api/admin/test-nas', { method: 'POST' }).then((x) => x.json());
notify(r.message || (r.ok ? 'OK' : 'Fehler'));
};
const netcheck = async () => {
if (!s.nas_host) { notify('Erst Host eintragen und speichern.'); return; }
notify('Prüfe Erreichbarkeit …');
const r = await fetch('/api/admin/netcheck', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ host: s.nas_host, port: s.nas_port || 22 }) }).then((x) => x.json());
const dnsMsg = r.resolve ? (r.resolve.ok ? `DNS ✓ (${r.resolve.address})` : `DNS ✕ (${r.resolve.error})`) : '';
const tcpMsg = r.tcp ? (r.tcp.ok ? `Port erreichbar in ${r.tcp.ms}ms ✓` : `nicht erreichbar: ${r.tcp.error}`) : (r.error || 'Fehler');
notify(`${dnsMsg}${dnsMsg ? ' · ' : ''}${tcpMsg}`);
};
const storageAction = async (action: string, confirmMsg?: string) => {
if (confirmMsg && !confirm(confirmMsg)) return;
notify('Räume auf …');
@@ -174,7 +183,8 @@ export default function AdminApp() {
<section className="karte">
<div className="kopfzeile"><span className="mono-label">Synology-NAS (zweite Sicherung)</span>
<button className="mini" onClick={testNas}>Verbindung testen</button></div>
<div className="reihe"><button className="mini" onClick={netcheck}>Erreichbarkeit</button>
<button className="mini" onClick={testNas}>Verbindung testen</button></div></div>
<div className="steuer">
<label className="schalt"><input type="checkbox" checked={!!s.nas_enabled} onChange={(e) => field('nas_enabled', e.target.checked)} />
<span><b>Ergebnisse zusätzlich aufs NAS spiegeln</b><em>Jedes fertige Bild wird nach <code>klarbild/JJJJ-MM/</code> gesichert.</em></span></label>
+59 -17
View File
@@ -1,6 +1,6 @@
// Telegram-Bot (grammY, Webhook) — vollwertiger Eingangs- und Bedien-Kanal.
// Kopplung, Bündelung weitergeleiteter Bilder, Rezeptwahl, Verarbeitung, Rückmeldung.
import { Bot, InputFile, InlineKeyboard } from 'grammy';
import { Bot, InputFile, InlineKeyboard, Keyboard } from 'grammy';
import { randomUUID } from 'node:crypto';
import { one, query } from './db';
import { decrypt } from './crypto';
@@ -14,6 +14,21 @@ let bot: Bot | null = null;
let webhookSecret = '';
// Zwischenspeicher: Freitext, aus dem der Nutzer per Knopf ein Bild erzeugen kann.
const pendingGenerate = new Map<number, string>();
// Chats, die gerade eine Bildbeschreibung eintippen (nach Tippen auf „Neues Bild").
const awaitingGenerate = new Set<number>();
// Menü-Beschriftungen (Dauertastatur) — alles per Fingertipp, ohne Befehle.
const BTN_NEW = '✨ Neues Bild', BTN_RECIPES = '📁 Rezept wählen', BTN_HELP = '❓ Hilfe';
function mainKeyboard() {
return new Keyboard().text(BTN_NEW).row().text(BTN_RECIPES).text(BTN_HELP).resized().persistent();
}
const HELP_TEXT =
'So funktionierts — alles per Knopf, ohne Befehle:\n\n' +
'🖼 *Bearbeiten:* Bild(er) hierher schicken (am besten als *Datei*), dann unten das Rezept antippen.\n' +
'🔀 *Kombinieren:* 2+ Bilder schicken, ins Bild-Textfeld eine Beschreibung wie „mit unserer Hündin Frieda" — danach „🔀 Kombinieren" antippen.\n' +
'✨ *Neues Bild:* unten „✨ Neues Bild" tippen und beschreiben, was entstehen soll.\n' +
'♻️ *Weiterbearbeiten:* ein fertiges Bild von mir einfach wieder zurückschicken.\n\n' +
'Ich melde mich einmal, wenn alles fertig ist — mit Ergebnis und Link.';
export async function getToken(): Promise<string> {
const s = await one<{ telegram_bot_token_enc: string | null }>('SELECT telegram_bot_token_enc FROM settings WHERE id=1');
@@ -48,6 +63,16 @@ export async function setupTelegram(): Promise<void> {
console.log('[telegram] Webhook gesetzt:', `${BASE}/api/telegram/webhook`);
} catch (e) { console.error('[telegram] setWebhook fehlgeschlagen:', e); }
}
// Befehlsmenü (das „/"-Menü in Telegram) — als zusätzliche Abkürzung.
try {
await b.api.setMyCommands([
{ command: 'neu', description: '✨ Neues Bild aus Text erzeugen' },
{ command: 'rezepte', description: '📁 Standard-Rezept wählen' },
{ command: 'status', description: '⏳ Letzten Auftrag anzeigen' },
{ command: 'hilfe', description: '❓ Hilfe & Anleitung' },
{ command: 'start', description: '🏠 Menü zeigen' },
]);
} catch (e) { console.error('[telegram] setMyCommands:', e); }
setInterval(() => { sweepDrafts().catch((e) => console.error('[telegram] sweep', e)); }, 3000);
}
@@ -247,34 +272,39 @@ export async function notifyJobDone(chatId: number, jobId: string): Promise<void
let msg = `✅ Fertig: ${done.length} Bild(er)${failed.length ? `, ${failed.length} fehlgeschlagen` : ''}.`;
if (delivered) msg += `\n📁 ${delivered} in Picdrop-Galerie ausgeliefert.`;
if (BASE) msg += `\n🔗 Bibliothek: ${BASE}/bibliothek`;
await b.api.sendMessage(chatId, msg);
if (done.length) msg += `\n♻️ Weiterbearbeiten? Schick ein Ergebnis einfach zurück.`;
await b.api.sendMessage(chatId, msg, { reply_markup: mainKeyboard() });
}
// --- Handler ----------------------------------------------------------------
function register(b: Bot) {
const sendRecipeMenu = async (ctx: any) => {
const recipes = await query<any>('SELECT id, name FROM recipes ORDER BY is_default DESC, name LIMIT 10');
const kb = new InlineKeyboard();
recipes.forEach((r, i) => { kb.text(r.name, `d:${r.id}`); if (i % 2 === 1) kb.row(); });
return ctx.reply('Welches Rezept soll für neue Bilder standardmäßig gelten?', { reply_markup: kb });
};
b.command('start', async (ctx) => {
const link = await linkedUser(ctx.chat.id);
if (link) return ctx.reply('👋 Klarbild-Bot ist verbunden. Leite mir Bilder weiter (als *Datei* für volle Qualität), dann wähle ein Rezept. /help für mehr.', { parse_mode: 'Markdown' });
if (link) return ctx.reply('👋 Klarbild ist verbunden. Schick mir Bilder zum Bearbeiten/Kombinieren, oder tippe unten auf „✨ Neues Bild".', { reply_markup: mainKeyboard() });
return ctx.reply('👋 Willkommen bei Klarbild. Zum Koppeln bitte den *Kopplungscode* aus dem Admin-Bereich hier eingeben.', { parse_mode: 'Markdown' });
});
b.command('help', (ctx) => ctx.reply(
'So gehts:\n• *Bearbeiten:* Bilder weiterleiten (am besten als *Datei*) → Rezept wählen.\n• *Kombinieren:* 2+ Bilder senden, dazu eine Bildunterschrift wie „mit unserer Hündin Frieda" → „🔀 Kombinieren".\n• *Neu erzeugen:* `/neu <Beschreibung>` — z. B. `/neu ein Olivenzweig auf Sand`.\n\nIch melde mich einmal, wenn alles fertig ist — mit den Ergebnissen und Links.\nBefehle: /neu · /rezepte (Standard setzen) · /status · /start',
{ parse_mode: 'Markdown' }));
const sendHelp = (ctx: any) => ctx.reply(HELP_TEXT, { parse_mode: 'Markdown', reply_markup: mainKeyboard() });
b.command('help', sendHelp);
b.command('hilfe', sendHelp);
b.command('neu', async (ctx) => {
if (!(await linkedUser(ctx.chat.id))) return ctx.reply('Bitte zuerst koppeln (Code aus dem Admin).');
const text = (ctx.match || '').toString().trim();
if (!text) return ctx.reply('Schreib z. B.: /neu ein minimalistisches Poster mit einem Olivenzweig auf Sand');
if (!text) { awaitingGenerate.add(ctx.chat.id); return ctx.reply('Beschreibe kurz dein Bild — was soll entstehen?'); }
const b2 = await getBot(); if (b2) await dispatchGenerate(ctx.chat.id, text, b2);
});
b.command('rezepte', async (ctx) => {
if (!(await linkedUser(ctx.chat.id))) return ctx.reply('Bitte zuerst koppeln (Code aus dem Admin).');
const recipes = await query<any>('SELECT id, name FROM recipes ORDER BY is_default DESC, name LIMIT 10');
const kb = new InlineKeyboard();
recipes.forEach((r, i) => { kb.text(r.name, `d:${r.id}`); if (i % 2 === 1) kb.row(); });
return ctx.reply('Standard-Rezept für diesen Chat wählen:', { reply_markup: kb });
return sendRecipeMenu(ctx);
});
b.command('status', async (ctx) => {
@@ -302,23 +332,35 @@ function register(b: Bot) {
b.on('message:text', async (ctx) => {
if (ctx.message.text.startsWith('/')) return;
const text = ctx.message.text.trim();
const linked = await linkedUser(ctx.chat.id);
if (linked) {
// Dauertastatur-Knöpfe
if (text === BTN_HELP) return sendHelp(ctx);
if (text === BTN_RECIPES) return sendRecipeMenu(ctx);
if (text === BTN_NEW) { awaitingGenerate.add(ctx.chat.id); return ctx.reply('Beschreibe kurz dein Bild — was soll entstehen?'); }
// Wartet ein Kombinieren-Draft auf die Beschreibung? → Text als Beschreibung nehmen.
const waiting = await one<{ id: string }>(
`SELECT id FROM telegram_drafts WHERE chat_id=$1 AND status='awaiting_compose_text'
ORDER BY last_received_at DESC LIMIT 1`, [ctx.chat.id]);
if (waiting) {
const b2 = await getBot(); if (b2) await dispatchCompose(ctx.chat.id, waiting.id, ctx.message.text.trim(), b2);
const b2 = await getBot(); if (b2) await dispatchCompose(ctx.chat.id, waiting.id, text, b2);
return;
}
// Nutzer hat „Neues Bild" angetippt und tippt jetzt die Beschreibung.
if (awaitingGenerate.has(ctx.chat.id)) {
awaitingGenerate.delete(ctx.chat.id);
const b2 = await getBot(); if (b2) await dispatchGenerate(ctx.chat.id, text, b2);
return;
}
// Sonst: Freitext als Angebot zum Neu-Erzeugen anbieten.
const kb = new InlineKeyboard().text('🎨 Neues Bild erzeugen', 'g:x');
pendingGenerate.set(ctx.chat.id, ctx.message.text.trim());
return ctx.reply('Soll ich daraus ein neues Bild erzeugen? (oder /help)', { reply_markup: kb });
const kb = new InlineKeyboard().text('✨ Ja, Bild daraus erzeugen', 'g:x');
pendingGenerate.set(ctx.chat.id, text);
return ctx.reply('Soll ich daraus ein neues Bild erzeugen?', { reply_markup: kb });
}
if (await tryPair(ctx.chat.id, ctx.message.text)) {
return ctx.reply('✅ Verbunden! Leite mir Bilder weiter, oder erzeuge mit /neu ein neues Bild. /help für mehr.');
if (await tryPair(ctx.chat.id, text)) {
return ctx.reply('✅ Verbunden! Schick mir Bilder oder tippe unten auf „✨ Neues Bild".', { reply_markup: mainKeyboard() });
}
return ctx.reply('Code ungültig oder abgelaufen. Neuen Code im Admin erzeugen.');
});
+22 -11
View File
@@ -38,23 +38,33 @@ import Base from '../layouts/Base.astro';
</section>
<section>
<h2>4 · Bibliothek, Ordner & Alternativen</h2>
<h2>4 · Bibliothek, Ordner, Alternativen & Weiterbearbeiten</h2>
<p>Alle Ergebnisse landen in der <b>Bibliothek</b>. Über die Ordner-Leiste oben lässt sich nach Ordner
filtern; jedem Ordner kann eine eigene Picdrop-Galerie zugeordnet werden. Mit <b>+ Alternative</b>
entsteht eine weitere Fassung desselben Bildes — die Fassungen liegen gruppiert beieinander und lassen
sich per Miniaturleiste umschalten. <b>Vorher/Nachher</b> zeigt den Vergleich zum Original.</p>
filtern; jedem Ordner kann eine eigene Picdrop-Galerie zugeordnet werden (Pfeil am Ordner = eigene
Galerie hinterlegt).</p>
<ul>
<li><b>+ Alternative</b>: erzeugt eine weitere Fassung desselben Bildes. Die Fassungen liegen gruppiert
beieinander und lassen sich per Miniaturleiste umschalten (Badge „Fassung 2/3").</li>
<li><b>Weiterbearbeiten</b>: übernimmt ein fertiges Bild als neue Vorlage ins Studio — dort erneut
bearbeiten, ins nächste Format bringen oder im Modus Kombinieren mit weiteren Bildern mischen.</li>
<li><b>Vorher/Nachher</b>: zeigt den Vergleich Vorlage ↔ Ergebnis (bei reinen Text-Bildern entfällt das).</li>
</ul>
</section>
<section>
<h2>5 · Alles per Telegram</h2>
<h2>5 · Alles per Telegram — ohne Befehle</h2>
<p>Einmalig koppeln: im Admin einen Code erzeugen und im Chat mit dem Bot eingeben. Danach läuft alles über
die Knöpfe unten im Chat — kein Tippen von Befehlen nötig.</p>
<ul>
<li>Einmalig koppeln: im Admin einen Code erzeugen und im Chat mit dem Bot eingeben.</li>
<li><b>Bearbeiten:</b> Bilder weiterleiten (am besten als <i>Datei</i> für volle Qualität) → Rezept wählen.</li>
<li><b>Kombinieren:</b> zwei oder mehr Bilder mit einer Bildunterschrift senden (z. B. „mit unserer Hündin
Frieda") → Knopf „🔀 Kombinieren".</li>
<li><b>Neu erzeugen:</b> <code>/neu &lt;Beschreibung&gt;</code> — z. B. <code>/neu ein Olivenzweig auf Sand</code>.</li>
<li>Der Bot meldet sich einmal, wenn alles fertig ist — mit den Ergebnissen und Links.</li>
<li><b>Bearbeiten:</b> Bild(er) schicken (am besten als <i>Datei</i> für volle Qualität) → unten das
Rezept antippen.</li>
<li><b>Kombinieren:</b> zwei oder mehr Bilder schicken, ins Bild-Textfeld eine Beschreibung wie
„mit unserer Hündin Frieda" — dann „🔀 Kombinieren" antippen. (Ohne Beschreibung fragt der Bot nach.)</li>
<li><b>Neu erzeugen:</b> unten auf „✨ Neues Bild" tippen und beschreiben, was entstehen soll.</li>
<li><b>Weiterbearbeiten:</b> ein fertiges Bild vom Bot einfach wieder zurückschicken.</li>
<li>Über „❓ Hilfe" gibt es die Kurzanleitung, über „📁 Rezept wählen" das Standard-Rezept.</li>
</ul>
<p class="mini">Für Profis gibt es zusätzlich das „/"-Befehlsmenü (/neu, /rezepte, /status) — nötig ist es nicht.</p>
</section>
<section>
@@ -78,6 +88,7 @@ import Base from '../layouts/Base.astro';
.howto ol,.howto ul{margin:0;padding-left:20px;line-height:1.65;}
.howto li{margin-bottom:6px;}
.howto code{font-family:var(--font-mono);font-size:.85em;background:var(--paper);border:1px solid var(--line);border-radius:3px;padding:1px 5px;}
.howto .mini{font-size:.82rem;color:var(--soft);margin:8px 0 0;}
@media print{.topbar,.tabbar,.druck{display:none !important;}.howto section{break-inside:avoid;}}
</style>
</Base>
+56
View File
@@ -0,0 +1,56 @@
import type { APIRoute } from 'astro';
import net from 'node:net';
import dns from 'node:dns/promises';
import { one } from '../../../lib/db';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
/** TCP-Verbindungstest aus dem Container heraus (mit Timeout). */
function tcpProbe(host: string, port: number, timeoutMs = 6000): Promise<{ ok: boolean; ms: number; error?: string }> {
return new Promise((resolve) => {
const start = Date.now();
const sock = new net.Socket();
let done = false;
const finish = (ok: boolean, error?: string) => {
if (done) return; done = true;
try { sock.destroy(); } catch { /* egal */ }
resolve({ ok, ms: Date.now() - start, error });
};
sock.setTimeout(timeoutMs);
sock.once('connect', () => finish(true));
sock.once('timeout', () => finish(false, 'Timeout — keine Antwort (Firewall/Route?)'));
sock.once('error', (e: any) => finish(false, e?.code || e?.message || 'Verbindungsfehler'));
sock.connect(port, host);
});
}
/** Prüft, ob ein Host:Port aus dem Container erreichbar ist. Body {host,port} oder
* ohne Angabe der konfigurierte NAS-Host. Testet zusätzlich DNS-Auflösung. */
export const POST: APIRoute = async ({ request }) => {
const b = await request.json().catch(() => ({} as any));
let host = (b.host || '').trim();
let port = Number(b.port) || 0;
if (!host) {
const s = await one<{ nas_host: string | null; nas_port: number | null; nas_protocol: string | null }>(
'SELECT nas_host, nas_port, nas_protocol FROM settings WHERE id=1');
host = (s?.nas_host || '').trim();
port = s?.nas_port || (s?.nas_protocol === 'ftps' ? 21 : 22);
}
if (!host) return json({ error: 'Kein Host angegeben.' }, 400);
if (!port) port = 22;
const out: any = { host, port };
// DNS: nur wenn kein reines IP.
const isIp = net.isIP(host) !== 0;
if (!isIp) {
try { const a = await dns.lookup(host); out.resolve = { ok: true, address: a.address }; }
catch (e: any) { out.resolve = { ok: false, error: e?.code || 'DNS fehlgeschlagen' }; }
} else {
out.resolve = { ok: true, address: host, note: 'IP-Adresse' };
}
out.tcp = await tcpProbe(host, port);
out.reachable = out.tcp.ok;
return json(out);
};