// Telegram-Bot (grammY, Webhook) — vollwertiger Eingangs- und Bedien-Kanal. // Kopplung, Bündelung weitergeleiteter Bilder, Rezeptwahl, Verarbeitung, Rückmeldung. import { Bot, InputFile, InlineKeyboard, Keyboard } 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'; import { layout, cutMarks, recommendedGap, type SheetSpec } from './printlayout'; import { renderCell, buildSheetPdf } from './printrender'; import { photoById, labelMm, paperById } from './paper'; const QUIET_MS = 4000; // Ruhefenster für die Bündelung const BASE = process.env.PUBLIC_BASE_URL || ''; 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(); } // Druckbogen per Telegram: feste, knopfbare Auswahl — kein Freitext nötig. const PRINT_SIZES = ['P35x45', 'K30x40', 'S9x13', 'S10x15', 'S13x18', 'R30x40']; const PRINT_COUNTS = [1, 2, 4, 8, 0]; // 0 = „Bogen füllen" 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' + '🖨 *Drucken (ohne KI):* Bild schicken → „🖨 Drucken" → Maß und Anzahl antippen. ' + 'Du bekommst ein PDF in 100 %-Größe mit Schnittmarken.\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'); 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); } } // 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); } // --- 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, caption?: string | null) { const existing = await one<{ id: string; file_refs: any[]; caption: string | null }>( `SELECT id, file_refs, caption 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]; // Erste vorhandene Bildunterschrift als Beschreibung merken. const cap = existing.caption || (caption?.trim() || null); await query(`UPDATE telegram_drafts SET file_refs=$2, caption=$3, last_received_at=now() WHERE id=$1`, [existing.id, JSON.stringify(refs), cap]); return existing.id; } const row = await one<{ id: string }>( `INSERT INTO telegram_drafts (chat_id, media_group_id, file_refs, caption, status) VALUES ($1,$2,$3,$4,'collecting') RETURNING id`, [chatId, mediaGroup, JSON.stringify([fileRef]), caption?.trim() || 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, caption 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 6'); const kb = new InlineKeyboard(); // Callback-Daten <64 Bytes: nur Rezept-UUID; der Draft wird beim Klick über den Chat gefunden. recipes.forEach((r, i) => { kb.text(r.name, `r:${r.id}`); if (i % 2 === 1) kb.row(); }); const n = (d.file_refs || []).length; // Ab 2 Bildern zusätzlich „Kombinieren" anbieten. if (n >= 2) { kb.row(); kb.text('🔀 Zu einem Bild kombinieren', 'c:x'); } kb.row(); kb.text('🖨 Drucken (ohne KI)', 'p:menu'); const compressed = (d.file_refs || []).some((f: any) => f.quality === 'compressed'); const capNote = d.caption ? `\n📝 Beschreibung erkannt: „${d.caption}" — für „Kombinieren".` : ''; try { await b.api.sendMessage(d.chat_id, `${n} Bild${n > 1 ? 'er' : ''} empfangen. Was möchtest du tun?` + capNote + (compressed ? '\n⚠️ Als Foto gesendet (komprimiert). Für volle Qualität als *Datei* senden.' : ''), { reply_markup: kb, parse_mode: 'Markdown' }); await query(`UPDATE telegram_drafts SET status='awaiting_recipe' WHERE id=$1`, [d.id]); } catch (e) { console.error('[telegram] sweep send:', e); } } } // --- Draft -> Auftrag ------------------------------------------------------- /** Lädt alle Bilder eines Drafts von Telegram und legt sie in den Objektspeicher. */ async function refsToSources(refs: any[], b: Bot): Promise { const sources: any[] = []; const token = await getToken(); for (const ref of 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); } } return sources; } /** Basis-Snapshot aus dem Standard-Rezept des Chats (Format/Modell/Auslieferung). */ async function chatRecipeDefaults(chatId: number): Promise { const link = await linkedUser(chatId); if (link?.default_recipe_id) { const r = await one('SELECT * FROM recipes WHERE id=$1', [link.default_recipe_id]); if (r) return { output_format: r.output_format, orientation: r.orientation, crop_mode: r.crop_mode || 'crop', dpi: r.dpi || 300, model_key: r.model_key, delivery: r.delivery || 'library', picdrop_gallery: r.picdrop_gallery, output_ext: r.output_ext, delivery_target_id: r.delivery_target_id, }; } return { output_format: 'keep', crop_mode: 'crop', dpi: 300, delivery: 'library', output_ext: null }; } 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) …`); const sources = await refsToSources(draft.file_refs || [], b); 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, output_ext: recipe.output_ext, delivery_target_id: recipe.delivery_target_id, }; const job = await one<{ id: string }>( `INSERT INTO jobs (created_by, origin, mode, recipe_snapshot, status, total, telegram_chat_id) VALUES ($1,'telegram','each',$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]); } /** Kombinieren: alle Bilder des Drafts + Beschreibung → ein neues Bild. */ async function dispatchCompose(chatId: number, draftId: string, description: string, b: Bot) { const draft = await one('SELECT * FROM telegram_drafts WHERE id=$1', [draftId]); if (!draft || draft.status === 'dispatched') return; const link = await linkedUser(chatId); await b.api.sendMessage(chatId, '⏳ Kombiniere die Bilder …'); const sources = await refsToSources(draft.file_refs || [], b); if (sources.length < 2) { await b.api.sendMessage(chatId, '❌ Zum Kombinieren brauche ich mindestens 2 Bilder.'); return; } const d = await chatRecipeDefaults(chatId); const wantsFormat = d.output_format && d.output_format !== 'keep'; const snap = { tasks: wantsFormat ? ['format'] : [], output_format: d.output_format, orientation: d.orientation, crop_mode: d.crop_mode || 'crop', dpi: d.dpi || 300, model_key: d.model_key, prompt_text: description, delivery: d.delivery || 'library', picdrop_gallery: d.picdrop_gallery, output_ext: d.output_ext, delivery_target_id: d.delivery_target_id, }; const job = await one<{ id: string }>( `INSERT INTO jobs (created_by, origin, mode, recipe_snapshot, status, total, telegram_chat_id) VALUES ($1,'telegram','compose',$2,'queued',1,$3) RETURNING id`, [link?.user_id || null, JSON.stringify(snap), chatId]); const it = await one<{ id: string }>( `INSERT INTO items (job_id, position, status, source_paths, filename, source_quality) VALUES ($1,0,'queued',$2,'kombiniert','original') RETURNING id`, [job!.id, JSON.stringify(sources.map((s) => s.source_path))]); await enqueue({ itemId: it!.id, jobId: job!.id }); await query(`UPDATE telegram_drafts SET status='dispatched' WHERE id=$1`, [draftId]); } /** Freitext: ein komplett neues Bild allein aus der Beschreibung. */ async function dispatchGenerate(chatId: number, description: string, b: Bot) { const link = await linkedUser(chatId); await b.api.sendMessage(chatId, '⏳ Erzeuge ein neues Bild …'); const d = await chatRecipeDefaults(chatId); const wantsFormat = d.output_format && d.output_format !== 'keep'; const snap = { tasks: wantsFormat ? ['format'] : [], output_format: d.output_format, orientation: d.orientation, crop_mode: d.crop_mode || 'crop', dpi: d.dpi || 300, model_key: d.model_key, prompt_text: description, delivery: d.delivery || 'library', picdrop_gallery: d.picdrop_gallery, output_ext: d.output_ext, delivery_target_id: d.delivery_target_id, }; const job = await one<{ id: string }>( `INSERT INTO jobs (created_by, origin, mode, recipe_snapshot, status, total, telegram_chat_id) VALUES ($1,'telegram','generate',$2,'queued',1,$3) RETURNING id`, [link?.user_id || null, JSON.stringify(snap), chatId]); const it = await one<{ id: string }>( `INSERT INTO items (job_id, position, status, filename, source_quality) VALUES ($1,0,'queued','neu','original') RETURNING id`, [job!.id]); await enqueue({ itemId: it!.id, jobId: job!.id }); } /** * Druckbogen ohne KI: Bilder des Drafts auf ein Maß bringen, auf A4 setzen, * Schnittmarken dazu, als PDF zurückschicken. Läuft direkt (keine Warteschlange, * kein Modell, keine Kosten). */ async function dispatchPrint(chatId: number, draftId: string, sizeId: string, count: number, b: Bot) { const draft = await one('SELECT * FROM telegram_drafts WHERE id=$1', [draftId]); if (!draft) { await b.api.sendMessage(chatId, 'Kein offener Bild-Stapel gefunden.'); return; } const size = photoById(sizeId); if (!size) { await b.api.sendMessage(chatId, 'Unbekanntes Maß.'); return; } await b.api.sendMessage(chatId, '📐 Baue den Druckbogen …'); const sources = await refsToSources(draft.file_refs || [], b); if (!sources.length) { await b.api.sendMessage(chatId, '❌ Keine Bilder ladbar.'); return; } const a4 = paperById('A4')!; const sheet: SheetSpec = { wMm: a4.w, hMm: a4.h, marginMm: 5, gapMm: recommendedGap('corner', 0, 4, 3), bleedMm: 0, center: true, }; // „Bogen füllen": so oft wie möglich, gleichmäßig auf die Bilder verteilt. let per = count; if (!per) { const probe = layout([{ id: 'x', wMm: size.w, hMm: size.h, count: 200, allowRotate: true }], sheet); per = Math.max(1, Math.floor((probe.pages[0]?.placements.length || 1) / sources.length)); } const specs = sources.map((s, i) => ({ id: `s${i}`, wMm: size.w, hMm: size.h, count: per, allowRotate: true })); const plan = layout(specs, sheet); if (!plan.pages.length) { await b.api.sendMessage(chatId, '❌ Das Maß passt nicht auf A4.'); return; } const images: Record = {}; for (const [i, src] of sources.entries()) { const buf = await getObject(src.source_path); const rots = new Set(plan.pages.flatMap((pg) => pg.placements.filter((p) => p.specId === `s${i}`).map((p) => p.rotated))); for (const rot of rots) { const out = await renderCell(buf, null, size.w, size.h, 300, { ext: 'jpg', rotate: rot }); images[rot ? `s${i}::rot` : `s${i}`] = { bytes: out.buffer, ext: out.ext }; } } const pages = plan.pages.map((pg) => ({ placements: pg.placements.map((p) => ({ ...p, specId: p.rotated ? `${p.specId}::rot` : p.specId })), })); const marks = plan.pages.map((pg) => cutMarks(pg, sheet, { mode: 'corner', lengthMm: 4, offsetMm: 3 })); const label = labelMm(size.w, size.h); const bytes = await buildSheetPdf({ sheet, pages, marksPerPage: marks, images, title: `Klarbild Druckbogen ${label}`, footer: `Klarbild · DIN A4 · ${label} · 300 dpi · Druck bei 100 % (nicht „an Seite anpassen")`, }); const file = new InputFile(Buffer.from(bytes), `klarbild-druckbogen-${label.replace(/[^0-9]+/g, 'x')}.pdf`); await b.api.sendDocument(chatId, file, { caption: `📐 ${plan.pages.length} Bogen · ${plan.pages[0].placements.length} Bild(er) auf Bogen 1 · ${label}\n` + 'Beim Drucken „Tatsächliche Größe / 100 %" wählen — sonst stimmt das Maß nicht.', }); 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`; 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('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 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' }); }); 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) { 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).'); return sendRecipeMenu(ctx); }); 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 }, ctx.message?.caption || null); }; 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; 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, 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('✨ 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, 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.'); }); b.on('callback_query:data', async (ctx) => { const [kind, recipeId, extra] = ctx.callbackQuery.data.split(':'); await ctx.answerCallbackQuery(); // Auch Knöpfe brauchen eine aktive Kopplung — sonst wirkt ein alter Chat weiter. if (!(await linkedUser(ctx.chat!.id))) return ctx.editMessageText('⛔️ Nicht (mehr) gekoppelt. Bitte neuen Kopplungscode aus dem Admin eingeben.'); if (kind === 'p') { // Druckbogen ohne KI const draft = await one<{ id: string }>( `SELECT id FROM telegram_drafts WHERE chat_id=$1 AND status IN ('awaiting_recipe','awaiting_print') ORDER BY last_received_at DESC LIMIT 1`, [ctx.chat!.id]); if (!draft) return ctx.editMessageText('Kein offener Bild-Stapel gefunden — bitte Bilder neu senden.'); if (recipeId === 'menu') { await query(`UPDATE telegram_drafts SET status='awaiting_print' WHERE id=$1`, [draft.id]); const kb = new InlineKeyboard(); PRINT_SIZES.forEach((id, i) => { const s = photoById(id); if (!s) return; kb.text(labelMm(s.w, s.h), `p:${id}`); if (i % 2 === 1) kb.row(); }); return ctx.editMessageText('📐 Welches Endmaß soll das Bild haben?', { reply_markup: kb }); } if (extra === undefined) { const s = photoById(recipeId); if (!s) return ctx.editMessageText('Unbekanntes Maß.'); const kb = new InlineKeyboard(); PRINT_COUNTS.forEach((n, i) => { kb.text(n ? `${n}×` : 'Bogen füllen', `p:${recipeId}:${n}`); if (i % 2 === 1) kb.row(); }); return ctx.editMessageText(`📐 ${labelMm(s.w, s.h)} — wie oft auf den A4-Bogen?`, { reply_markup: kb }); } await ctx.editMessageText('Alles klar — Bogen wird gebaut.'); const b2 = await getBot(); if (b2) await dispatchPrint(ctx.chat!.id, draft.id, recipeId, Number(extra) || 0, b2) .catch((e) => b2.api.sendMessage(ctx.chat!.id, `❌ Druckbogen fehlgeschlagen: ${e?.message || e}`)); return; } if (kind === 'd') { // Standard-Rezept setzen await query(`UPDATE telegram_links SET default_recipe_id=$2 WHERE chat_id=$1`, [ctx.chat!.id, recipeId]); return ctx.editMessageText('Standard-Rezept gesetzt.'); } if (kind === 'r') { // Rezept gewählt → offenen Draft dieses Chats verarbeiten const draft = await one<{ id: string }>( `SELECT id FROM telegram_drafts WHERE chat_id=$1 AND status='awaiting_recipe' ORDER BY last_received_at DESC LIMIT 1`, [ctx.chat!.id]); if (!draft) return ctx.editMessageText('Kein offener Bild-Stapel gefunden — bitte Bilder neu senden.'); await ctx.editMessageText('Alles klar, los geht’s.'); const b2 = await getBot(); if (b2) await dispatchDraft(ctx.chat!.id, draft.id, recipeId, b2); } if (kind === 'c') { // Kombinieren gewählt const draft = await one<{ id: string; caption: string | null }>( `SELECT id, caption FROM telegram_drafts WHERE chat_id=$1 AND status='awaiting_recipe' ORDER BY last_received_at DESC LIMIT 1`, [ctx.chat!.id]); if (!draft) return ctx.editMessageText('Kein offener Bild-Stapel gefunden — bitte Bilder neu senden.'); if (draft.caption?.trim()) { await ctx.editMessageText(`Alles klar — kombiniere mit: „${draft.caption.trim()}".`); const b2 = await getBot(); if (b2) await dispatchCompose(ctx.chat!.id, draft.id, draft.caption.trim(), b2); } else { await query(`UPDATE telegram_drafts SET status='awaiting_compose_text' WHERE id=$1`, [draft.id]); await ctx.editMessageText('Beschreibe kurz, was entstehen soll (z. B. „das Bild, aber mit unserer Hündin Frieda"):'); } } if (kind === 'g') { // Freitext → neues Bild const text = pendingGenerate.get(ctx.chat!.id); if (!text) return ctx.editMessageText('Text nicht mehr vorhanden — bitte erneut senden oder /neu nutzen.'); pendingGenerate.delete(ctx.chat!.id); await ctx.editMessageText('Alles klar, erzeuge ein neues Bild …'); const b2 = await getBot(); if (b2) await dispatchGenerate(ctx.chat!.id, text, b2); } }); }