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:
@@ -13,6 +13,8 @@ export function ensureInit(): Promise<void> {
|
||||
try { await ensureBucket(); } catch (e) { console.error('[init] S3 nicht bereit:', e); }
|
||||
await seed();
|
||||
try { await startImageWorker(); } catch (e) { console.error('[init] Worker-Start fehlgeschlagen:', e); }
|
||||
try { const { setupTelegram } = await import('./telegram'); await setupTelegram(); }
|
||||
catch (e) { console.error('[init] Telegram-Setup fehlgeschlagen:', e); }
|
||||
console.log('[init] Klarbild bereit.');
|
||||
})().catch((e) => { started = null; throw e; });
|
||||
}
|
||||
|
||||
@@ -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 geht’s:\n• Bilder weiterleiten (einzeln oder als Album) — am besten als *Datei*.\n• Rezept wählen.\n• Ich melde mich einmal, wenn alles fertig ist — mit den Ergebnissen und Links.\n\nBefehle: /rezepte (Standard setzen) · /status · /start',
|
||||
{ parse_mode: 'Markdown' }));
|
||||
|
||||
b.command('rezepte', async (ctx) => {
|
||||
if (!(await linkedUser(ctx.chat.id))) return ctx.reply('Bitte zuerst koppeln (Code aus dem Admin).');
|
||||
const recipes = await query<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);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user