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
+2
View File
@@ -0,0 +1,2 @@
-- Telegram: Auftrag kennt den auslösenden Chat (für die Rückmeldung).
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS telegram_chat_id bigint;
+31 -2
View File
@@ -6,19 +6,31 @@ export default function AdminApp() {
const [models, setModels] = useState<any[]>([]);
const [avail, setAvail] = useState<any[]>([]);
const [users, setUsers] = useState<any[]>([]);
const [tgLinks, setTgLinks] = useState<any[]>([]);
const [toast, setToast] = useState<string | null>(null);
const [orKey, setOrKey] = useState(''); const [pdPw, setPdPw] = useState('');
const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2600); };
const load = useCallback(async () => {
const [a, b, c, d] = await Promise.all([
const [a, b, c, d, e] = await Promise.all([
fetch('/api/admin/settings').then((r) => r.json()),
fetch('/api/admin/stats').then((r) => r.json()),
fetch('/api/admin/models?available=1').then((r) => r.json()),
fetch('/api/admin/users').then((r) => r.json()),
fetch('/api/admin/telegram/links').then((r) => r.json()).catch(() => ({ links: [] })),
]);
setS(a.settings || {}); setStats(b); setModels(c.models || []); setAvail(c.available || []); setUsers(d.users || []);
setS(a.settings || {}); setStats(b); setModels(c.models || []); setAvail(c.available || []);
setUsers(d.users || []); setTgLinks(e.links || []);
}, []);
const pairCode = async (userId: string) => {
const r = await fetch('/api/admin/telegram/pairing-code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId }) }).then((x) => x.json());
if (r.code) alert(`Kopplungscode: ${r.code}\n\n15 Minuten gültig. Im Klarbild-Telegram-Chat eingeben.`);
};
const unlink = async (chat_id: number) => {
await fetch('/api/admin/telegram/links', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chat_id }) });
load();
};
useEffect(() => { load(); }, [load]);
const field = (k: string, v: any) => setS((p: any) => ({ ...p, [k]: v }));
@@ -147,12 +159,29 @@ export default function AdminApp() {
{users.map((u) => (
<div key={u.id} className="zeile">
<div><b>{u.display_name || u.username}</b> <span className="fein">{u.username} · {u.role}</span></div>
<div className="reihe">
<button className="mini" onClick={() => pairCode(u.id)}>Telegram koppeln</button>
<button className="mini" onClick={() => setPw(u.id)}>Passwort setzen</button>
</div>
</div>
))}
</div>
</section>
<section className="karte">
<div className="kopfzeile"><span className="mono-label">Telegram-Bot</span></div>
<div className="liste">
<div className="fein">Über Telegram koppeln" bei einem Nutzer einen Code erzeugen und im Chat mit dem Bot eingeben. Danach dort Bilder weiterleiten → Rezept wählen → Ergebnisse kommen zurück.</div>
{tgLinks.map((l) => (
<div key={l.chat_id} className="zeile">
<div><b>Chat {l.chat_id}</b> <span className="fein">{l.user_name || ''} · seit {new Date(l.linked_at).toLocaleDateString('de-DE')}</span></div>
<button className="mini" onClick={() => unlink(l.chat_id)}>Trennen</button>
</div>
))}
{tgLinks.length === 0 && <div className="fein">Noch keine gekoppelten Chats.</div>}
</div>
</section>
{toast && <div className="toast">{toast}</div>}
<AdminStyles />
</div>
+2
View File
@@ -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; });
}
+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);
}
});
}
+19
View File
@@ -0,0 +1,19 @@
import type { APIRoute } from 'astro';
import { query } from '../../../../lib/db';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
export const GET: APIRoute = async () => {
const links = await query(`SELECT l.chat_id, l.active, l.linked_at, u.display_name AS user_name
FROM telegram_links l LEFT JOIN users u ON u.id=l.user_id ORDER BY l.linked_at DESC`);
return json({ links });
};
export const DELETE: APIRoute = async ({ request }) => {
const b = await request.json().catch(() => ({}));
if (!b.chat_id) return json({ error: 'chat_id nötig.' }, 400);
await query('DELETE FROM telegram_links WHERE chat_id=$1', [b.chat_id]);
return json({ ok: true });
};
@@ -0,0 +1,20 @@
import type { APIRoute } from 'astro';
import { one } from '../../../../lib/db';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
// Erzeugt einen 6-stelligen Kopplungscode (15 Min gültig) für einen Nutzer.
export const POST: APIRoute = async ({ request, locals }) => {
const b = await request.json().catch(() => ({}));
const userId = b.userId || locals.user?.uid;
if (!userId) return json({ error: 'userId nötig.' }, 400);
// 6-stelliger Code (Math.random ist hier ok — kein Krypto-Geheimnis, nur kurzlebig)
const code = String(Math.floor(100000 + Math.random() * 900000));
await one(`INSERT INTO telegram_pairing_codes (code, user_id, expires_at)
VALUES ($1,$2, now() + interval '15 minutes')
ON CONFLICT (code) DO UPDATE SET user_id=$2, expires_at=now()+interval '15 minutes', used_at=NULL`,
[code, userId]);
return json({ code, expires_minutes: 15 });
};
+20
View File
@@ -0,0 +1,20 @@
import type { APIRoute } from 'astro';
import { webhookCallback } from 'grammy';
import { getBot, getWebhookSecret } from '../../../lib/telegram';
export const prerender = false;
let handler: ((req: Request) => Promise<Response>) | null = null;
export const POST: APIRoute = async ({ request }) => {
const bot = await getBot();
if (!bot) return new Response('Bot inaktiv', { status: 503 });
if (!handler) {
handler = webhookCallback(bot, 'std/http', { secretToken: getWebhookSecret() || undefined });
}
try {
return await handler(request);
} catch (e) {
console.error('[telegram] webhook', e);
return new Response('ok'); // Telegram nicht mit 5xx fluten
}
};
+9 -1
View File
@@ -56,6 +56,14 @@ async function maybeComplete(jobId: string): Promise<void> {
[jobId]);
// Picdrop-Auslieferung für alle offenen Positionen anstoßen.
try { await deliverPendingForJob(jobId); } catch (e) { console.error('[worker] Auslieferung:', e); }
// TODO: Benachrichtigung (Telegram/n8n).
// Telegram-Rückmeldung, wenn der Auftrag aus einem Chat kam.
const jt = await one<{ origin: string; telegram_chat_id: string | null }>(
'SELECT origin, telegram_chat_id FROM jobs WHERE id=$1', [jobId]);
if (jt?.origin === 'telegram' && jt.telegram_chat_id) {
try {
const { notifyJobDone } = await import('./lib/telegram');
await notifyJobDone(Number(jt.telegram_chat_id), jobId);
} catch (e) { console.error('[worker] Telegram-Notify:', e); }
}
}
}