Files
klarbild/src/lib/telegram.ts
T
till 7d42782c41 feat: all AI formats printable, crop-vs-border rule, mobile layout, Passbilder rename
Print now offers every format the AI pipeline knows (9x13 to 60x90, DIN A6-A2,
squares, poster/frame sizes to 70x100, and the screen ratios as physical sizes).
When a picture does not match the target ratio the user picks per image between
cropping to fill and keeping the whole picture on a border colour - never a
stretch. The module is called Passbilder now; /druck redirects.

Fixes two real defects: sharp runs extend after resize, so padded cells came out
oversized (a 35x45 mm cell became 35x171 mm with a border), and the grid packer
rotated a single portrait photo just because more would fit sideways.

Mobile: cards become rows, touch targets ~40px, crop editor as a bottom sheet.
Verified at 390/820/1440px - all three produce the same PDF.
2026-08-18 07:04:08 +00:00

504 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<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();
}
// Druckbogen per Telegram: feste, knopfbare Auswahl — kein Freitext nötig.
const PRINT_SIZES = ['P35x45', 'K30x40', 'S9x13', 'S10x15', 'S12x15', 'S13x18'];
const PRINT_COUNTS = [1, 2, 4, 8, 0]; // 0 = „Bogen füllen"
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' +
'📐 *Passbilder (ohne KI):* Bild schicken → „📐 Passbilder" → 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<string> {
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<Bot | null> {
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<void> {
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<boolean> {
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<void> {
const b = await getBot(); if (!b) return;
const drafts = await query<any>(
`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<any>('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('📐 Passbilder / Druckbogen', '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<any[]> {
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<any> {
const link = await linkedUser(chatId);
if (link?.default_recipe_id) {
const r = await one<any>('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<any>('SELECT * FROM telegram_drafts WHERE id=$1', [draftId]);
if (!draft || draft.status === 'dispatched') return;
const recipe = await one<any>('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<any>('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<any>('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<string, { bytes: Buffer; ext: 'jpg' | 'png' }> = {};
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<void> {
const b = await getBot(); if (!b) return;
const items = await query<any>(
`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<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 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<any>(`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();
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 gehts.');
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);
}
});
}