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:
+72
-2
@@ -12,8 +12,8 @@
|
|||||||
|
|
||||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
import { basename } from 'node:path';
|
import { basename, resolve } from 'node:path';
|
||||||
|
|
||||||
const BASE = (process.env.KLARBILD_URL || '').replace(/\/$/, '');
|
const BASE = (process.env.KLARBILD_URL || '').replace(/\/$/, '');
|
||||||
const TOKEN = process.env.KLARBILD_TOKEN || '';
|
const TOKEN = process.env.KLARBILD_TOKEN || '';
|
||||||
@@ -64,6 +64,38 @@ const TOOLS = [
|
|||||||
}, required: ['files'] } },
|
}, required: ['files'] } },
|
||||||
{ name: 'job_status', description: 'Status eines Auftrags abfragen (inkl. Endzeit und Fehlermeldungen).',
|
{ name: 'job_status', description: 'Status eines Auftrags abfragen (inkl. Endzeit und Fehlermeldungen).',
|
||||||
inputSchema: { type: 'object', properties: { jobId: { type: 'string' } }, required: ['jobId'] } },
|
inputSchema: { type: 'object', properties: { jobId: { type: 'string' } }, required: ['jobId'] } },
|
||||||
|
|
||||||
|
{ name: 'exact_size',
|
||||||
|
description: 'OHNE KI: ein Bild exakt auf ein physisches Maß bringen (mittiger Ausschnitt im Zielverhältnis, dpi-Metadaten). Ergebnis wird lokal gespeichert.',
|
||||||
|
inputSchema: { type: 'object', properties: {
|
||||||
|
file: { type: 'string', description: 'Lokaler Pfad oder data:-URL' },
|
||||||
|
size: { type: 'string', description: 'Maß: "12x15" (cm), "35x45mm", "5" (=5×5 cm) oder "4:3/15"' },
|
||||||
|
landscape: { type: 'boolean', description: 'Querformat statt Hochformat' },
|
||||||
|
dpi: { type: 'number', description: 'Standard 300' },
|
||||||
|
ext: { type: 'string', enum: ['jpg', 'png'] },
|
||||||
|
out: { type: 'string', description: 'Zielpfad für die Datei (Standard: ./klarbild-<maß>.<ext>)' },
|
||||||
|
}, required: ['file', 'size'] } },
|
||||||
|
|
||||||
|
{ name: 'print_sheet',
|
||||||
|
description: 'OHNE KI: mehrere Bilder in 100 %-Größe mit Schnittmarken auf einen Druckbogen setzen und als PDF speichern. Für Passbildsätze, Kita-Bilder, Sticker.',
|
||||||
|
inputSchema: { type: 'object', properties: {
|
||||||
|
images: { type: 'array', description: 'Je Eintrag: Datei + Endmaß + Stückzahl',
|
||||||
|
items: { type: 'object', properties: {
|
||||||
|
file: { type: 'string', description: 'Lokaler Pfad oder data:-URL' },
|
||||||
|
size: { type: 'string', description: 'Endmaß, z. B. "35x45mm", "9x13", "12x15"' },
|
||||||
|
count: { type: 'number', description: 'Wie oft auf den Bogen (Standard 1)' },
|
||||||
|
landscape: { type: 'boolean' },
|
||||||
|
allowRotate: { type: 'boolean', description: 'Darf gedreht platziert werden (Standard true)' },
|
||||||
|
}, required: ['file', 'size'] } },
|
||||||
|
paper: { type: 'string', description: 'A4 (Standard), A3, A3plus, A5, A6, A2, F10x15, F13x18, F9x13, F15x20, F20x30, Letter — oder ein freies Maß wie "32,9x48,3"' },
|
||||||
|
landscape: { type: 'boolean', description: 'Bogen quer' },
|
||||||
|
marks: { type: 'string', enum: ['none', 'corner', 'grid'], description: 'Schnitthilfen (Standard corner)' },
|
||||||
|
marginMm: { type: 'number', description: 'Rand zum Papier, Standard 5' },
|
||||||
|
gapMm: { type: 'number', description: 'Abstand zwischen den Bildern, Standard passend zu den Marken' },
|
||||||
|
bleedMm: { type: 'number', description: 'Beschnittzugabe je Seite, Standard 0' },
|
||||||
|
dpi: { type: 'number', description: 'Standard 300' },
|
||||||
|
out: { type: 'string', description: 'Zielpfad des PDFs (Standard: ./klarbild-druckbogen.pdf)' },
|
||||||
|
}, required: ['images'] } },
|
||||||
];
|
];
|
||||||
|
|
||||||
server.setRequestHandler({ method: 'tools/list' }, async () => ({ tools: TOOLS }));
|
server.setRequestHandler({ method: 'tools/list' }, async () => ({ tools: TOOLS }));
|
||||||
@@ -100,6 +132,44 @@ server.setRequestHandler({ method: 'tools/call' }, async (req) => {
|
|||||||
if (!j.jobId) throw new Error(j.error || 'Auftrag fehlgeschlagen');
|
if (!j.jobId) throw new Error(j.error || 'Auftrag fehlgeschlagen');
|
||||||
return { content: [{ type: 'text', text: `Auftrag angelegt: ${j.jobId}` }] };
|
return { content: [{ type: 'text', text: `Auftrag angelegt: ${j.jobId}` }] };
|
||||||
}
|
}
|
||||||
|
if (name === 'exact_size') {
|
||||||
|
const up = await uploadFile(a.file);
|
||||||
|
const r = await fetch(api('/api/print/single'), {
|
||||||
|
method: 'POST', headers: { ...authHeaders, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ src: { kind: 'upload', path: up.source_path }, size: a.size,
|
||||||
|
landscape: !!a.landscape, dpi: a.dpi || 300, ext: a.ext || 'jpg', name: up.filename }),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
|
||||||
|
const ext = a.ext === 'png' ? 'png' : 'jpg';
|
||||||
|
const out = resolve(a.out || `./klarbild-${String(a.size).replace(/[^0-9a-z]+/gi, 'x')}.${ext}`);
|
||||||
|
await writeFile(out, Buffer.from(await r.arrayBuffer()));
|
||||||
|
const px = r.headers.get('x-klarbild-px'), real = r.headers.get('x-klarbild-real-dpi');
|
||||||
|
const warn = r.headers.get('x-klarbild-dpi-ok') === '0' ? ` ⚠️ Quelle reicht nur für ca. ${real} dpi.` : '';
|
||||||
|
return { content: [{ type: 'text', text: `Gespeichert: ${out} (${px} px).${warn}` }] };
|
||||||
|
}
|
||||||
|
if (name === 'print_sheet') {
|
||||||
|
const imgs = a.images || [];
|
||||||
|
if (!imgs.length) throw new Error('Keine Bilder angegeben.');
|
||||||
|
const cells = [];
|
||||||
|
for (const [i, im] of imgs.entries()) {
|
||||||
|
const up = await uploadFile(im.file);
|
||||||
|
cells.push({ id: `c${i}`, src: { kind: 'upload', path: up.source_path }, size: im.size,
|
||||||
|
count: im.count || 1, landscape: !!im.landscape, allowRotate: im.allowRotate !== false });
|
||||||
|
}
|
||||||
|
const paper = a.paper && /[x×]/.test(a.paper) ? { size: a.paper } : { id: a.paper || 'A4' };
|
||||||
|
const body = { paper, landscape: !!a.landscape, marginMm: a.marginMm ?? 5,
|
||||||
|
bleedMm: a.bleedMm ?? 0, dpi: a.dpi || 300, ext: 'jpg', footer: true,
|
||||||
|
marks: { mode: a.marks || 'corner' }, cells };
|
||||||
|
if (a.gapMm != null) body.gapMm = a.gapMm;
|
||||||
|
const r = await fetch(api('/api/print/sheet'), {
|
||||||
|
method: 'POST', headers: { ...authHeaders, 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||||
|
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).error || `HTTP ${r.status}`);
|
||||||
|
const out = resolve(a.out || './klarbild-druckbogen.pdf');
|
||||||
|
await writeFile(out, Buffer.from(await r.arrayBuffer()));
|
||||||
|
return { content: [{ type: 'text', text:
|
||||||
|
`Gespeichert: ${out} — ${r.headers.get('x-klarbild-pages')} Bogen, ${r.headers.get('x-klarbild-per-sheet')} Bilder auf Bogen 1. ` +
|
||||||
|
'Beim Drucken „Tatsächliche Größe / 100 %" wählen.' }] };
|
||||||
|
}
|
||||||
if (name === 'job_status') {
|
if (name === 'job_status') {
|
||||||
const j = await (await fetch(api(`/api/jobs/${a.jobId}`), { headers: authHeaders })).json();
|
const j = await (await fetch(api(`/api/jobs/${a.jobId}`), { headers: authHeaders })).json();
|
||||||
const job = j.job || j;
|
const job = j.job || j;
|
||||||
|
|||||||
+98
-2
@@ -6,6 +6,9 @@ import { one, query } from './db';
|
|||||||
import { decrypt } from './crypto';
|
import { decrypt } from './crypto';
|
||||||
import { getObject, putObject, sourceKey } from './storage';
|
import { getObject, putObject, sourceKey } from './storage';
|
||||||
import { enqueue } from './queue';
|
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 QUIET_MS = 4000; // Ruhefenster für die Bündelung
|
||||||
const BASE = process.env.PUBLIC_BASE_URL || '';
|
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() {
|
function mainKeyboard() {
|
||||||
return new Keyboard().text(BTN_NEW).row().text(BTN_RECIPES).text(BTN_HELP).resized().persistent();
|
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 =
|
const HELP_TEXT =
|
||||||
'So funktioniert’s — alles per Knopf, ohne Befehle:\n\n' +
|
'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' +
|
'🖼 *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' +
|
'🔀 *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' +
|
'✨ *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.';
|
'Ich melde mich einmal, wenn alles fertig ist — mit Ergebnis und Link.';
|
||||||
|
|
||||||
export async function getToken(): Promise<string> {
|
export async function getToken(): Promise<string> {
|
||||||
@@ -126,6 +135,7 @@ export async function sweepDrafts(): Promise<void> {
|
|||||||
const n = (d.file_refs || []).length;
|
const n = (d.file_refs || []).length;
|
||||||
// Ab 2 Bildern zusätzlich „Kombinieren" anbieten.
|
// Ab 2 Bildern zusätzlich „Kombinieren" anbieten.
|
||||||
if (n >= 2) { kb.row(); kb.text('🔀 Zu einem Bild kombinieren', 'c:x'); }
|
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 compressed = (d.file_refs || []).some((f: any) => f.quality === 'compressed');
|
||||||
const capNote = d.caption ? `\n📝 Beschreibung erkannt: „${d.caption}" — für „Kombinieren".` : '';
|
const capNote = d.caption ? `\n📝 Beschreibung erkannt: „${d.caption}" — für „Kombinieren".` : '';
|
||||||
try {
|
try {
|
||||||
@@ -254,6 +264,63 @@ async function dispatchGenerate(chatId: number, description: string, b: Bot) {
|
|||||||
await enqueue({ itemId: it!.id, jobId: job!.id });
|
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. */
|
/** Vom Worker aufgerufen: eine Rückmeldung an den Chat, wenn der Auftrag fertig ist. */
|
||||||
export async function notifyJobDone(chatId: number, jobId: string): Promise<void> {
|
export async function notifyJobDone(chatId: number, jobId: string): Promise<void> {
|
||||||
const b = await getBot(); if (!b) return;
|
const b = await getBot(); if (!b) return;
|
||||||
@@ -369,8 +436,37 @@ function register(b: Bot) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
b.on('callback_query:data', async (ctx) => {
|
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();
|
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
|
if (kind === 'd') { // Standard-Rezept setzen
|
||||||
await query(`UPDATE telegram_links SET default_recipe_id=$2 WHERE chat_id=$1`, [ctx.chat!.id, recipeId]);
|
await query(`UPDATE telegram_links SET default_recipe_id=$2 WHERE chat_id=$1`, [ctx.chat!.id, recipeId]);
|
||||||
return ctx.editMessageText('Standard-Rezept gesetzt.');
|
return ctx.editMessageText('Standard-Rezept gesetzt.');
|
||||||
|
|||||||
Reference in New Issue
Block a user