feat: telegram bot (grammy webhook) — full parity channel

- 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
This commit is contained in:
2026-07-23 17:39:49 +00:00
parent 26c3e0ef07
commit d680b3f67a
8 changed files with 342 additions and 4 deletions
+238
View File
@@ -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<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); }
}
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, 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<void> {
const b = await getBot(); if (!b) return;
const drafts = await query<any>(
`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<any>('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<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) …`);
// 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<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`;
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 gehts:\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<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('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<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 });
};
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);
}
});
}