feat: Studio mode selector + Telegram compose/generate (full parity)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
This commit is contained in:
+144
-27
@@ -12,6 +12,8 @@ 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>();
|
||||
|
||||
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');
|
||||
@@ -66,20 +68,22 @@ async function tryPair(chatId: number, code: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
// --- 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'
|
||||
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];
|
||||
await query(`UPDATE telegram_drafts SET file_refs=$2, last_received_at=now() WHERE id=$1`,
|
||||
[existing.id, JSON.stringify(refs)]);
|
||||
// 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, status, notice_message_id)
|
||||
VALUES ($1,$2,$3,'collecting',$4) RETURNING id`,
|
||||
[chatId, mediaGroup, JSON.stringify([fileRef]), noticeMsgId ?? null]);
|
||||
`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;
|
||||
}
|
||||
|
||||
@@ -87,18 +91,21 @@ async function addToDraft(chatId: number, mediaGroup: string | null, fileRef: an
|
||||
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
|
||||
`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 8');
|
||||
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'); }
|
||||
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. Welches Rezept?` +
|
||||
`${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]);
|
||||
@@ -107,19 +114,12 @@ export async function sweepDrafts(): Promise<void> {
|
||||
}
|
||||
|
||||
// --- 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
|
||||
/** 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 draft.file_refs || []) {
|
||||
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}`;
|
||||
@@ -129,6 +129,32 @@ async function dispatchDraft(chatId: number, draftId: string, recipeId: string,
|
||||
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,
|
||||
};
|
||||
}
|
||||
return { output_format: 'keep', crop_mode: 'crop', dpi: 300, delivery: 'library' };
|
||||
}
|
||||
|
||||
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 = {
|
||||
@@ -138,8 +164,8 @@ async function dispatchDraft(chatId: number, draftId: string, recipeId: string,
|
||||
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`,
|
||||
`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 }>(
|
||||
@@ -151,6 +177,55 @@ async function dispatchDraft(chatId: number, draftId: string, recipeId: string,
|
||||
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,
|
||||
};
|
||||
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,
|
||||
};
|
||||
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 });
|
||||
}
|
||||
|
||||
/** 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;
|
||||
@@ -184,9 +259,16 @@ function register(b: Bot) {
|
||||
});
|
||||
|
||||
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',
|
||||
'So geht’s:\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' }));
|
||||
|
||||
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');
|
||||
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');
|
||||
@@ -205,7 +287,8 @@ function register(b: Bot) {
|
||||
|
||||
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 });
|
||||
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];
|
||||
@@ -219,9 +302,23 @@ function register(b: Bot) {
|
||||
|
||||
b.on('message:text', async (ctx) => {
|
||||
if (ctx.message.text.startsWith('/')) return;
|
||||
if (await linkedUser(ctx.chat.id)) return; // gekoppelt: Text ignorieren
|
||||
const linked = await linkedUser(ctx.chat.id);
|
||||
if (linked) {
|
||||
// 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);
|
||||
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 });
|
||||
}
|
||||
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('✅ Verbunden! Leite mir Bilder weiter, oder erzeuge mit /neu ein neues Bild. /help für mehr.');
|
||||
}
|
||||
return ctx.reply('Code ungültig oder abgelaufen. Neuen Code im Admin erzeugen.');
|
||||
});
|
||||
@@ -241,5 +338,25 @@ function register(b: Bot) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user