From d680b3f67a31adbea94cce3329ba5633701650f6 Mon Sep 17 00:00:00 2001 From: Till Heidrich Date: Thu, 23 Jul 2026 17:39:49 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20telegram=20bot=20(grammy=20webhook)=20?= =?UTF-8?q?=E2=80=94=20full=20parity=20channel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pairing (admin code -> chat link), bundling of forwarded images via drafts+quiet-window sweeper - recipe pick via inline keyboard, job (origin=telegram) creation, one summary reply with result files + links - compressed-photo detection/flag; /start /help /rezepte /status - webhook route (secret token), admin pairing-code + links endpoints, AdminApp telegram section - worker notifies chat on completion; migration 002 adds jobs.telegram_chat_id; setupTelegram on init --- migrations/002_telegram_job.sql | 2 + src/components/AdminApp.tsx | 35 ++- src/lib/init.ts | 2 + src/lib/telegram.ts | 238 +++++++++++++++++++ src/pages/api/admin/telegram/links.ts | 19 ++ src/pages/api/admin/telegram/pairing-code.ts | 20 ++ src/pages/api/telegram/webhook.ts | 20 ++ src/worker.ts | 10 +- 8 files changed, 342 insertions(+), 4 deletions(-) create mode 100644 migrations/002_telegram_job.sql create mode 100644 src/lib/telegram.ts create mode 100644 src/pages/api/admin/telegram/links.ts create mode 100644 src/pages/api/admin/telegram/pairing-code.ts create mode 100644 src/pages/api/telegram/webhook.ts diff --git a/migrations/002_telegram_job.sql b/migrations/002_telegram_job.sql new file mode 100644 index 0000000..0b4481b --- /dev/null +++ b/migrations/002_telegram_job.sql @@ -0,0 +1,2 @@ +-- Telegram: Auftrag kennt den auslösenden Chat (für die Rückmeldung). +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS telegram_chat_id bigint; diff --git a/src/components/AdminApp.tsx b/src/components/AdminApp.tsx index c69c833..7649060 100644 --- a/src/components/AdminApp.tsx +++ b/src/components/AdminApp.tsx @@ -6,19 +6,31 @@ export default function AdminApp() { const [models, setModels] = useState([]); const [avail, setAvail] = useState([]); const [users, setUsers] = useState([]); + const [tgLinks, setTgLinks] = useState([]); const [toast, setToast] = useState(null); const [orKey, setOrKey] = useState(''); const [pdPw, setPdPw] = useState(''); const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2600); }; const load = useCallback(async () => { - const [a, b, c, d] = await Promise.all([ + const [a, b, c, d, e] = await Promise.all([ fetch('/api/admin/settings').then((r) => r.json()), fetch('/api/admin/stats').then((r) => r.json()), fetch('/api/admin/models?available=1').then((r) => r.json()), fetch('/api/admin/users').then((r) => r.json()), + fetch('/api/admin/telegram/links').then((r) => r.json()).catch(() => ({ links: [] })), ]); - setS(a.settings || {}); setStats(b); setModels(c.models || []); setAvail(c.available || []); setUsers(d.users || []); + setS(a.settings || {}); setStats(b); setModels(c.models || []); setAvail(c.available || []); + setUsers(d.users || []); setTgLinks(e.links || []); }, []); + + const pairCode = async (userId: string) => { + const r = await fetch('/api/admin/telegram/pairing-code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId }) }).then((x) => x.json()); + if (r.code) alert(`Kopplungscode: ${r.code}\n\n15 Minuten gültig. Im Klarbild-Telegram-Chat eingeben.`); + }; + const unlink = async (chat_id: number) => { + await fetch('/api/admin/telegram/links', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chat_id }) }); + load(); + }; useEffect(() => { load(); }, [load]); const field = (k: string, v: any) => setS((p: any) => ({ ...p, [k]: v })); @@ -147,12 +159,29 @@ export default function AdminApp() { {users.map((u) => (
{u.display_name || u.username} {u.username} · {u.role}
- +
+ + +
))} +
+
Telegram-Bot
+
+
Über „Telegram koppeln" bei einem Nutzer einen Code erzeugen und im Chat mit dem Bot eingeben. Danach dort Bilder weiterleiten → Rezept wählen → Ergebnisse kommen zurück.
+ {tgLinks.map((l) => ( +
+
Chat {l.chat_id} {l.user_name || ''} · seit {new Date(l.linked_at).toLocaleDateString('de-DE')}
+ +
+ ))} + {tgLinks.length === 0 &&
Noch keine gekoppelten Chats.
} +
+
+ {toast &&
{toast}
} diff --git a/src/lib/init.ts b/src/lib/init.ts index 24579fb..7aae4a3 100644 --- a/src/lib/init.ts +++ b/src/lib/init.ts @@ -13,6 +13,8 @@ export function ensureInit(): Promise { try { await ensureBucket(); } catch (e) { console.error('[init] S3 nicht bereit:', e); } await seed(); try { await startImageWorker(); } catch (e) { console.error('[init] Worker-Start fehlgeschlagen:', e); } + try { const { setupTelegram } = await import('./telegram'); await setupTelegram(); } + catch (e) { console.error('[init] Telegram-Setup fehlgeschlagen:', e); } console.log('[init] Klarbild bereit.'); })().catch((e) => { started = null; throw e; }); } diff --git a/src/lib/telegram.ts b/src/lib/telegram.ts new file mode 100644 index 0000000..781e799 --- /dev/null +++ b/src/lib/telegram.ts @@ -0,0 +1,238 @@ +// 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 { randomUUID } from 'node:crypto'; +import { one, query } from './db'; +import { decrypt } from './crypto'; +import { getObject, putObject, sourceKey } from './storage'; +import { enqueue } from './queue'; + +const QUIET_MS = 4000; // Ruhefenster für die Bündelung +const BASE = process.env.PUBLIC_BASE_URL || ''; + +let bot: Bot | null = null; +let webhookSecret = ''; + +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'); + if (s?.telegram_bot_token_enc) { try { return decrypt(s.telegram_bot_token_enc); } catch { /* ENV */ } } + return process.env.TELEGRAM_BOT_TOKEN || ''; +} + +export async function getBot(): Promise { + if (bot) return bot; + const token = await getToken(); + if (!token) return null; + bot = new Bot(token); + register(bot); + await bot.init(); + return bot; +} + +export function getWebhookSecret(): string { return webhookSecret; } + +/** Beim Start: Webhook registrieren (Secret aus settings) + Bündelungs-Sweeper. */ +export async function setupTelegram(): Promise { + const b = await getBot(); + if (!b) { console.log('[telegram] kein Token — Bot inaktiv'); return; } + const s = await one<{ telegram_webhook_secret: string | null }>('SELECT telegram_webhook_secret FROM settings WHERE id=1'); + webhookSecret = s?.telegram_webhook_secret || randomUUID().replace(/-/g, ''); + if (!s?.telegram_webhook_secret) await query('UPDATE settings SET telegram_webhook_secret=$1 WHERE id=1', [webhookSecret]); + if (BASE) { + try { + await b.api.setWebhook(`${BASE}/api/telegram/webhook`, { + secret_token: webhookSecret, allowed_updates: ['message', 'callback_query'], drop_pending_updates: true, + }); + console.log('[telegram] Webhook gesetzt:', `${BASE}/api/telegram/webhook`); + } catch (e) { console.error('[telegram] setWebhook fehlgeschlagen:', e); } + } + setInterval(() => { sweepDrafts().catch((e) => console.error('[telegram] sweep', e)); }, 3000); +} + +// --- Kopplung --------------------------------------------------------------- +async function linkedUser(chatId: number) { + return one<{ user_id: string; default_recipe_id: string | null }>( + 'SELECT user_id, default_recipe_id FROM telegram_links WHERE chat_id=$1 AND active', [chatId]); +} + +async function tryPair(chatId: number, code: string): Promise { + const row = await one<{ user_id: string }>( + `SELECT user_id FROM telegram_pairing_codes WHERE code=$1 AND used_at IS NULL AND expires_at > now()`, [code.trim()]); + if (!row) return false; + await query(`UPDATE telegram_pairing_codes SET used_at=now() WHERE code=$1`, [code.trim()]); + await query(`INSERT INTO telegram_links (chat_id, user_id, active) VALUES ($1,$2,true) + ON CONFLICT (chat_id) DO UPDATE SET user_id=$2, active=true`, [chatId, row.user_id]); + return true; +} + +// --- Bündelung (Drafts) ----------------------------------------------------- +async function addToDraft(chatId: number, mediaGroup: string | null, fileRef: any, noticeMsgId?: number) { + const existing = await one<{ id: string; file_refs: any[] }>( + `SELECT id, file_refs FROM telegram_drafts WHERE chat_id=$1 AND status='collecting' + ORDER BY last_received_at DESC LIMIT 1`, [chatId]); + if (existing) { + const refs = [...(existing.file_refs || []), fileRef]; + await query(`UPDATE telegram_drafts SET file_refs=$2, last_received_at=now() WHERE id=$1`, + [existing.id, JSON.stringify(refs)]); + return existing.id; + } + const row = await one<{ id: string }>( + `INSERT INTO telegram_drafts (chat_id, media_group_id, file_refs, status, notice_message_id) + VALUES ($1,$2,$3,'collecting',$4) RETURNING id`, + [chatId, mediaGroup, JSON.stringify([fileRef]), noticeMsgId ?? null]); + return row!.id; +} + +/** Sweeper: fertig gesammelte Drafts (Ruhefenster vorbei) → Rezept erfragen. */ +export async function sweepDrafts(): Promise { + const b = await getBot(); if (!b) return; + const drafts = await query( + `SELECT id, chat_id, file_refs FROM telegram_drafts + WHERE status='collecting' AND last_received_at < now() - interval '${Math.round(QUIET_MS / 1000)} seconds'`); + for (const d of drafts) { + const recipes = await query('SELECT id, name FROM recipes ORDER BY is_default DESC, name LIMIT 8'); + const kb = new InlineKeyboard(); + recipes.forEach((r, i) => { kb.text(r.name, `r:${r.id}:${d.id}`); if (i % 2 === 1) kb.row(); }); + const n = (d.file_refs || []).length; + const compressed = (d.file_refs || []).some((f: any) => f.quality === 'compressed'); + await query(`UPDATE telegram_drafts SET status='awaiting_recipe' WHERE id=$1`, [d.id]); + await b.api.sendMessage(d.chat_id, + `${n} Bild${n > 1 ? 'er' : ''} empfangen. Welches Rezept?` + + (compressed ? '\n⚠️ Als Foto gesendet (komprimiert). Für volle Qualität als *Datei* senden.' : ''), + { reply_markup: kb, parse_mode: 'Markdown' }); + } +} + +// --- Draft -> Auftrag ------------------------------------------------------- +async function dispatchDraft(chatId: number, draftId: string, recipeId: string, b: Bot) { + const draft = await one('SELECT * FROM telegram_drafts WHERE id=$1', [draftId]); + if (!draft || draft.status === 'dispatched') return; + const recipe = await one('SELECT * FROM recipes WHERE id=$1', [recipeId]); + if (!recipe) { await b.api.sendMessage(chatId, 'Rezept nicht gefunden.'); return; } + const link = await linkedUser(chatId); + + await b.api.sendMessage(chatId, `⏳ Verarbeite ${(draft.file_refs || []).length} Bild(er) …`); + + // Bilder von Telegram laden und in den Objektspeicher legen + const sources: any[] = []; + const token = await getToken(); + for (const ref of draft.file_refs || []) { + try { + const file = await b.api.getFile(ref.file_id); + const url = `https://api.telegram.org/file/bot${token}/${file.file_path}`; + const buf = Buffer.from(await (await fetch(url)).arrayBuffer()); + const key = sourceKey(randomUUID(), 'jpg'); + await putObject(key, buf, 'image/jpeg'); + sources.push({ source_path: key, filename: ref.name || 'telegram.jpg', quality: ref.quality || 'original' }); + } catch (e) { console.error('[telegram] Datei laden fehlgeschlagen', e); } + } + if (!sources.length) { await b.api.sendMessage(chatId, '❌ Keine Bilder ladbar.'); return; } + + const snap = { + tasks: recipe.tasks, output_format: recipe.output_format, orientation: recipe.orientation, + crop_mode: recipe.crop_mode || 'crop', dpi: recipe.dpi || 300, contour_mm: recipe.contour_mm, + model_key: recipe.model_key, custom_instruction: recipe.custom_instruction, + delivery: recipe.delivery || 'library', picdrop_gallery: recipe.picdrop_gallery, + }; + const job = await one<{ id: string }>( + `INSERT INTO jobs (created_by, origin, recipe_snapshot, status, total, telegram_chat_id) + VALUES ($1,'telegram',$2,'queued',$3,$4) RETURNING id`, + [link?.user_id || null, JSON.stringify(snap), sources.length, chatId]); + for (let i = 0; i < sources.length; i++) { + const it = await one<{ id: string }>( + `INSERT INTO items (job_id, position, status, source_path, filename, source_quality) + VALUES ($1,$2,'queued',$3,$4,$5) RETURNING id`, + [job!.id, i, sources[i].source_path, sources[i].filename, sources[i].quality]); + await enqueue({ itemId: it!.id, jobId: job!.id }); + } + await query(`UPDATE telegram_drafts SET status='dispatched' WHERE id=$1`, [draftId]); +} + +/** Vom Worker aufgerufen: eine Rückmeldung an den Chat, wenn der Auftrag fertig ist. */ +export async function notifyJobDone(chatId: number, jobId: string): Promise { + const b = await getBot(); if (!b) return; + const items = await query( + `SELECT id, filename, result_path, status, delivery_status FROM items WHERE job_id=$1 ORDER BY position`, [jobId]); + const done = items.filter((i) => i.status === 'done'); + const failed = items.filter((i) => i.status === 'failed'); + const delivered = items.filter((i) => i.delivery_status === 'delivered').length; + + // Ergebnisse als Dateien zurücksenden (bei überschaubarer Menge) + if (done.length <= 10) { + for (const it of done) { + try { + const buf = await getObject(it.result_path); + await b.api.sendDocument(chatId, new InputFile(buf, it.filename || 'klarbild.png')); + } catch (e) { console.error('[telegram] senden', e); } + } + } + 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); +} + +// --- Handler ---------------------------------------------------------------- +function register(b: Bot) { + 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' }); + 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• Bilder weiterleiten (einzeln oder als Album) — am besten als *Datei*.\n• Rezept wählen.\n• Ich melde mich einmal, wenn alles fertig ist — mit den Ergebnissen und Links.\n\nBefehle: /rezepte (Standard setzen) · /status · /start', + { parse_mode: 'Markdown' })); + + 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 }); + }); + + b.command('status', async (ctx) => { + if (!(await linkedUser(ctx.chat.id))) return ctx.reply('Bitte zuerst koppeln.'); + const j = await one(`SELECT status, done_count, failed_count, total FROM jobs + WHERE telegram_chat_id=$1 ORDER BY created_at DESC LIMIT 1`, [ctx.chat.id]); + if (!j) return ctx.reply('Noch keine Aufträge.'); + return ctx.reply(`Letzter Auftrag: ${j.status} — ${j.done_count}/${j.total} fertig${j.failed_count ? `, ${j.failed_count} fehlgeschlagen` : ''}.`); + }); + + const onImage = async (ctx: any, fileId: string, name: string, quality: 'original' | 'compressed') => { + if (!(await linkedUser(ctx.chat.id))) return ctx.reply('⛔️ Nicht gekoppelt. Bitte Kopplungscode aus dem Admin eingeben.'); + await addToDraft(ctx.chat.id, ctx.message?.media_group_id || null, { file_id: fileId, name, quality }); + }; + b.on('message:photo', async (ctx) => { + const p = ctx.message.photo[ctx.message.photo.length - 1]; + await onImage(ctx, p.file_id, 'foto.jpg', 'compressed'); + }); + b.on('message:document', async (ctx) => { + const d = ctx.message.document; + if (!(d.mime_type || '').startsWith('image/')) return; + await onImage(ctx, d.file_id, d.file_name || 'bild', 'original'); + }); + + b.on('message:text', async (ctx) => { + if (ctx.message.text.startsWith('/')) return; + if (await linkedUser(ctx.chat.id)) return; // gekoppelt: Text ignorieren + if (await tryPair(ctx.chat.id, ctx.message.text)) { + return ctx.reply('✅ Verbunden! Leite mir jetzt Bilder weiter. /help für mehr.'); + } + return ctx.reply('Code ungültig oder abgelaufen. Neuen Code im Admin erzeugen.'); + }); + + b.on('callback_query:data', async (ctx) => { + const [kind, a, bId] = ctx.callbackQuery.data.split(':'); + await ctx.answerCallbackQuery(); + if (kind === 'd') { // Standard-Rezept setzen + await query(`UPDATE telegram_links SET default_recipe_id=$2 WHERE chat_id=$1`, [ctx.chat!.id, a]); + return ctx.editMessageText('Standard-Rezept gesetzt.'); + } + if (kind === 'r') { // Rezept für Draft gewählt + await ctx.editMessageText('Alles klar.'); + const b2 = await getBot(); if (b2) await dispatchDraft(ctx.chat!.id, bId, a, b2); + } + }); +} diff --git a/src/pages/api/admin/telegram/links.ts b/src/pages/api/admin/telegram/links.ts new file mode 100644 index 0000000..3dee16a --- /dev/null +++ b/src/pages/api/admin/telegram/links.ts @@ -0,0 +1,19 @@ +import type { APIRoute } from 'astro'; +import { query } 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' } }); + +export const GET: APIRoute = async () => { + const links = await query(`SELECT l.chat_id, l.active, l.linked_at, u.display_name AS user_name + FROM telegram_links l LEFT JOIN users u ON u.id=l.user_id ORDER BY l.linked_at DESC`); + return json({ links }); +}; + +export const DELETE: APIRoute = async ({ request }) => { + const b = await request.json().catch(() => ({})); + if (!b.chat_id) return json({ error: 'chat_id nötig.' }, 400); + await query('DELETE FROM telegram_links WHERE chat_id=$1', [b.chat_id]); + return json({ ok: true }); +}; diff --git a/src/pages/api/admin/telegram/pairing-code.ts b/src/pages/api/admin/telegram/pairing-code.ts new file mode 100644 index 0000000..8a959b5 --- /dev/null +++ b/src/pages/api/admin/telegram/pairing-code.ts @@ -0,0 +1,20 @@ +import type { APIRoute } from 'astro'; +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' } }); + +// Erzeugt einen 6-stelligen Kopplungscode (15 Min gültig) für einen Nutzer. +export const POST: APIRoute = async ({ request, locals }) => { + const b = await request.json().catch(() => ({})); + const userId = b.userId || locals.user?.uid; + if (!userId) return json({ error: 'userId nötig.' }, 400); + // 6-stelliger Code (Math.random ist hier ok — kein Krypto-Geheimnis, nur kurzlebig) + const code = String(Math.floor(100000 + Math.random() * 900000)); + await one(`INSERT INTO telegram_pairing_codes (code, user_id, expires_at) + VALUES ($1,$2, now() + interval '15 minutes') + ON CONFLICT (code) DO UPDATE SET user_id=$2, expires_at=now()+interval '15 minutes', used_at=NULL`, + [code, userId]); + return json({ code, expires_minutes: 15 }); +}; diff --git a/src/pages/api/telegram/webhook.ts b/src/pages/api/telegram/webhook.ts new file mode 100644 index 0000000..bdfab7b --- /dev/null +++ b/src/pages/api/telegram/webhook.ts @@ -0,0 +1,20 @@ +import type { APIRoute } from 'astro'; +import { webhookCallback } from 'grammy'; +import { getBot, getWebhookSecret } from '../../../lib/telegram'; + +export const prerender = false; +let handler: ((req: Request) => Promise) | null = null; + +export const POST: APIRoute = async ({ request }) => { + const bot = await getBot(); + if (!bot) return new Response('Bot inaktiv', { status: 503 }); + if (!handler) { + handler = webhookCallback(bot, 'std/http', { secretToken: getWebhookSecret() || undefined }); + } + try { + return await handler(request); + } catch (e) { + console.error('[telegram] webhook', e); + return new Response('ok'); // Telegram nicht mit 5xx fluten + } +}; diff --git a/src/worker.ts b/src/worker.ts index 0c8e83b..92d6a51 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -56,6 +56,14 @@ async function maybeComplete(jobId: string): Promise { [jobId]); // Picdrop-Auslieferung für alle offenen Positionen anstoßen. try { await deliverPendingForJob(jobId); } catch (e) { console.error('[worker] Auslieferung:', e); } - // TODO: Benachrichtigung (Telegram/n8n). + // Telegram-Rückmeldung, wenn der Auftrag aus einem Chat kam. + const jt = await one<{ origin: string; telegram_chat_id: string | null }>( + 'SELECT origin, telegram_chat_id FROM jobs WHERE id=$1', [jobId]); + if (jt?.origin === 'telegram' && jt.telegram_chat_id) { + try { + const { notifyJobDone } = await import('./lib/telegram'); + await notifyJobDone(Number(jt.telegram_chat_id), jobId); + } catch (e) { console.error('[worker] Telegram-Notify:', e); } + } } }