feat: button-driven Telegram menu (reply keyboard + commands), reachability diagnostic, How-To update

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
This commit is contained in:
2026-07-23 20:12:27 +00:00
parent 5105c7d529
commit 7608bc62da
4 changed files with 148 additions and 29 deletions
+59 -17
View File
@@ -1,6 +1,6 @@
// 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 { Bot, InputFile, InlineKeyboard, Keyboard } from 'grammy';
import { randomUUID } from 'node:crypto';
import { one, query } from './db';
import { decrypt } from './crypto';
@@ -14,6 +14,21 @@ 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();
}
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\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');
@@ -48,6 +63,16 @@ export async function setupTelegram(): Promise<void> {
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);
}
@@ -247,34 +272,39 @@ export async function notifyJobDone(chatId: number, jobId: string): Promise<void
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);
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-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' });
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' });
});
b.command('help', (ctx) => ctx.reply(
'So gehts:\n• *Bearbeiten:* Bilder weiterleiten (am besten als *Datei*) → Rezept wählen.\n• *Kombinieren:* 2+ Bilder senden, dazu eine Bildunterschrift wie „mit unserer Hündin Frieda" → „🔀 Kombinieren".\n• *Neu erzeugen:* `/neu <Beschreibung>` — z. B. `/neu ein Olivenzweig auf Sand`.\n\nIch melde mich einmal, wenn alles fertig ist — mit den Ergebnissen und Links.\nBefehle: /neu · /rezepte (Standard setzen) · /status · /start',
{ 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) return ctx.reply('Schreib z. B.: /neu ein minimalistisches Poster mit einem Olivenzweig auf Sand');
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).');
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 });
return sendRecipeMenu(ctx);
});
b.command('status', async (ctx) => {
@@ -302,23 +332,35 @@ function register(b: Bot) {
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, ctx.message.text.trim(), b2);
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('🎨 Neues Bild erzeugen', 'g:x');
pendingGenerate.set(ctx.chat.id, ctx.message.text.trim());
return ctx.reply('Soll ich daraus ein neues Bild erzeugen? (oder /help)', { reply_markup: kb });
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, ctx.message.text)) {
return ctx.reply('✅ Verbunden! Leite mir Bilder weiter, oder erzeuge mit /neu ein neues Bild. /help für mehr.');
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.');
});