feat: print sheets via Telegram and MCP
Telegram gains a fully button-driven print flow (photo -> size -> count -> A4 PDF with corner marks), running inline without the queue or a model. MCP gains exact_size and print_sheet so an assistant can produce print-ready files from a local folder. Keeps the rule that every feature works on all three ways in.
This commit is contained in:
+98
-2
@@ -6,6 +6,9 @@ import { one, query } from './db';
|
||||
import { decrypt } from './crypto';
|
||||
import { getObject, putObject, sourceKey } from './storage';
|
||||
import { enqueue } from './queue';
|
||||
import { layout, cutMarks, recommendedGap, type SheetSpec } from './printlayout';
|
||||
import { renderCell, buildSheetPdf } from './printrender';
|
||||
import { photoById, labelMm, paperById } from './paper';
|
||||
|
||||
const QUIET_MS = 4000; // Ruhefenster für die Bündelung
|
||||
const BASE = process.env.PUBLIC_BASE_URL || '';
|
||||
@@ -22,12 +25,18 @@ const BTN_NEW = '✨ Neues Bild', BTN_RECIPES = '📁 Rezept wählen', BTN_HELP
|
||||
function mainKeyboard() {
|
||||
return new Keyboard().text(BTN_NEW).row().text(BTN_RECIPES).text(BTN_HELP).resized().persistent();
|
||||
}
|
||||
// Druckbogen per Telegram: feste, knopfbare Auswahl — kein Freitext nötig.
|
||||
const PRINT_SIZES = ['P35x45', 'K30x40', 'S9x13', 'S10x15', 'S12x15', 'S13x18'];
|
||||
const PRINT_COUNTS = [1, 2, 4, 8, 0]; // 0 = „Bogen füllen"
|
||||
|
||||
const HELP_TEXT =
|
||||
'So funktioniert’s — 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' +
|
||||
'♻️ *Weiterbearbeiten:* ein fertiges Bild von mir einfach wieder zurückschicken.\n' +
|
||||
'📐 *Druckbogen (ohne KI):* Bild schicken → „📐 Druckbogen" → Maß und Anzahl antippen. ' +
|
||||
'Du bekommst ein PDF in 100 %-Größe mit Schnittmarken.\n\n' +
|
||||
'Ich melde mich einmal, wenn alles fertig ist — mit Ergebnis und Link.';
|
||||
|
||||
export async function getToken(): Promise<string> {
|
||||
@@ -126,6 +135,7 @@ export async function sweepDrafts(): Promise<void> {
|
||||
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'); }
|
||||
kb.row(); kb.text('📐 Druckbogen (ohne KI)', 'p:menu');
|
||||
const compressed = (d.file_refs || []).some((f: any) => f.quality === 'compressed');
|
||||
const capNote = d.caption ? `\n📝 Beschreibung erkannt: „${d.caption}" — für „Kombinieren".` : '';
|
||||
try {
|
||||
@@ -254,6 +264,63 @@ async function dispatchGenerate(chatId: number, description: string, b: Bot) {
|
||||
await enqueue({ itemId: it!.id, jobId: job!.id });
|
||||
}
|
||||
|
||||
/**
|
||||
* Druckbogen ohne KI: Bilder des Drafts auf ein Maß bringen, auf A4 setzen,
|
||||
* Schnittmarken dazu, als PDF zurückschicken. Läuft direkt (keine Warteschlange,
|
||||
* kein Modell, keine Kosten).
|
||||
*/
|
||||
async function dispatchPrint(chatId: number, draftId: string, sizeId: string, count: number, b: Bot) {
|
||||
const draft = await one<any>('SELECT * FROM telegram_drafts WHERE id=$1', [draftId]);
|
||||
if (!draft) { await b.api.sendMessage(chatId, 'Kein offener Bild-Stapel gefunden.'); return; }
|
||||
const size = photoById(sizeId);
|
||||
if (!size) { await b.api.sendMessage(chatId, 'Unbekanntes Maß.'); return; }
|
||||
|
||||
await b.api.sendMessage(chatId, '📐 Baue den Druckbogen …');
|
||||
const sources = await refsToSources(draft.file_refs || [], b);
|
||||
if (!sources.length) { await b.api.sendMessage(chatId, '❌ Keine Bilder ladbar.'); return; }
|
||||
|
||||
const a4 = paperById('A4')!;
|
||||
const sheet: SheetSpec = {
|
||||
wMm: a4.w, hMm: a4.h, marginMm: 5,
|
||||
gapMm: recommendedGap('corner', 0, 4, 3), bleedMm: 0, center: true,
|
||||
};
|
||||
// „Bogen füllen": so oft wie möglich, gleichmäßig auf die Bilder verteilt.
|
||||
let per = count;
|
||||
if (!per) {
|
||||
const probe = layout([{ id: 'x', wMm: size.w, hMm: size.h, count: 200, allowRotate: true }], sheet);
|
||||
per = Math.max(1, Math.floor((probe.pages[0]?.placements.length || 1) / sources.length));
|
||||
}
|
||||
const specs = sources.map((s, i) => ({ id: `s${i}`, wMm: size.w, hMm: size.h, count: per, allowRotate: true }));
|
||||
const plan = layout(specs, sheet);
|
||||
if (!plan.pages.length) { await b.api.sendMessage(chatId, '❌ Das Maß passt nicht auf A4.'); return; }
|
||||
|
||||
const images: Record<string, { bytes: Buffer; ext: 'jpg' | 'png' }> = {};
|
||||
for (const [i, src] of sources.entries()) {
|
||||
const buf = await getObject(src.source_path);
|
||||
const rots = new Set(plan.pages.flatMap((pg) => pg.placements.filter((p) => p.specId === `s${i}`).map((p) => p.rotated)));
|
||||
for (const rot of rots) {
|
||||
const out = await renderCell(buf, null, size.w, size.h, 300, { ext: 'jpg', rotate: rot });
|
||||
images[rot ? `s${i}::rot` : `s${i}`] = { bytes: out.buffer, ext: out.ext };
|
||||
}
|
||||
}
|
||||
const pages = plan.pages.map((pg) => ({
|
||||
placements: pg.placements.map((p) => ({ ...p, specId: p.rotated ? `${p.specId}::rot` : p.specId })),
|
||||
}));
|
||||
const marks = plan.pages.map((pg) => cutMarks(pg, sheet, { mode: 'corner', lengthMm: 4, offsetMm: 3 }));
|
||||
const label = labelMm(size.w, size.h);
|
||||
const bytes = await buildSheetPdf({
|
||||
sheet, pages, marksPerPage: marks, images, title: `Klarbild Druckbogen ${label}`,
|
||||
footer: `Klarbild · DIN A4 · ${label} · 300 dpi · Druck bei 100 % (nicht „an Seite anpassen")`,
|
||||
});
|
||||
|
||||
const file = new InputFile(Buffer.from(bytes), `klarbild-druckbogen-${label.replace(/[^0-9]+/g, 'x')}.pdf`);
|
||||
await b.api.sendDocument(chatId, file, {
|
||||
caption: `📐 ${plan.pages.length} Bogen · ${plan.pages[0].placements.length} Bild(er) auf Bogen 1 · ${label}\n` +
|
||||
'Beim Drucken „Tatsächliche Größe / 100 %" wählen — sonst stimmt das Maß nicht.',
|
||||
});
|
||||
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;
|
||||
@@ -369,8 +436,37 @@ function register(b: Bot) {
|
||||
});
|
||||
|
||||
b.on('callback_query:data', async (ctx) => {
|
||||
const [kind, recipeId] = ctx.callbackQuery.data.split(':');
|
||||
const [kind, recipeId, extra] = ctx.callbackQuery.data.split(':');
|
||||
await ctx.answerCallbackQuery();
|
||||
if (kind === 'p') { // Druckbogen ohne KI
|
||||
const draft = await one<{ id: string }>(
|
||||
`SELECT id FROM telegram_drafts WHERE chat_id=$1 AND status IN ('awaiting_recipe','awaiting_print')
|
||||
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 (recipeId === 'menu') {
|
||||
await query(`UPDATE telegram_drafts SET status='awaiting_print' WHERE id=$1`, [draft.id]);
|
||||
const kb = new InlineKeyboard();
|
||||
PRINT_SIZES.forEach((id, i) => {
|
||||
const s = photoById(id); if (!s) return;
|
||||
kb.text(labelMm(s.w, s.h), `p:${id}`); if (i % 2 === 1) kb.row();
|
||||
});
|
||||
return ctx.editMessageText('📐 Welches Endmaß soll das Bild haben?', { reply_markup: kb });
|
||||
}
|
||||
if (extra === undefined) {
|
||||
const s = photoById(recipeId);
|
||||
if (!s) return ctx.editMessageText('Unbekanntes Maß.');
|
||||
const kb = new InlineKeyboard();
|
||||
PRINT_COUNTS.forEach((n, i) => {
|
||||
kb.text(n ? `${n}×` : 'Bogen füllen', `p:${recipeId}:${n}`); if (i % 2 === 1) kb.row();
|
||||
});
|
||||
return ctx.editMessageText(`📐 ${labelMm(s.w, s.h)} — wie oft auf den A4-Bogen?`, { reply_markup: kb });
|
||||
}
|
||||
await ctx.editMessageText('Alles klar — Bogen wird gebaut.');
|
||||
const b2 = await getBot();
|
||||
if (b2) await dispatchPrint(ctx.chat!.id, draft.id, recipeId, Number(extra) || 0, b2)
|
||||
.catch((e) => b2.api.sendMessage(ctx.chat!.id, `❌ Druckbogen fehlgeschlagen: ${e?.message || e}`));
|
||||
return;
|
||||
}
|
||||
if (kind === 'd') { // Standard-Rezept setzen
|
||||
await query(`UPDATE telegram_links SET default_recipe_id=$2 WHERE chat_id=$1`, [ctx.chat!.id, recipeId]);
|
||||
return ctx.editMessageText('Standard-Rezept gesetzt.');
|
||||
|
||||
Reference in New Issue
Block a user