diff --git a/src/components/AdminApp.tsx b/src/components/AdminApp.tsx index 214e328..02486d7 100644 --- a/src/components/AdminApp.tsx +++ b/src/components/AdminApp.tsx @@ -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() {
Synology-NAS (zweite Sicherung) -
+
+
diff --git a/src/lib/telegram.ts b/src/lib/telegram.ts index 92ef1c5..157dfae 100644 --- a/src/lib/telegram.ts +++ b/src/lib/telegram.ts @@ -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(); +// Chats, die gerade eine Bildbeschreibung eintippen (nach Tippen auf „Neues Bild"). +const awaitingGenerate = new Set(); + +// 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 funktioniert’s — 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 { 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 { 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 { + const recipes = await query('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 geht’s:\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 ` — 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('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.'); }); diff --git a/src/pages/anleitung.astro b/src/pages/anleitung.astro index 7a8b28d..22aa6dc 100644 --- a/src/pages/anleitung.astro +++ b/src/pages/anleitung.astro @@ -38,23 +38,33 @@ import Base from '../layouts/Base.astro';
-

4 · Bibliothek, Ordner & Alternativen

+

4 · Bibliothek, Ordner, Alternativen & Weiterbearbeiten

Alle Ergebnisse landen in der Bibliothek. Über die Ordner-Leiste oben lässt sich nach Ordner - filtern; jedem Ordner kann eine eigene Picdrop-Galerie zugeordnet werden. Mit + Alternative - entsteht eine weitere Fassung desselben Bildes — die Fassungen liegen gruppiert beieinander und lassen - sich per Miniaturleiste umschalten. Vorher/Nachher zeigt den Vergleich zum Original.

+ filtern; jedem Ordner kann eine eigene Picdrop-Galerie zugeordnet werden (Pfeil am Ordner = eigene + Galerie hinterlegt).

+
-

5 · Alles per Telegram

+

5 · Alles per Telegram — ohne Befehle

+

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.

+

Für Profis gibt es zusätzlich das „/"-Befehlsmenü (/neu, /rezepte, /status) — nötig ist es nicht.

@@ -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;}} diff --git a/src/pages/api/admin/netcheck.ts b/src/pages/api/admin/netcheck.ts new file mode 100644 index 0000000..5ad20f4 --- /dev/null +++ b/src/pages/api/admin/netcheck.ts @@ -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); +};