7d42782c41
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.
504 lines
27 KiB
TypeScript
504 lines
27 KiB
TypeScript
// 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 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' +
|
||
'📐 *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 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);
|
||
}
|
||
});
|
||
}
|