diff --git a/docs/plans/2026-03-01-telegram-media-bot.md b/docs/plans/2026-03-01-telegram-media-bot.md new file mode 100644 index 0000000..711f1cc --- /dev/null +++ b/docs/plans/2026-03-01-telegram-media-bot.md @@ -0,0 +1,1155 @@ +# Telegram Media Upload Bot — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build a Telegram bot that uploads images from chat to Payload CMS Media collection with multi-tenant support. + +**Architecture:** Standalone Node.js service using Grammy (Telegram bot framework) with long-polling. Authenticates against Payload REST API, downloads images from Telegram servers, uploads via multipart/form-data. Runs as PM2 process on sv-payload alongside existing CMS. + +**Tech Stack:** Node.js 22, TypeScript (strict), Grammy, native fetch/FormData, PM2, pnpm + +--- + +### Task 1: Project Scaffold + +**Files:** +- Create: `/home/payload/telegram-media-bot/package.json` +- Create: `/home/payload/telegram-media-bot/tsconfig.json` +- Create: `/home/payload/telegram-media-bot/.gitignore` +- Create: `/home/payload/telegram-media-bot/.env.example` + +**Step 1: Create project directory and init** + +```bash +mkdir -p /home/payload/telegram-media-bot +cd /home/payload/telegram-media-bot +pnpm init +``` + +**Step 2: Write package.json** + +```json +{ + "name": "telegram-media-bot", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "lint": "tsc --noEmit" + } +} +``` + +**Step 3: Install dependencies** + +```bash +pnpm add grammy dotenv +pnpm add -D typescript @types/node tsx +``` + +**Step 4: Write tsconfig.json** + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} +``` + +**Step 5: Write .gitignore** + +``` +node_modules/ +dist/ +.env +logs/ +*.log +``` + +**Step 6: Write .env.example** + +```env +# Telegram Bot +TELEGRAM_BOT_TOKEN=123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11 + +# Zugelassene Telegram User-IDs (kommasepariert) +ALLOWED_USER_IDS=123456789,987654321 + +# Payload CMS +PAYLOAD_API_URL=https://cms.c2sgmbh.de/api +PAYLOAD_ADMIN_EMAIL=admin@example.com +PAYLOAD_ADMIN_PASSWORD=your-secure-password + +# Standard-Tenant (wird verwendet wenn kein Tenant gewählt) +DEFAULT_TENANT_ID=4 + +# Logging +LOG_LEVEL=info + +# Node Environment +NODE_ENV=production +``` + +**Step 7: Create directory structure** + +```bash +mkdir -p src/{payload,telegram,middleware,utils} +mkdir -p logs +``` + +**Step 8: Commit** + +```bash +cd /home/payload/telegram-media-bot +git init +git add -A +git commit -m "chore: project scaffold with deps and config" +``` + +--- + +### Task 2: Config & Logger + +**Files:** +- Create: `src/config.ts` +- Create: `src/utils/logger.ts` + +**Step 1: Write src/config.ts** + +Typed config that validates all env vars on import. `ALLOWED_USER_IDS` parsed as `number[]`. Missing required vars → process.exit(1) with clear error. + +```typescript +import 'dotenv/config'; + +interface Config { + telegram: { + botToken: string; + allowedUserIds: number[]; + }; + payload: { + apiUrl: string; + email: string; + password: string; + }; + defaultTenantId: number; + logLevel: string; + nodeEnv: string; +} + +function requireEnv(key: string): string { + const value = process.env[key]; + if (!value) { + console.error(`❌ Missing required environment variable: ${key}`); + process.exit(1); + } + return value; +} + +export const config: Config = { + telegram: { + botToken: requireEnv('TELEGRAM_BOT_TOKEN'), + allowedUserIds: requireEnv('ALLOWED_USER_IDS') + .split(',') + .map((id) => { + const num = Number(id.trim()); + if (Number.isNaN(num)) { + console.error(`❌ Invalid user ID in ALLOWED_USER_IDS: "${id}"`); + process.exit(1); + } + return num; + }), + }, + payload: { + apiUrl: requireEnv('PAYLOAD_API_URL'), + email: requireEnv('PAYLOAD_ADMIN_EMAIL'), + password: requireEnv('PAYLOAD_ADMIN_PASSWORD'), + }, + defaultTenantId: Number(process.env.DEFAULT_TENANT_ID || '4'), + logLevel: process.env.LOG_LEVEL || 'info', + nodeEnv: process.env.NODE_ENV || 'development', +}; +``` + +**Step 2: Write src/utils/logger.ts** + +Console-based logger with levels, timestamps, module tags. + +```typescript +const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 } as const; +type LogLevel = keyof typeof LEVELS; + +let minLevel: number = LEVELS.info; + +export function setLogLevel(level: LogLevel): void { + minLevel = LEVELS[level]; +} + +function formatTimestamp(): string { + return new Date().toISOString().replace('T', ' ').slice(0, 19); +} + +function log(level: LogLevel, module: string, message: string, data?: unknown): void { + if (LEVELS[level] < minLevel) return; + const prefix = `[${formatTimestamp()}] [${level.toUpperCase()}] [${module}]`; + if (data !== undefined) { + console[level === 'debug' ? 'log' : level](`${prefix} ${message}`, data); + } else { + console[level === 'debug' ? 'log' : level](`${prefix} ${message}`); + } +} + +export function createLogger(module: string) { + return { + debug: (msg: string, data?: unknown) => log('debug', module, msg, data), + info: (msg: string, data?: unknown) => log('info', module, msg, data), + warn: (msg: string, data?: unknown) => log('warn', module, msg, data), + error: (msg: string, data?: unknown) => log('error', module, msg, data), + }; +} +``` + +**Step 3: Verify build** + +```bash +pnpm lint +``` + +**Step 4: Commit** + +```bash +git add -A +git commit -m "feat: add typed config validation and logger utility" +``` + +--- + +### Task 3: Download Helper & Payload Client + +**Files:** +- Create: `src/utils/download.ts` +- Create: `src/payload/client.ts` + +**Step 1: Write src/utils/download.ts** + +Native fetch download with 30s timeout, 20MB size limit. + +```typescript +import { createLogger } from './logger.js'; + +const log = createLogger('Download'); +const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20 MB + +export async function downloadFile(url: string): Promise<{ buffer: Buffer; mimeType: string }> { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 30_000); + + try { + const response = await fetch(url, { signal: controller.signal }); + if (!response.ok) { + throw new Error(`Download failed: HTTP ${response.status}`); + } + + const contentLength = Number(response.headers.get('content-length') || '0'); + if (contentLength > MAX_FILE_SIZE) { + throw new Error(`Datei zu groß (${(contentLength / 1024 / 1024).toFixed(1)} MB). Maximum: 20 MB`); + } + + const arrayBuffer = await response.arrayBuffer(); + if (arrayBuffer.byteLength > MAX_FILE_SIZE) { + throw new Error(`Datei zu groß (${(arrayBuffer.byteLength / 1024 / 1024).toFixed(1)} MB). Maximum: 20 MB`); + } + + const mimeType = response.headers.get('content-type') || 'application/octet-stream'; + log.debug(`Downloaded ${arrayBuffer.byteLength} bytes, type: ${mimeType}`); + + return { buffer: Buffer.from(arrayBuffer), mimeType }; + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + throw new Error('Download-Timeout: Server hat nicht innerhalb von 30 Sekunden geantwortet'); + } + throw error; + } finally { + clearTimeout(timeout); + } +} +``` + +**Step 2: Write src/payload/client.ts** + +Full PayloadClient class: login, token caching, JWT decode for expiry, auto-refresh, retry on 401, upload/list/delete media with tenant scoping. + +```typescript +import { config } from '../config.js'; +import { createLogger } from '../utils/logger.js'; + +const log = createLogger('PayloadClient'); + +interface MediaUploadOptions { + alt: string; + tenantId: number; + caption?: string; +} + +interface MediaDoc { + id: number; + url: string; + filename: string; + alt: string; + mimeType: string; + filesize: number; + width?: number; + height?: number; + sizes?: Record; + createdAt: string; +} + +interface MediaListResponse { + docs: MediaDoc[]; + totalDocs: number; +} + +interface TenantDoc { + id: number; + name: string; + slug: string; +} + +function decodeJwtExpiry(token: string): number { + const parts = token.split('.'); + if (parts.length !== 3) return 0; + const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString()); + return payload.exp || 0; +} + +class PayloadClient { + private token: string | null = null; + private tokenExpiry: number = 0; + private readonly apiUrl: string; + + constructor() { + this.apiUrl = config.payload.apiUrl; + } + + async login(): Promise { + log.info('Logging in to Payload CMS...'); + const response = await fetch(`${this.apiUrl}/users/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: config.payload.email, + password: config.payload.password, + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Payload login failed (${response.status}): ${text}`); + } + + const data = await response.json(); + this.token = data.token; + this.tokenExpiry = decodeJwtExpiry(data.token); + log.info(`Login successful, token expires at ${new Date(this.tokenExpiry * 1000).toISOString()}`); + } + + async getToken(): Promise { + const now = Math.floor(Date.now() / 1000); + const buffer = 300; // 5 min buffer + + if (!this.token || this.tokenExpiry <= now + buffer) { + await this.login(); + } + + return this.token!; + } + + private async authFetch(url: string, init: RequestInit = {}, retry = true): Promise { + const token = await this.getToken(); + const headers = new Headers(init.headers); + headers.set('Authorization', `JWT ${token}`); + + const response = await fetch(url, { ...init, headers }); + + if (response.status === 401 && retry) { + log.warn('Got 401, re-authenticating...'); + this.token = null; + return this.authFetch(url, init, false); + } + + return response; + } + + async uploadMedia(file: Buffer, filename: string, mimeType: string, options: MediaUploadOptions): Promise { + const formData = new FormData(); + formData.append('alt', options.alt); + if (options.caption) formData.append('caption', options.caption); + formData.append('tenant', String(options.tenantId)); + + const blob = new Blob([file], { type: mimeType }); + formData.append('file', blob, filename); + + log.info(`Uploading ${filename} (${(file.byteLength / 1024).toFixed(1)} KB) to tenant ${options.tenantId}`); + + const response = await this.authFetch(`${this.apiUrl}/media`, { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Upload failed (${response.status}): ${text}`); + } + + const data = await response.json(); + log.info(`Upload successful: ID ${data.doc.id}, ${data.doc.filename}`); + return data.doc; + } + + async listMedia(tenantId: number, limit = 5): Promise { + const params = new URLSearchParams({ + 'where[tenant][equals]': String(tenantId), + sort: '-createdAt', + limit: String(limit), + }); + + const response = await this.authFetch(`${this.apiUrl}/media?${params}`); + if (!response.ok) { + throw new Error(`List media failed (${response.status})`); + } + + return response.json(); + } + + async deleteMedia(mediaId: number): Promise { + const response = await this.authFetch(`${this.apiUrl}/media/${mediaId}`, { + method: 'DELETE', + }); + + if (!response.ok) { + throw new Error(`Delete media failed (${response.status})`); + } + + log.info(`Deleted media ID ${mediaId}`); + } + + async listTenants(): Promise { + const response = await this.authFetch(`${this.apiUrl}/tenants?limit=50`); + if (!response.ok) { + throw new Error(`List tenants failed (${response.status})`); + } + + const data = await response.json(); + return data.docs; + } + + async checkHealth(): Promise { + try { + const response = await this.authFetch(`${this.apiUrl}/users/me`); + return response.ok; + } catch { + return false; + } + } + + getTokenExpiry(): Date | null { + return this.tokenExpiry > 0 ? new Date(this.tokenExpiry * 1000) : null; + } +} + +export const payloadClient = new PayloadClient(); +export type { MediaDoc, MediaUploadOptions, MediaListResponse, TenantDoc }; +``` + +**Step 3: Verify build** + +```bash +pnpm lint +``` + +**Step 4: Commit** + +```bash +git add -A +git commit -m "feat: add download helper and Payload API client with token caching" +``` + +--- + +### Task 4: Auth Middleware & Bot Setup + +**Files:** +- Create: `src/middleware/auth.ts` +- Create: `src/bot.ts` + +**Step 1: Write src/middleware/auth.ts** + +Grammy middleware that checks `ctx.from.id` against whitelist. + +```typescript +import { type Context, type NextFunction } from 'grammy'; +import { config } from '../config.js'; +import { createLogger } from '../utils/logger.js'; + +const log = createLogger('Auth'); + +export async function authMiddleware(ctx: Context, next: NextFunction): Promise { + const userId = ctx.from?.id; + + if (!userId || !config.telegram.allowedUserIds.includes(userId)) { + log.warn(`Unauthorized access attempt from user ${userId || 'unknown'}`); + await ctx.reply('⛔ Du bist nicht autorisiert, diesen Bot zu verwenden.'); + return; + } + + await next(); +} +``` + +**Step 2: Write src/bot.ts** + +Grammy bot with session, auth middleware, rate limiting map. + +```typescript +import { Bot, session, type Context, type SessionFlavor } from 'grammy'; +import { config } from './config.js'; +import { authMiddleware } from './middleware/auth.js'; +import { createLogger } from './utils/logger.js'; + +const log = createLogger('Bot'); + +interface SessionData { + selectedTenantId: number; + selectedTenantName: string; +} + +type BotContext = Context & SessionFlavor; + +// Rate limiting: track uploads per user +const uploadCounts = new Map(); + +function checkRateLimit(userId: number): { allowed: boolean; retryAfter?: number } { + const now = Date.now(); + const entry = uploadCounts.get(userId); + + if (!entry || entry.resetAt <= now) { + uploadCounts.set(userId, { count: 1, resetAt: now + 60_000 }); + return { allowed: true }; + } + + if (entry.count >= 10) { + const retryAfter = Math.ceil((entry.resetAt - now) / 1000); + return { allowed: false, retryAfter }; + } + + entry.count++; + return { allowed: true }; +} + +function createBot(): Bot { + const bot = new Bot(config.telegram.botToken); + + // Session middleware + bot.use( + session({ + initial: (): SessionData => ({ + selectedTenantId: config.defaultTenantId, + selectedTenantName: 'Default', + }), + }), + ); + + // Auth middleware + bot.use(authMiddleware); + + log.info('Bot instance created'); + return bot; +} + +export { createBot, checkRateLimit }; +export type { BotContext, SessionData }; +``` + +**Step 3: Verify build** + +```bash +pnpm lint +``` + +**Step 4: Commit** + +```bash +git add -A +git commit -m "feat: add auth middleware and bot setup with session and rate limiting" +``` + +--- + +### Task 5: Keyboards & Command Handlers + +**Files:** +- Create: `src/telegram/keyboards.ts` +- Create: `src/telegram/handlers.ts` + +**Step 1: Write src/telegram/keyboards.ts** + +Inline keyboard for tenant selection, dynamically loaded from API. + +```typescript +import { InlineKeyboard } from 'grammy'; +import { payloadClient, type TenantDoc } from '../payload/client.js'; +import { createLogger } from '../utils/logger.js'; + +const log = createLogger('Keyboards'); + +let cachedTenants: TenantDoc[] = []; +let tenantsCacheExpiry = 0; + +async function getTenants(): Promise { + const now = Date.now(); + if (cachedTenants.length > 0 && tenantsCacheExpiry > now) { + return cachedTenants; + } + + try { + cachedTenants = await payloadClient.listTenants(); + tenantsCacheExpiry = now + 5 * 60 * 1000; // 5 min cache + log.info(`Loaded ${cachedTenants.length} tenants`); + } catch (error) { + log.error('Failed to load tenants', error); + if (cachedTenants.length > 0) return cachedTenants; // stale fallback + throw error; + } + + return cachedTenants; +} + +export async function buildTenantKeyboard(): Promise { + const tenants = await getTenants(); + const keyboard = new InlineKeyboard(); + + tenants.forEach((tenant, i) => { + keyboard.text(tenant.name, `tenant:${tenant.id}`); + if (i % 2 === 1) keyboard.row(); // 2 per row + }); + + // If odd number of tenants, close the last row + if (tenants.length % 2 === 1) keyboard.row(); + + return keyboard; +} + +export function getTenantName(tenantId: number): string { + const tenant = cachedTenants.find((t) => t.id === tenantId); + return tenant?.name || `Tenant ${tenantId}`; +} +``` + +**Step 2: Write src/telegram/handlers.ts** + +All command handlers (/start, /tenant, /list, /status, /help), photo handler, document handler, album handler, callback query handler. + +```typescript +import type { Bot } from 'grammy'; +import type { BotContext } from '../bot.js'; +import { payloadClient } from '../payload/client.js'; +import { downloadFile } from '../utils/download.js'; +import { createLogger } from '../utils/logger.js'; +import { checkRateLimit } from '../bot.js'; +import { buildTenantKeyboard, getTenantName } from './keyboards.js'; + +const log = createLogger('Handlers'); +const startTime = Date.now(); + +const ALLOWED_MIME_TYPES = new Set([ + 'image/jpeg', 'image/png', 'image/webp', 'image/avif', + 'image/gif', 'image/svg+xml', +]); + +const ALLOWED_EXTENSIONS = new Set([ + 'jpg', 'jpeg', 'png', 'webp', 'avif', 'gif', 'svg', +]); + +// Album debounce: collect media_group messages +const albumBuffers = new Map; timer: ReturnType }>(); + +export function registerHandlers(bot: Bot): void { + // Commands + bot.command('start', handleStart); + bot.command('tenant', handleTenant); + bot.command('list', handleList); + bot.command('status', handleStatus); + bot.command('help', handleHelp); + + // Callback queries (tenant selection) + bot.callbackQuery(/^tenant:(\d+)$/, handleTenantCallback); + + // Photo messages + bot.on('message:photo', handlePhoto); + + // Document messages (for original quality images) + bot.on('message:document', handleDocument); + + log.info('All handlers registered'); +} + +async function handleStart(ctx: BotContext): Promise { + const tenantName = getTenantName(ctx.session.selectedTenantId); + await ctx.reply( + `🤖 *Payload Media Upload Bot*\n\n` + + `Schicke mir ein Bild und ich lade es in die Payload CMS Media-Bibliothek hoch.\n\n` + + `📌 Aktueller Tenant: *${tenantName}*\n` + + `📋 Befehle:\n` + + `/tenant \\- Tenant wechseln\n` + + `/list \\- Letzte 5 Uploads anzeigen\n` + + `/status \\- Bot\\- und API\\-Status\n` + + `/help \\- Hilfe anzeigen`, + { parse_mode: 'MarkdownV2' }, + ); +} + +async function handleTenant(ctx: BotContext): Promise { + try { + const keyboard = await buildTenantKeyboard(); + await ctx.reply('🏢 Wähle einen Tenant:', { reply_markup: keyboard }); + } catch { + await ctx.reply('❌ Konnte Tenants nicht laden. Versuche es später erneut.'); + } +} + +async function handleTenantCallback(ctx: BotContext): Promise { + const match = ctx.callbackQuery?.data?.match(/^tenant:(\d+)$/); + if (!match) return; + + const tenantId = Number(match[1]); + ctx.session.selectedTenantId = tenantId; + ctx.session.selectedTenantName = getTenantName(tenantId); + + await ctx.answerCallbackQuery(); + await ctx.editMessageText(`✅ Tenant gewechselt zu: *${ctx.session.selectedTenantName}* \\(ID: ${tenantId}\\)`, { + parse_mode: 'MarkdownV2', + }); +} + +async function handleList(ctx: BotContext): Promise { + try { + const result = await payloadClient.listMedia(ctx.session.selectedTenantId, 5); + + if (result.docs.length === 0) { + await ctx.reply('📭 Keine Medien für diesen Tenant gefunden.'); + return; + } + + const lines = result.docs.map((doc, i) => { + const date = new Date(doc.createdAt).toLocaleDateString('de-DE'); + return `${i + 1}. 📎 *${doc.filename}* (ID: ${doc.id})\n 📅 ${date} | 📐 ${doc.width || '?'}×${doc.height || '?'}`; + }); + + await ctx.reply( + `📋 Letzte ${result.docs.length} Uploads (${ctx.session.selectedTenantName}):\n\n${lines.join('\n\n')}`, + ); + } catch (error) { + log.error('List failed', error); + await ctx.reply('❌ Konnte Medien nicht laden.'); + } +} + +async function handleStatus(ctx: BotContext): Promise { + const uptime = Math.floor((Date.now() - startTime) / 1000); + const hours = Math.floor(uptime / 3600); + const minutes = Math.floor((uptime % 3600) / 60); + + let apiStatus = '❌ Nicht erreichbar'; + try { + const healthy = await payloadClient.checkHealth(); + apiStatus = healthy ? '✅ Erreichbar' : '❌ Nicht erreichbar'; + } catch { + apiStatus = '❌ Fehler bei Verbindung'; + } + + const tokenExpiry = payloadClient.getTokenExpiry(); + const tokenStatus = tokenExpiry + ? `✅ Gültig bis ${tokenExpiry.toLocaleString('de-DE')}` + : '⚠️ Kein Token'; + + await ctx.reply( + `📊 Bot-Status\n\n` + + `⏱️ Uptime: ${hours}h ${minutes}m\n` + + `🌐 Payload API: ${apiStatus}\n` + + `📌 Tenant: ${ctx.session.selectedTenantName} (ID: ${ctx.session.selectedTenantId})\n` + + `🔑 Token: ${tokenStatus}`, + ); +} + +async function handleHelp(ctx: BotContext): Promise { + await ctx.reply( + `📖 *Hilfe*\n\n` + + `*Bild hochladen:*\n` + + `Sende ein Bild als Foto oder als Dokument\\. ` + + `Die Bildunterschrift wird als Alt\\-Text verwendet\\.\n\n` + + `💡 *Tipp:* Sende Bilder als _Dokument_ für Originalqualität\\. ` + + `Telegram komprimiert Fotos automatisch\\.\n\n` + + `*Befehle:*\n` + + `/start \\- Begrüßung\n` + + `/tenant \\- Ziel\\-Tenant wechseln\n` + + `/list \\- Letzte 5 Uploads\n` + + `/status \\- API\\- und Bot\\-Status\n` + + `/help \\- Diese Hilfe\n\n` + + `*Bulk\\-Upload:*\n` + + `Sende mehrere Bilder als Album \\– sie werden nacheinander hochgeladen\\.`, + { parse_mode: 'MarkdownV2' }, + ); +} + +async function uploadSinglePhoto( + ctx: BotContext, + fileId: string, + caption: string | undefined, +): Promise { + const rateCheck = checkRateLimit(ctx.from!.id); + if (!rateCheck.allowed) { + await ctx.reply(`⏳ Zu viele Uploads. Bitte warte ${rateCheck.retryAfter} Sekunden.`); + return; + } + + const statusMsg = await ctx.reply('⏳ Bild wird hochgeladen...'); + + try { + const file = await ctx.api.getFile(fileId); + const fileUrl = `https://api.telegram.org/file/bot${ctx.api.token}/${file.file_path}`; + + const { buffer, mimeType } = await downloadFile(fileUrl); + + const ext = file.file_path?.split('.').pop() || 'jpg'; + const filename = `telegram-${Date.now()}.${ext}`; + const alt = caption || `Upload via Telegram – ${new Date().toLocaleString('de-DE')}`; + + const doc = await payloadClient.uploadMedia(buffer, filename, mimeType, { + alt, + tenantId: ctx.session.selectedTenantId, + caption, + }); + + const sizeNames = doc.sizes ? Object.keys(doc.sizes).join(', ') : 'werden generiert'; + + await ctx.api.editMessageText( + ctx.chat!.id, + statusMsg.message_id, + `✅ Upload erfolgreich!\n\n` + + `📎 ID: ${doc.id}\n` + + `📁 Dateiname: ${doc.filename}\n` + + `🔗 URL: ${doc.url}\n` + + `🏷️ Tenant: ${getTenantName(ctx.session.selectedTenantId)}\n` + + `📐 Größen: ${sizeNames}`, + ); + } catch (error) { + log.error('Upload failed', error); + const message = error instanceof Error ? error.message : 'Unbekannter Fehler'; + await ctx.api.editMessageText( + ctx.chat!.id, + statusMsg.message_id, + `❌ Upload fehlgeschlagen: ${message}`, + ); + } +} + +async function handlePhoto(ctx: BotContext): Promise { + const photos = ctx.message?.photo; + if (!photos || photos.length === 0) return; + + const mediaGroupId = ctx.message?.media_group_id; + + // Album handling + if (mediaGroupId) { + handleAlbumMessage(ctx, photos[photos.length - 1].file_id, mediaGroupId); + return; + } + + // Single photo: last element = highest resolution + const bestPhoto = photos[photos.length - 1]; + await uploadSinglePhoto(ctx, bestPhoto.file_id, ctx.message?.caption); +} + +async function handleDocument(ctx: BotContext): Promise { + const doc = ctx.message?.document; + if (!doc) return; + + const filename = doc.file_name || 'unknown'; + const ext = filename.split('.').pop()?.toLowerCase() || ''; + + if (!ALLOWED_EXTENSIONS.has(ext)) { + await ctx.reply('⚠️ Format nicht unterstützt. Erlaubt: JPG, PNG, WebP, AVIF, GIF, SVG'); + return; + } + + if (doc.mime_type && !ALLOWED_MIME_TYPES.has(doc.mime_type)) { + await ctx.reply('⚠️ MIME-Type nicht unterstützt. Erlaubt: JPG, PNG, WebP, AVIF, GIF, SVG'); + return; + } + + const mediaGroupId = ctx.message?.media_group_id; + if (mediaGroupId) { + handleAlbumMessage(ctx, doc.file_id, mediaGroupId); + return; + } + + await uploadSinglePhoto(ctx, doc.file_id, ctx.message?.caption); +} + +function handleAlbumMessage(ctx: BotContext, fileId: string, mediaGroupId: string): void { + let album = albumBuffers.get(mediaGroupId); + + if (!album) { + album = { photos: [], timer: setTimeout(() => processAlbum(ctx, mediaGroupId), 500) }; + albumBuffers.set(mediaGroupId, album); + } + + album.photos.push({ fileId, caption: ctx.message?.caption }); +} + +async function processAlbum(ctx: BotContext, mediaGroupId: string): Promise { + const album = albumBuffers.get(mediaGroupId); + albumBuffers.delete(mediaGroupId); + + if (!album || album.photos.length === 0) return; + + const total = Math.min(album.photos.length, 10); + if (album.photos.length > 10) { + await ctx.reply('⚠️ Maximal 10 Bilder pro Album. Die ersten 10 werden hochgeladen.'); + } + + const statusMsg = await ctx.reply(`⏳ Album erkannt: ${total} Bilder werden hochgeladen...`); + const results: string[] = []; + let successCount = 0; + + for (let i = 0; i < total; i++) { + const photo = album.photos[i]; + const rateCheck = checkRateLimit(ctx.from!.id); + + if (!rateCheck.allowed) { + results.push(`${i + 1}. ⏳ Rate-Limit erreicht`); + break; + } + + try { + const file = await ctx.api.getFile(photo.fileId); + const fileUrl = `https://api.telegram.org/file/bot${ctx.api.token}/${file.file_path}`; + const { buffer, mimeType } = await downloadFile(fileUrl); + + const ext = file.file_path?.split('.').pop() || 'jpg'; + const filename = `telegram-${Date.now()}-${i + 1}.${ext}`; + const alt = photo.caption || `Album-Upload ${i + 1}/${total} – ${new Date().toLocaleString('de-DE')}`; + + const doc = await payloadClient.uploadMedia(buffer, filename, mimeType, { + alt, + tenantId: ctx.session.selectedTenantId, + }); + + results.push(`${i + 1}. ✅ ID: ${doc.id} – ${doc.filename}`); + successCount++; + } catch (error) { + const msg = error instanceof Error ? error.message : 'Fehler'; + results.push(`${i + 1}. ❌ ${msg}`); + } + + // Progress update every 3 images + if ((i + 1) % 3 === 0 && i + 1 < total) { + await ctx.api.editMessageText( + ctx.chat!.id, + statusMsg.message_id, + `📤 ${i + 1}/${total} hochgeladen...`, + ); + } + } + + await ctx.api.editMessageText( + ctx.chat!.id, + statusMsg.message_id, + `📦 Album-Upload abgeschlossen: ${successCount}/${total} erfolgreich\n\n${results.join('\n')}`, + ); +} +``` + +**Step 3: Verify build** + +```bash +pnpm lint +``` + +**Step 4: Commit** + +```bash +git add -A +git commit -m "feat: add command handlers, photo/document/album upload, tenant keyboard" +``` + +--- + +### Task 6: Entry Point & PM2 Config + +**Files:** +- Create: `src/index.ts` +- Create: `ecosystem.config.cjs` + +**Step 1: Write src/index.ts** + +Entry point: import config (validates env), set log level, create bot, register handlers, start with graceful shutdown. + +```typescript +import { config } from './config.js'; +import { setLogLevel } from './utils/logger.js'; +import { createLogger } from './utils/logger.js'; +import { createBot } from './bot.js'; +import { registerHandlers } from './telegram/handlers.js'; + +const log = createLogger('Main'); + +setLogLevel(config.logLevel as 'debug' | 'info' | 'warn' | 'error'); + +async function main(): Promise { + log.info('Starting Telegram Media Upload Bot...'); + log.info(`Environment: ${config.nodeEnv}`); + log.info(`Payload API: ${config.payload.apiUrl}`); + log.info(`Default Tenant: ${config.defaultTenantId}`); + log.info(`Allowed Users: ${config.telegram.allowedUserIds.join(', ')}`); + + const bot = createBot(); + registerHandlers(bot); + + // Graceful shutdown + const shutdown = async (signal: string) => { + log.info(`Received ${signal}, shutting down...`); + bot.stop(); + // Give ongoing uploads up to 60s to finish + setTimeout(() => { + log.warn('Forced shutdown after timeout'); + process.exit(1); + }, 60_000); + }; + + process.on('SIGINT', () => shutdown('SIGINT')); + process.on('SIGTERM', () => shutdown('SIGTERM')); + + // Start bot + await bot.start({ + onStart: () => log.info('Bot is running! Listening for messages...'), + }); +} + +main().catch((error) => { + log.error('Fatal error', error); + process.exit(1); +}); +``` + +**Step 2: Write ecosystem.config.cjs** + +```javascript +module.exports = { + apps: [{ + name: 'telegram-media-bot', + script: './dist/index.js', + instances: 1, + autorestart: true, + watch: false, + max_memory_restart: '256M', + env: { + NODE_ENV: 'production', + }, + error_file: './logs/error.log', + out_file: './logs/out.log', + merge_logs: true, + time: true, + }], +}; +``` + +**Step 3: Full build verification** + +```bash +pnpm lint # tsc --noEmit +pnpm build # tsc → dist/ +ls dist/ # verify output files exist +``` + +**Step 4: Commit** + +```bash +git add -A +git commit -m "feat: add entry point with graceful shutdown and PM2 config" +``` + +--- + +### Task 7: README & Final Verification + +**Files:** +- Create: `README.md` + +**Step 1: Write README.md** + +Brief README with setup instructions, commands, and deployment. + +**Step 2: Full verification cycle** + +```bash +pnpm lint # No errors +pnpm build # Clean build +ls -la dist/ # All files present +``` + +**Step 3: Final commit** + +```bash +git add -A +git commit -m "docs: add README with setup and deployment instructions" +``` + +--- + +### Task 8: Deploy & Manual Test + +**Step 1: Create .env from .env.example** + +```bash +cp .env.example .env +# Fill with real values (bot token, payload credentials, user IDs) +``` + +**Step 2: Test in dev mode** + +```bash +pnpm dev +# In Telegram: /start, /tenant, send photo, /list, /status +``` + +**Step 3: Production start** + +```bash +pnpm build +pm2 start ecosystem.config.cjs +pm2 save +pm2 list # Verify telegram-media-bot is online +``` + +**Step 4: Verify in PM2** + +```bash +pm2 logs telegram-media-bot --lines 20 +``` diff --git a/docs/sensualmoments/farbschema.html b/docs/sensualmoments/farbschema.html new file mode 100644 index 0000000..04a71c9 --- /dev/null +++ b/docs/sensualmoments/farbschema.html @@ -0,0 +1,477 @@ + + + + + +Finales Farbschema – Bordeaux-Dominant + + + + +
+

Finales Farbschema

+

Bordeaux-Dominant · Warm & Sinnlich

+
Freigegeben
+ + +
+
Dark Wine
#2A1520
+
Bordeaux
#8B3A4A
+
Blush Nude
#D4A9A0
+
Deep Navy
#151B2B
+
Crème
#F8F4F0
+
Espresso
#3D2F30
+
+ + +
+
+
+
+
Dark Wine
+
Basis · Haupthintergrund
+
#2A1520
+
Hero, Header, dunkle Sektionen, Haupthintergrund der Seite
+
+
+ +
+
+
+
Blush Nude
+
Akzent 1 · Interaktion
+
#D4A9A0
+
Buttons, Hover-Effekte, Links, Icons, aktive Elemente
+
+
+ +
+
+
+
Bordeaux
+
Akzent 2 · Headlines auf Hell
+
#8B3A4A
+
Überschriften auf hellen Flächen, Trennlinien, sekundäre Buttons
+
+
+ +
+
+
+
Deep Navy
+
Kontrast · Sektionswechsel
+
#151B2B
+
Testimonial-Bereich, Footer, Sektionswechsel für Tiefe
+
+
+ +
+
+
+
Crème
+
Neutral · Helle Flächen
+
#F8F4F0
+
Textblöcke, Formulare, helle Sektionen, Kartenhintrergründe
+
+
+ +
+
+
+
Espresso
+
Text · Fließtext auf Hell
+
#3D2F30
+
Fließtext auf Crème-Hintergrund (statt reinem Schwarz)
+
+
+
+ + +
+
+
Hero / Dunkle Sektionen
+
Headline auf Dark Wine
+
Fließtext in Crème auf dem Haupthintergrund der Website.
+
+
+
Helle Sektionen
+
Headline in Bordeaux
+
Espresso-Fließtext auf Crème-Hintergrund – warm und gut lesbar.
+
+
+
Navy-Kontrast
+
Blush auf Navy
+
Testimonials und Footer – kühler Gegenpol zu den warmen Tönen.
+
+
+ + +
+

Anwendungsregeln

+
+
Hintergrund
+
Dark Wine als Standard, Navy für Sektionswechsel
+
+
+
Buttons primär
+
Blush Nude mit Dark Wine Text
+
+
+
Buttons sekundär
+
Outline in Blush oder Bordeaux
+
+
+
Headlines dunkel
+
Crème auf dunklem Grund
+
+
+
Headlines hell
+
Bordeaux auf Crème
+
+
+
Fließtext dunkel
+
Crème auf dunklem Grund (Opacity 80–85 %)
+
+
+
Fließtext hell
+
Espresso auf Crème
+
+
+
Links / Hover
+
Blush Nude, Hover: leicht aufgehellt
+
+
+
Trennlinien
+
Bordeaux mit 20–30 % Opacity
+
+
+
Formulare
+
Crème-Hintergrund, Espresso-Text, Blush-Fokusrand
+
+
+ + +
+ +/* Farbschema – Bordeaux-Dominant · Boudoir Website */ +:root { + --color-base: #2A1520; /* Dark Wine – Haupthintergrund */ + --color-accent-1: #D4A9A0; /* Blush Nude – Buttons, Links, Hover */ + --color-accent-2: #8B3A4A; /* Bordeaux – Headlines auf Hell */ + --color-contrast: #151B2B; /* Deep Navy – Sektionswechsel */ + --color-neutral: #F8F4F0; /* Crème – Helle Flächen */ + --color-text: #3D2F30; /* Espresso – Fließtext auf Hell */ +} +
+ +
+ +
+ + + + \ No newline at end of file diff --git a/docs/sensualmoments/prototype.html b/docs/sensualmoments/prototype.html new file mode 100644 index 0000000..207bdc5 --- /dev/null +++ b/docs/sensualmoments/prototype.html @@ -0,0 +1,928 @@ + + + + + +Sensual Moment Photography + + + + + + + + + + +
+
+
+

+ Sensual + Moment +

+

Boudoir Photography · Dein Moment der Selbstliebe

+ Dein Shooting buchen +
+
+ Entdecken + + + +
+
+ + +
+
+
+
+ +

Jede Frau verdient es, sich selbst zu feiern

+
+

Ich glaube daran, dass wahre Schönheit nicht inszeniert werden muss – sie muss nur sichtbar gemacht werden. In meinem Studio schaffe ich einen geschützten Raum, in dem du dich fallen lassen kannst.

+

Mit einfühlsamer Anleitung und einem Blick für das Besondere entstehen Bilder, die deine Stärke, Sinnlichkeit und Einzigartigkeit einfangen – authentisch und mit Respekt.

+

Kein Shooting gleicht dem anderen, denn keine Frau gleicht der anderen.

+
— Dein Name
+
+
+
+ + +
+
+ +

Momente der Selbstliebe

+
+

Jedes Bild erzählt eine Geschichte von Mut, Verletzlichkeit und Stärke. Mit Einverständnis meiner Kundinnen teile ich hier ausgewählte Arbeiten.

+
+
+
Klassisch
+
Artistisch
+
Elegant
+
Natürlich
+
Dramatisch
+
Sinnlich
+
Intim
+
Mutig
+
+ +
+ + +
+ +

Was meine Kundinnen sagen

+
+
+
+
"
+

Ich habe mich noch nie so schön gefühlt. Die Atmosphäre war so vertrauensvoll, dass ich komplett loslassen konnte. Die Bilder sind unfassbar.

+ Sandra, 42 +
+
+
"
+

Anfangs war ich nervös, aber nach fünf Minuten fühlte es sich an wie ein Abend mit einer guten Freundin. Die Ergebnisse haben mich zu Tränen gerührt.

+ Katrin, 38 +
+
+
"
+

Ein Geschenk an mich selbst, das ich jeder Frau empfehlen würde. Professionell, einfühlsam und mit unglaublichem Blick für Details.

+ Maria, 51 +
+
+
+ + +
+
+ +

Pakete & Preise

+
+
+
+
+
Entdecken
+
Ab 299 €
+
    +
  • 60 Min. Shooting
  • +
  • Styling-Beratung vorab
  • +
  • 10 bearbeitete Bilder
  • +
  • Private Online-Galerie
  • +
  • 5 Feinabzüge 13×18
  • +
+ Anfragen +
+ +
+
Zelebrieren
+
Ab 799 €
+
    +
  • Halber Tag (4 Std.)
  • +
  • Styling + Visagistin
  • +
  • Alle bearbeiteten Bilder
  • +
  • Luxus-Leinenalbum
  • +
  • 3 Wandbilder nach Wahl
  • +
  • Behind-the-Scenes Video
  • +
+ Anfragen +
+
+
+ + +
+
+ +

Gedanken & Geschichten

+
+
+
+
+
+
Februar 2026
+

Warum sich jede Frau ein Boudoir-Shooting gönnen sollte

+

Es geht nicht um perfekte Posen – es geht darum, sich selbst mit neuen Augen zu sehen…

+
+
+
+
Januar 2026
+

Was ziehe ich bloß an? Dein Style-Guide fürs Shooting

+

Die richtige Garderobe kann den Unterschied machen. Hier sind meine besten Tipps…

+
+
+
+
Dezember 2025
+

Behind the Scenes: So entsteht dein persönliches Fotobuch

+

Vom ersten Klick bis zum fertigen Album – ein Blick hinter die Kulissen…

+
+
+
+ + +
+
+ +

Bereit für deinen Moment?

+
+

+ Schreib mir unverbindlich – ich melde mich innerhalb von 24 Stunden bei dir. + Gemeinsam besprechen wir in einem kostenlosen Vorgespräch, wie dein perfektes Shooting aussehen kann. +

+
+
+
E-Mail
+ +
+
+
Telefon
+ +
+
+
Studio
+
Musterstraße 12, 12345 Stadt
+
+
+
Social
+ +
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ + + + + + + \ No newline at end of file diff --git a/docs/sensualmoments/sensualmomentsdesign.md b/docs/sensualmoments/sensualmomentsdesign.md new file mode 100644 index 0000000..a7c604e --- /dev/null +++ b/docs/sensualmoments/sensualmomentsdesign.md @@ -0,0 +1,467 @@ +# Design-Briefing: sensualmoment.de + +## Projekt-Übersicht + +**Website:** sensualmoment.de +**Branche:** Boudoir-Fotografie +**Zielgruppe:** Frauen, 35–55 Jahre, die sich ein hochwertiges, intimes Fotoshooting als Akt der Selbstliebe gönnen möchten +**Tonalität:** Warm, einladend, luxuriös aber nicht einschüchternd. Empowerment statt Sexualisierung. Die Kundin soll sich sicher, wertgeschätzt und inspiriert fühlen. +**CMS:** Payload CMS (bereits vorhanden) +**Design-Referenz:** Die beigefügte Datei `sensualmoment-prototype.html` enthält den vollständigen visuellen Prototyp mit allen Sektionen, Farben, Typografie und Layoutstrukturen. + +--- + +## 1. Seitenstruktur & Payload-Collections + +### 1.1 Globale Elemente + +#### Navigation (Payload Global: `navigation`) + +| Feld | Typ | Beschreibung | +|------|-----|-------------| +| `logo` | Upload (SVG/PNG) | Logo-Wordmark in Blush-Variante für dunklen Hintergrund | +| `menuItems` | Array | Menüpunkte mit `label` (Text) und `link` (Relationship oder URL) | + +Verhalten: Fixiert am oberen Rand, transparent über dem Hero. Nach 80px Scroll wechselt der Hintergrund zu Dark Wine (#2A1520) mit 95% Deckkraft und Backdrop-Blur. Menüpunkte sind in Josefin Sans, Uppercase, gesperrt. + +#### Footer (Payload Global: `footer`) + +| Feld | Typ | Beschreibung | +|------|-----|-------------| +| `tagline` | Text | Kurzbeschreibung unter dem Logo | +| `navigationColumns` | Array of Groups | Spalten mit `title` + Array aus `label`/`link` | +| `socialLinks` | Array | Plattform + URL | +| `legalLinks` | Array | Impressum, Datenschutz, AGB als `label`/`link` | + +Hintergrund: Deep Navy (#151B2B). Vierspaltig: Logo-Spalte (breit), drei schmale Navigationsspalten. + +--- + +### 1.2 Einzelne Seiten + +--- + +#### SEITE: Startseite (Homepage) + +**Route:** `/` +**Payload:** Page-Collection mit Slug `home`, Layout-Builder oder feste Felder + +##### Sektion 1 — Hero + +| Feld | Typ | Beschreibung | +|------|-----|-------------| +| `headline` | Text | Hauptüberschrift (Standard: „Sensual Moment") | +| `subline` | Text | Untertitel (Standard: „Boudoir Photography · Dein Moment der Selbstliebe") | +| `ctaLabel` | Text | Button-Text (Standard: „Dein Shooting buchen") | +| `ctaLink` | Relationship/URL | Ziel des Buttons | +| `backgroundImage` | Upload (optional) | Hintergrundbild, wird mit Dark-Wine-Overlay versehen | + +Layout: Fullscreen (100vh), zentrierter Inhalt. Hintergrund ist Dark Wine mit zwei subtilen radialen Gradienten (Bordeaux + Blush, je sehr niedrige Deckkraft). Scroll-Indikator am unteren Rand mit Puls-Animation. Einblendung des Inhalts mit fadeUp-Animation. + +##### Sektion 2 — Über mich (Kurzversion) + +| Feld | Typ | Beschreibung | +|------|-----|-------------| +| `portrait` | Upload | Fotografen-Portrait, hochformat (3:4) | +| `sectionLabel` | Text | „Über mich" | +| `headline` | Rich Text | z.B. „Jede Frau verdient es, *sich selbst zu feiern*" | +| `bodyText` | Rich Text | 2–3 Absätze persönliche Vorstellung | +| `signature` | Text | Name der Fotografin | +| `linkToFullPage` | URL | Link zur vollständigen Über-mich-Seite | + +Layout: Zweispaltiges Grid (1:1) auf Crème-Hintergrund. Bild links mit abgerundeter Ecke oben rechts (border-radius: 0 120px 0 0). Text rechts. Max-Width 1280px, zentriert. + +##### Sektion 3 — Galerie-Vorschau + +| Feld | Typ | Beschreibung | +|------|-----|-------------| +| `sectionLabel` | Text | „Portfolio" | +| `headline` | Rich Text | z.B. „Momente der *Selbstliebe*" | +| `description` | Text | Kurzbeschreibung | +| `images` | Array of Uploads | 8 Galerie-Bilder mit optionalem `category`-Label | +| `linkToGallery` | URL | „Alle Arbeiten ansehen" | + +Layout: Dark Wine Hintergrund, volle Breite. Asymmetrisches 4-Spalten-Grid mit 4px Gap. Das 3. Bild spannt 2 Zeilen, das 6. Bild spannt 2 Spalten. Hover-Effekt: dunkler Gradient von unten mit Category-Label das einblendet. + +##### Sektion 4 — Testimonials + +| Feld | Typ | Beschreibung | +|------|-----|-------------| +| `sectionLabel` | Text | „Erfahrungen" | +| `headline` | Rich Text | z.B. „Was meine Kundinnen *sagen*" | +| `testimonials` | Relationship zu Testimonials-Collection | 3 Stück auf der Startseite | + +Layout: Crème-Hintergrund, zentrierter Header. Drei Karten im Grid mit weißem Hintergrund, feinem Blush-Border. Große Anführungszeichen in Playfair Display als Dekorelement. + +##### Sektion 5 — Pakete-Vorschau + +| Feld | Typ | Beschreibung | +|------|-----|-------------| +| `sectionLabel` | Text | „Investition in dich" | +| `headline` | Rich Text | „Pakete & *Preise*" | +| `packages` | Relationship zu Pakete-Collection | 3 Pakete | + +Layout: Deep Navy Hintergrund. Drei Karten nebeneinander. Mittlere Karte ist „featured" (Blush-Border + Label „Beliebtestes Paket" als Pill oben). Hover: Border heller, translateY(-4px). + +##### Sektion 6 — Blog/Journal-Vorschau + +| Feld | Typ | Beschreibung | +|------|-----|-------------| +| `sectionLabel` | Text | „Journal" | +| `headline` | Rich Text | „Gedanken & *Geschichten*" | +| `posts` | Relationship zu Blog-Collection | 3 neueste Beiträge | + +Layout: Crème-Hintergrund. Drei Karten mit Vorschaubild (4:3), Datum, Titel, Excerpt. Hover: Titel wechselt zu Bordeaux. + +##### Sektion 7 — Kontakt-CTA + +| Feld | Typ | Beschreibung | +|------|-----|-------------| +| `headline` | Rich Text | „Bereit für deinen *Moment*?" | +| `description` | Text | Einladungstext | +| `contactInfo` | Group | E-Mail, Telefon, Adresse, Social-Links | +| `formFields` | Definiert im Code | Name, E-Mail, Telefon, Paket-Interesse, Nachricht | + +Layout: Dark Wine, zweispaltig. Links: Text + Kontaktinfos. Rechts: Formular mit Underline-Inputs (kein Border, nur border-bottom). Submit-Button in Blush mit Dark-Wine-Text. + +--- + +#### SEITE: Über mich + +**Route:** `/ueber-mich` +**Payload:** Page-Collection mit Slug `ueber-mich` + +| Sektion | Felder | Layout | +|---------|--------|--------| +| Hero-Bereich | Großes Portrait + Headline + Kurztext | Zweispaltig, Dark Wine BG | +| Meine Geschichte | Rich Text mit Zwischenüberschriften | Schmale Spalte (max 720px), Crème BG | +| Meine Werte | 3–4 Werte-Blöcke (Icon/Titel/Text) | Grid auf Crème | +| Persönliches | Lockerer Text, ggf. mit persönlichen Fotos | Crème, freies Layout | +| CTA | „Lass uns kennenlernen" → Kontaktseite | Dark Wine Band | + +--- + +#### SEITE: Galerie + +**Route:** `/galerie` +**Payload Collection:** `gallery-images` + +| Feld | Typ | Beschreibung | +|------|-----|-------------| +| `image` | Upload | Das Bild selbst | +| `title` | Text (optional) | Bildtitel | +| `category` | Select | Klassisch / Artistisch / Elegant / Natürlich / Dramatisch / Sinnlich | +| `featured` | Boolean | Auf Startseite zeigen | +| `order` | Number | Sortierreihenfolge | + +Layout: Masonry- oder asymmetrisches Grid. Filtermöglichkeit nach Kategorie (animierte Tabs). Dark Wine Hintergrund. Lightbox bei Klick. + +--- + +#### SEITE: Pakete & Preise + +**Route:** `/pakete` +**Payload Collection:** `packages` + +| Feld | Typ | Beschreibung | +|------|-----|-------------| +| `name` | Text | Paketname (Entdecken / Erleben / Zelebrieren) | +| `priceLabel` | Text | z.B. „Ab 499 €" | +| `features` | Array of Text | Leistungsliste | +| `featured` | Boolean | Hervorgehobenes Paket | +| `ctaLabel` | Text | Button-Text | +| `order` | Number | Reihenfolge | + +Zusätzliche Sektionen auf der Seite: Einleitungstext oben, FAQ-Accordion unten (Payload Collection `faqs` mit `question`/`answer`), CTA-Band zum Kontaktformular. + +--- + +#### SEITE: Blog / Journal + +**Route:** `/journal` +**Payload Collection:** `blog-posts` + +| Feld | Typ | Beschreibung | +|------|-----|-------------| +| `title` | Text | Beitragstitel | +| `slug` | Text (auto) | URL-Pfad | +| `publishDate` | Date | Veröffentlichungsdatum | +| `coverImage` | Upload | Vorschaubild (4:3) | +| `excerpt` | Textarea | Kurztext für Vorschau | +| `content` | Rich Text / Blocks | Voller Artikelinhalt | +| `category` | Select | Tipps / Behind the Scenes / Persönlich | + +Übersichtsseite: Grid mit Karten (wie Startseiten-Vorschau, aber mehr Beiträge, paginiert). Einzelseite: Schmale Lesespalte (max 720px), großes Header-Bild, elegant typografiert. + +--- + +#### SEITE: Kontakt + +**Route:** `/kontakt` +**Payload:** Page mit eingebettetem Formular oder Payload Forms Plugin + +Identisch mit der Kontakt-Sektion auf der Startseite, aber als eigenständige Seite mit optionaler Karte/Anfahrt und erweitertem Text. + +--- + +#### SEITE: FAQ + +**Route:** `/faq` +**Payload Collection:** `faqs` + +| Feld | Typ | Beschreibung | +|------|-----|-------------| +| `question` | Text | Frage | +| `answer` | Rich Text | Antwort | +| `category` | Select | Vor dem Shooting / Während / Nachher / Allgemein | +| `order` | Number | Reihenfolge | + +Layout: Accordion-Elemente, gruppiert nach Kategorie. Crème-Hintergrund. + +--- + +#### Pflichtseiten (Rechtlich) + +| Seite | Route | Payload | +|-------|-------|---------| +| Impressum | `/impressum` | Page mit Rich-Text-Feld | +| Datenschutz | `/datenschutz` | Page mit Rich-Text-Feld | +| AGB | `/agb` | Page mit Rich-Text-Feld | + +Einfaches Layout: Schmale Textspalte auf Crème, sachliche Typografie. + +--- + +### 1.3 Payload Collections – Zusammenfassung + +| Collection | Slug | Verwendung | +|------------|------|------------| +| Pages | `pages` | Alle statischen Seiten (Home, Über mich, Kontakt, Legal) | +| Gallery Images | `gallery-images` | Galerie-Bilder mit Kategorien | +| Packages | `packages` | Shooting-Pakete mit Preisen | +| Testimonials | `testimonials` | Kundinnenstimmen (`quote`, `authorName`, `authorAge`, `featured`) | +| Blog Posts | `blog-posts` | Journal-Beiträge | +| FAQs | `faqs` | Häufige Fragen | +| Media | `media` | Standard Payload Media-Collection | + +| Global | Slug | Verwendung | +|--------|------|------------| +| Navigation | `navigation` | Logo + Menüpunkte | +| Footer | `footer` | Footer-Inhalte | +| Site Settings | `site-settings` | SEO-Defaults, Social-Media-Links, Kontaktdaten | + +--- + +## 2. Farbschema + +### 2.1 Die sechs Kernfarben + +``` +┌─────────────────┬───────────┬────────────────────────────────────────────┐ +│ Name │ Hex │ Verwendung │ +├─────────────────┼───────────┼────────────────────────────────────────────┤ +│ Dark Wine │ #2A1520 │ Haupthintergrund dunkler Sektionen, │ +│ │ │ Hero, Kontakt, scrolled Navigation │ +├─────────────────┼───────────┼────────────────────────────────────────────┤ +│ Blush Nude │ #D4A9A0 │ Primäre Akzentfarbe: Buttons, Links, │ +│ │ │ Hover-States, Logo auf dunklem Grund, │ +│ │ │ Ornamente, Trennlinien │ +├─────────────────┼───────────┼────────────────────────────────────────────┤ +│ Bordeaux │ #8B3A4A │ Headlines auf hellem Hintergrund, │ +│ │ │ Hover-Farbe für Blog-Titel, Akzente │ +├─────────────────┼───────────┼────────────────────────────────────────────┤ +│ Deep Navy │ #151B2B │ Sektionswechsel (Pakete-Bereich), Footer │ +├─────────────────┼───────────┼────────────────────────────────────────────┤ +│ Crème │ #F8F4F0 │ Helle Flächen, Formulare, Fließtext- │ +│ │ │ Hintergrund, Cards │ +├─────────────────┼───────────┼────────────────────────────────────────────┤ +│ Espresso │ #3D2F30 │ Fließtext auf hellem Hintergrund │ +└─────────────────┴───────────┴────────────────────────────────────────────┘ +``` + +### 2.2 Abgeleitete Werte (CSS Custom Properties) + +```css +:root { + --dark-wine: #2A1520; + --blush: #D4A9A0; + --bordeaux: #8B3A4A; + --navy: #151B2B; + --creme: #F8F4F0; + --espresso: #3D2F30; + + /* Transparenzvarianten für Overlays, Borders, Hintergründe */ + --blush-soft: rgba(212, 169, 160, 0.15); + --blush-medium: rgba(212, 169, 160, 0.30); + --blush-border: rgba(212, 169, 160, 0.20); + --blush-hover-bg: rgba(212, 169, 160, 0.10); +} +``` + +### 2.3 Farbzuordnung nach Sektion + +| Sektion | Hintergrund | Text | Akzente | +|---------|------------|------|---------| +| Navigation (gescrollt) | Dark Wine 95% | Blush | Blush Underline-Hover | +| Hero | Dark Wine + radiale Gradienten | Blush | Blush (CTA-Border) | +| Über mich | Crème | Espresso | Bordeaux (Headline), Blush (Divider) | +| Galerie | Dark Wine | Blush | Blush-soft (Bild-Platzhalter) | +| Testimonials | Crème | Espresso | Blush (Anführungszeichen), Bordeaux (Autorin) | +| Pakete | Deep Navy | Crème/Blush | Blush (Borders, CTA, Featured-Badge) | +| Blog | Crème | Espresso | Bordeaux (Hover), Blush (Divider) | +| Kontakt | Dark Wine | Blush | Blush (Submit-Button Hintergrund) | +| Footer | Deep Navy | Blush | Blush bei 30–60% Opacity | + +### 2.4 Interaktionszustände + +| Element | Default | Hover | Active/Focus | +|---------|---------|-------|-------------| +| CTA-Buttons (dunkel) | Transparent + Blush-Border 30% | Blush-BG 10% + Border 100% | — | +| CTA-Buttons (Pakete) | Transparent + Blush-Border 30% | Blush-BG solid + Dark-Wine-Text | — | +| Submit-Button | Blush-BG + Dark-Wine-Text | Helleres Blush (#E0B8B0) + Schatten + translateY(-2px) | — | +| Nav-Links | Blush | Opacity 0.8 + Underline von links (width 0→100%) | — | +| Blog-Titel | Espresso | Bordeaux | — | +| Karten | Ohne Schatten | Box-Shadow + translateY(-4px) | — | +| Form-Inputs | Border-bottom Blush 20% | — | Border-bottom Blush 100% | + +--- + +## 3. Typografie + +### 3.1 Schriftfamilien + +| Rolle | Font | Gewichte | Quelle | +|-------|------|----------|--------| +| **Display / Headlines** | Playfair Display | 400, 500, 600, 700 + Italic | Google Fonts | +| **Fließtext / Body** | Cormorant Garamond | 300, 400, 500 + Italic | Google Fonts | +| **UI / Labels / Navigation** | Josefin Sans | 200, 300, 400 | Google Fonts | + +### 3.2 Typografie-Hierarchie + +| Element | Font | Größe | Gewicht | Sonstiges | +|---------|------|-------|---------|-----------| +| Hero-Headline | Playfair Display | clamp(3.5rem, 8vw, 7rem) | 400 | Kursiv für zweite Zeile | +| Sektion-Titel (h2) | Playfair Display | clamp(2rem, 4vw, 3.2rem) | 400 | `` = Italic | +| Sektion-Label | Josefin Sans | 0.6rem | 300 | Uppercase, letter-spacing: 0.4em, opacity: 0.5 | +| Fließtext | Cormorant Garamond | 1.2rem | 300 | line-height: 1.8 | +| Navigation | Josefin Sans | 0.72rem | 300 | Uppercase, letter-spacing: 0.18em | +| Buttons/CTAs | Josefin Sans | 0.62–0.68rem | 300 | Uppercase, letter-spacing: 0.2–0.25em | +| Testimonial-Text | Cormorant Garamond | 1.05rem | 300 Italic | line-height: 1.75 | +| Karten-Titel | Playfair Display | 1.2–1.4rem | 400 | — | +| Footer-Links | Cormorant Garamond | 0.9rem | 300 | Opacity: 0.6, Hover: 1.0 | +| Rechtliches/Meta | Josefin Sans | 0.55–0.58rem | 300 | letter-spacing: 0.1–0.15em | + +### 3.3 Typografie-Prinzipien + +Die drei Schriften haben klar getrennte Rollen: Playfair Display erzeugt visuelle Spannung und Eleganz in Headlines, wird aber nie für kleine Texte eingesetzt. Cormorant Garamond ist die „Stimme" der Seite und transportiert den Fließtext mit Leichtigkeit (Gewicht 300). Josefin Sans fungiert als funktionale Schrift für UI-Elemente und wird ausschließlich in Uppercase mit großzügiger Sperrung eingesetzt, um einen Kontrast zur organischen Serif-Welt zu schaffen. + +Headlines nutzen häufig eine Kombination aus Regular und Italic, wobei ein Wort oder eine Phrase kursiv gesetzt wird, um einen visuellen Rhythmus zu erzeugen (z.B. „Momente der *Selbstliebe*"). Das Pattern zieht sich durch die gesamte Seite. + +--- + +## 4. Designsprache & Gestaltungsprinzipien + +### 4.1 Gesamteindruck + +Die Seite vermittelt **luxuriöse Intimität** — sie fühlt sich an wie ein warmer, geschützter Raum. Dunkel genug für Atmosphäre, aber nie düster. Die Farbwelt ist bewusst Bordeaux-dominant mit warmen Hauttönen, nicht kühl oder technisch. Alles strahlt Vertrauen und Wertschätzung aus. + +### 4.2 Layout-Prinzipien + +**Großzügiger Weißraum:** Sektionen haben 120px Padding vertikal (80px auf Mobile). Nichts fühlt sich gedrängt an. + +**Rhythmischer Wechsel zwischen Hell und Dunkel:** Die Seite alterniert konsequent: +Hero (dunkel) → Über mich (hell) → Galerie (dunkel) → Testimonials (hell) → Pakete (dunkel/navy) → Blog (hell) → Kontakt (dunkel) → Footer (dunkel/navy) + +**Asymmetrie mit System:** Das Galerie-Grid bricht bewusst aus dem gleichmäßigen Raster aus (ein Bild spannt 2 Zeilen, eines 2 Spalten). Das erzeugt visuelles Interesse ohne Chaos. + +**Max-Width für Inhalte:** Textsektionen begrenzen sich auf 1100–1280px. Die Galerie darf volle Breite nutzen. + +### 4.3 Dekorative Elemente + +| Element | Beschreibung | +|---------|-------------| +| Trennlinien | 48px breit, 1px hoch, Farbe Blush. Unter Sektion-Titeln. | +| Hero-Ornament | 60px Linie mit Gradient (transparent → Blush → transparent), über dem Titel | +| Anführungszeichen | Großes „ in Playfair Display (2rem), Farbe Blush, in Testimonial-Karten | +| Featured-Badge | Pill-Shape, Blush-Hintergrund, Dark-Wine-Text, über der Paketkarte positioniert | +| Bild-Ecke | Das Über-mich-Portrait hat einen border-radius nur oben rechts (0 120px 0 0) | + +### 4.4 Animationen & Transitions + +| Effekt | Beschreibung | Dauer | +|--------|-------------|-------| +| Scroll-Reveal | Elemente mit Klasse `.reveal` faden von unten ein (translateY 30px → 0, opacity 0 → 1) | 0.8s ease | +| Hero-Einblendung | Gesamter Hero-Content fährt von unten ein | 1.2s ease-out | +| Nav-Transition | Padding, Hintergrund, Schatten ändern sich beim Scrollen | 0.5s ease | +| Scroll-Indikator | Puls-Animation (Opacity + translateY) | 2s infinite | +| Hover-Underline | Linie unter Nav-Links wächst von links (width 0 → 100%) | 0.3s ease | +| Karten-Hover | Box-Shadow einblenden + translateY(-4px) | 0.4s | +| Galerie-Hover | Dunkler Gradient von unten einblenden + Label einblenden (delay 0.1s) | 0.4s | +| Button-Hover | Background-Change + ggf. Schatten + translateY(-2px) | 0.4s | + +Alle Animationen sind subtil und unterstützend. Keine springenden, aufmerksamkeitsheischenden Effekte. Die IntersectionObserver-Schwelle liegt bei 15%. + +### 4.5 Responsive-Verhalten + +| Breakpoint | Änderungen | +|------------|------------| +| ≤ 900px | Navigation: Hamburger-Menü (Mobile-Menu noch zu implementieren). Alle Grids fallen auf 1 Spalte. Galerie wird 2-spaltig ohne Span-Varianten. Padding reduziert auf 80px/24px. Kontakt-Sektion wird einspaltig gestapelt. Footer wird 2-spaltig. | + +### 4.6 Bildsprache (Hinweis für Content) + +Alle Platzhalter im Prototyp sind als dezente Blush-soft-Flächen angelegt. Die echten Bilder sollten folgenden Charakter haben: warme Töne, weiches Licht, natürliche Posen, Fokus auf Stärke und Anmut. Keine kühlen oder harten Kontraste. Die Bildwelt sollte die Farbwelt der Seite widerspiegeln — warme Hauttöne, dunkle Hintergründe, gelegentliche Stoffe in Bordeaux oder Crème. + +--- + +## 5. Logo-Spezifikation + +### 5.1 Aufbau + +Das Logo ist ein reines Wordmark (keine Bildmarke, kein Symbol): + +``` +Sensual ← Playfair Display, Regular, groß + Moment ← Playfair Display, Italic, kleiner, rechtsbündig unter "Sensual" + PHOTOGRAPHY ← Josefin Sans, Light, sehr klein, Uppercase, stark gesperrt, rechtsbündig +``` + +### 5.2 Farbvarianten + +| Variante | Text | Hintergrund | Einsatz | +|----------|------|-------------|---------| +| **Primary** | Blush (#D4A9A0) | Dark Wine (#2A1520) | Navigation, Hero, Kontakt | +| Navy | Blush (#D4A9A0) | Deep Navy (#151B2B) | Footer, Pakete-Bereich | +| Hell | Bordeaux (#8B3A4A) | Crème (#F8F4F0) | Helle Sektionen, Drucksachen | +| Invertiert | Crème (#F8F4F0) | Bordeaux (#8B3A4A) | Spezielle Akzente, Social Media | + +Das Logo existiert als Canva-Design mit allen 4 Varianten (Design-ID: DAHCgZTIQkg). Für die Website sollte es als SVG exportiert werden. + +--- + +## 6. Technische Hinweise für Payload + +### 6.1 Empfohlene Payload-Konfiguration + +Die Seite hat einen klaren Wechsel zwischen dunklen und hellen Sektionen. Im Payload-Admin sollte es für Page-Layouts einen Block-Builder geben, bei dem jeder Block ein Feld `theme` hat (`light` | `dark` | `navy`), das automatisch die richtige Farbkombination anwendet. + +### 6.2 Rich-Text-Kursiv-Konvention + +Überall auf der Seite werden Kursivierungen in Headlines als Stilmittel eingesetzt. Das Payload-Rich-Text-Feld muss Italic unterstützen und im Frontend als `` mit Playfair Display Italic gerendert werden. + +### 6.3 SEO-Felder + +Jede Page und jeder Blog-Post sollte folgende Felder haben: `metaTitle`, `metaDescription`, `ogImage` (Upload). Payload bietet dafür ein SEO-Plugin an. + +### 6.4 Formular-Handling + +Das Kontaktformular sollte über Payload Forms oder eine eigene Collection (`form-submissions`) verarbeitet werden. Felder: Name, E-Mail, Telefon (optional), Paket-Interesse (Select), Nachricht. E-Mail-Benachrichtigung an die Fotografin konfigurieren. + +--- + +## 7. Dateireferenzen + +| Datei | Beschreibung | +|-------|-------------| +| `sensualmoment-prototype.html` | Vollständiger visueller Prototyp mit allen Sektionen und CSS | +| `farbschema-final.html` | Interaktive Farbschema-Dokumentation | +| Canva Design DAHCgZTIQkg | Logo in allen 4 Farbvarianten | diff --git a/prompts/PROMPT_TELEGRAM_MEDIA_BOT.md b/prompts/PROMPT_TELEGRAM_MEDIA_BOT.md new file mode 100644 index 0000000..5690103 --- /dev/null +++ b/prompts/PROMPT_TELEGRAM_MEDIA_BOT.md @@ -0,0 +1,749 @@ +# Telegram Media Upload Bot – Payload CMS Integration + +## Kontext + +- **Projektverzeichnis:** `/home/payload/telegram-media-bot` (auf sv-payload, LXC 700, IP: 10.10.181.100) +- **Alternative:** Neues GitHub Repository `complexcaresolutions/telegram-media-bot` +- **Tech-Stack:** Node.js 22 LTS, TypeScript, grammy (Telegram Bot Framework), PM2 +- **Ziel-System:** Payload CMS Multi-Tenant (Production: `https://cms.c2sgmbh.de`, Staging: `https://pl.porwoll.tech`) +- **Betriebssystem:** Debian/Ubuntu (LXC Container im Proxmox-Cluster) +- **Package Manager:** pnpm + +--- + +## Projektbeschreibung + +Erstelle einen Telegram Bot, der es autorisierten Benutzern ermöglicht, Bilder direkt aus dem Telegram-Chat in die Media Collection des Payload CMS hochzuladen. Der Bot authentifiziert sich gegen die Payload REST-API, empfängt Bilder über die Telegram Bot API, lädt sie von den Telegram-Servern herunter und leitet sie per `multipart/form-data` an den Payload Media-Endpoint weiter. Dabei wird die Multi-Tenant-Isolation strikt eingehalten. + +--- + +## Technische Referenzen + +### Payload CMS REST-API + +**Base URLs:** +- Production: `https://cms.c2sgmbh.de/api` +- Staging: `https://pl.porwoll.tech/api` +- Swagger UI: `https://cms.c2sgmbh.de/api/docs` + +**Authentifizierung – Login:** +```bash +curl -X POST "https://cms.c2sgmbh.de/api/users/login" \ + -H "Content-Type: application/json" \ + -d '{ + "email": "admin@example.com", + "password": "your-password" + }' +``` + +**Response:** +```json +{ + "message": "Auth Passed", + "user": { "id": 1, "email": "admin@example.com", "isSuperAdmin": true }, + "token": "eyJhbGciOiJIUzI1NiIs..." +} +``` + +**Token verwenden:** +```bash +curl "https://cms.c2sgmbh.de/api/media" \ + -H "Authorization: JWT eyJhbGciOiJIUzI1NiIs..." +``` + +### Media Upload Endpoint + +```bash +curl -X POST "https://cms.c2sgmbh.de/api/media" \ + -H "Authorization: JWT " \ + -F "file=@/path/to/image.jpg" \ + -F "alt=Beschreibungstext" \ + -F "tenant=1" +``` + +**WICHTIG:** Das `tenant`-Feld ist **Pflicht** bei jedem Upload. Ohne korrekte Tenant-Zuordnung gibt die API 403 Forbidden zurück. Die Tenant-Isolation ist ein Kernprinzip des gesamten Systems. + +### Verfügbare Tenants + +| ID | Name | Slug | Domain | +|----|------|------|--------| +| 1 | porwoll.de | porwoll | porwoll.de | +| 4 | Complex Care Solutions GmbH | c2s | complexcaresolutions.de | +| 5 | Gunshin | gunshin | gunshin.de | +| (weitere Tenants ggf. dynamisch über API abrufen) | + +### Media Collection – Automatische Bildverarbeitung + +Payload generiert automatisch folgende responsive Größen beim Upload: + +| Size | Auflösung | Format | +|------|-----------|--------| +| thumbnail | 150×150 | Original + AVIF | +| small | 300×300 | Original + AVIF | +| medium | 600×600 | Original + AVIF | +| large | 1200×1200 | Original + AVIF | +| xlarge | 1920×1920 | Original + AVIF | +| 2k | 2560×2560 | Original + AVIF | +| og | 1200×630 | Original (Social Media) | + +→ Der Bot muss sich NICHT um Bildgrößen kümmern. Payload erledigt das serverseitig. + +### Rate Limiting (Payload API) + +| Limiter | Limit | Fenster | +|---------|-------|---------| +| publicApiLimiter | 60 Requests | 1 Minute | +| authLimiter | 5 Requests | 15 Minuten | + +→ Der Bot sollte Token cachen und nicht bei jedem Upload neu einloggen. + +--- + +## Aufgaben + +### 1. Projekt-Setup + +#### 1.1 Projektstruktur erstellen + +``` +telegram-media-bot/ +├── src/ +│ ├── index.ts # Entry Point, Bot-Start +│ ├── bot.ts # Grammy Bot-Instanz + Handler +│ ├── config.ts # Environment-Konfiguration (typisiert) +│ ├── payload/ +│ │ ├── client.ts # Payload API Client (Login, Token-Management) +│ │ └── media.ts # Media Upload Logik +│ ├── telegram/ +│ │ ├── handlers.ts # Message Handler (Photo, Document, Commands) +│ │ └── keyboards.ts # Inline Keyboards (Tenant-Auswahl etc.) +│ ├── middleware/ +│ │ └── auth.ts # User-Whitelist Middleware +│ └── utils/ +│ ├── logger.ts # Logging Utility +│ └── download.ts # Telegram File Download Helper +├── .env.example # Template für Environment Variables +├── .env # (gitignored) Actual Config +├── package.json +├── tsconfig.json +├── ecosystem.config.cjs # PM2 Konfiguration +├── .gitignore +└── README.md +``` + +**Akzeptanzkriterien:** +- [ ] `pnpm init` ausgeführt +- [ ] TypeScript konfiguriert (strict mode) +- [ ] Alle Dependencies installiert +- [ ] `.gitignore` enthält `node_modules`, `.env`, `dist` + +#### 1.2 Dependencies installieren + +```bash +pnpm add grammy dotenv +pnpm add -D typescript @types/node tsx +``` + +**Hinweis:** `grammy` ist das bevorzugte Telegram Bot Framework (modern, TypeScript-first, aktiv maintained). Alternativ wäre `node-telegram-bot-api` möglich, aber `grammy` hat bessere TypeScript-Unterstützung. + +#### 1.3 TypeScript Konfiguration + +**Datei:** `tsconfig.json` + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} +``` + +#### 1.4 Package.json Scripts + +```json +{ + "name": "telegram-media-bot", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "lint": "tsc --noEmit" + } +} +``` + +--- + +### 2. Konfiguration + +#### 2.1 Environment Variables + +**Datei:** `.env.example` + +```env +# Telegram Bot +TELEGRAM_BOT_TOKEN=123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11 + +# Zugelassene Telegram User-IDs (kommasepariert) +ALLOWED_USER_IDS=123456789,987654321 + +# Payload CMS +PAYLOAD_API_URL=https://cms.c2sgmbh.de/api +PAYLOAD_ADMIN_EMAIL=admin@example.com +PAYLOAD_ADMIN_PASSWORD=your-secure-password + +# Standard-Tenant (wird verwendet wenn kein Tenant gewählt) +DEFAULT_TENANT_ID=1 + +# Logging +LOG_LEVEL=info + +# Node Environment +NODE_ENV=production +``` + +#### 2.2 Typisierte Config + +**Datei:** `src/config.ts` + +```typescript +// Alle Environment Variables typisiert laden und validieren. +// Bei fehlenden Pflicht-Variablen: Prozess mit Error beenden. +// ALLOWED_USER_IDS als number[] parsen (kommasepariert). +// Validation beim Import, nicht lazy. + +interface Config { + telegram: { + botToken: string; + allowedUserIds: number[]; + }; + payload: { + apiUrl: string; + email: string; + password: string; + }; + defaultTenantId: number; + logLevel: string; + nodeEnv: string; +} +``` + +**Akzeptanzkriterien:** +- [ ] Config wird beim Start validiert +- [ ] Fehlende Pflicht-Variablen = sofortiger Exit mit klarer Fehlermeldung +- [ ] `ALLOWED_USER_IDS` wird als `number[]` geparst + +--- + +### 3. Payload API Client + +#### 3.1 Authentifizierung mit Token-Caching + +**Datei:** `src/payload/client.ts` + +Implementiere einen Payload API Client mit folgender Logik: + +1. **Login:** `POST /api/users/login` mit Email/Password +2. **Token speichern** (In-Memory, NICHT auf Disk) +3. **Token-Expiry tracken:** JWT decodieren (ohne Verifikation, nur Payload lesen), `exp` Feld prüfen +4. **Auto-Refresh:** Vor jedem API-Call prüfen ob Token noch gültig (mit 5 Min. Buffer). Falls abgelaufen → neu einloggen. +5. **Retry-Logik:** Bei 401-Response einmal neu einloggen und Request wiederholen. + +```typescript +class PayloadClient { + private token: string | null = null; + private tokenExpiry: number = 0; + + async getToken(): Promise { /* ... */ } + async login(): Promise { /* ... */ } + async uploadMedia(file: Buffer, filename: string, options: MediaUploadOptions): Promise { /* ... */ } + async listMedia(tenantId: number, limit?: number): Promise { /* ... */ } + async deleteMedia(mediaId: number): Promise { /* ... */ } +} + +interface MediaUploadOptions { + alt: string; + tenantId: number; + caption?: string; +} + +interface MediaResponse { + id: number; + url: string; + filename: string; + alt: string; + sizes: Record; +} +``` + +**WICHTIG – Multi-Tenant:** +- Jeder API-Call der Media betrifft MUSS `tenant` als Feld mitsenden +- Bei Reads: `?where[tenant][equals]=` als Query-Parameter +- Bei Writes: `tenant` im Request-Body + +**Akzeptanzkriterien:** +- [ ] Login funktioniert, Token wird gecacht +- [ ] Token wird vor Ablauf automatisch erneuert +- [ ] 401 Response triggert Re-Login + Retry +- [ ] Alle API-Calls enthalten korrekte Tenant-Filterung + +#### 3.2 Media Upload Funktion + +**Datei:** `src/payload/media.ts` + +```typescript +// Upload einer Bilddatei an POST /api/media +// Content-Type: multipart/form-data +// +// Felder: +// file: Die Bilddatei (Buffer) mit korrektem filename + mimetype +// alt: Alt-Text (string) +// tenant: Tenant-ID (number) +// +// Die Payload API generiert automatisch alle responsiven Größen. +// Response enthält die vollständige Media-Resource inkl. aller Size-URLs. +``` + +Für den multipart Upload verwende die native `FormData` API (ab Node.js 18+ verfügbar) oder `form-data` Package. **Kein axios nötig** – nutze native `fetch`. + +**Akzeptanzkriterien:** +- [ ] Upload funktioniert mit JPG, PNG, WebP, AVIF +- [ ] Alt-Text wird korrekt gesetzt +- [ ] Tenant-Zuordnung funktioniert +- [ ] Response wird korrekt geparst + +--- + +### 4. Telegram Bot + +#### 4.1 Bot-Instanz und Middleware + +**Datei:** `src/bot.ts` + +```typescript +import { Bot, Context, session } from 'grammy'; + +interface SessionData { + selectedTenantId: number; + selectedTenantName: string; +} + +type BotContext = Context & { session: SessionData }; + +// Bot erstellen mit Grammy +// Session-Middleware für Tenant-Auswahl pro User +// Auth-Middleware für User-Whitelist +``` + +#### 4.2 Auth Middleware (User-Whitelist) + +**Datei:** `src/middleware/auth.ts` + +```typescript +// Middleware die prüft ob ctx.from.id in ALLOWED_USER_IDS enthalten ist. +// Falls nicht: Antwort "⛔ Du bist nicht autorisiert, diesen Bot zu verwenden." +// und ctx.next() NICHT aufrufen. +// +// WICHTIG: Auch in Gruppen-Chats nur auf autorisierte User reagieren. +``` + +**Akzeptanzkriterien:** +- [ ] Nicht-autorisierte User erhalten Fehlermeldung +- [ ] Autorisierte User können alle Funktionen nutzen +- [ ] Middleware blockiert alle Handler, nicht nur einzelne + +#### 4.3 Command Handler + +**Datei:** `src/telegram/handlers.ts` + +Implementiere folgende Befehle: + +**`/start`** +- Begrüßungsnachricht mit Kurzanleitung +- Zeige aktuell gewählten Tenant +- Text: +``` +🤖 Payload Media Upload Bot + +Schicke mir ein Bild und ich lade es in die Payload CMS Media-Bibliothek hoch. + +📌 Aktueller Tenant: [Tenant-Name] +📋 Befehle: +/tenant - Tenant wechseln +/list - Letzte 5 Uploads anzeigen +/status - Bot- und API-Status +/help - Hilfe anzeigen +``` + +**`/tenant`** +- Zeige Inline-Keyboard mit allen verfügbaren Tenants +- Tenants dynamisch von der API laden: `GET /api/tenants` (Auth erforderlich) +- Nach Auswahl: Tenant in Session speichern +- Bestätigung: `✅ Tenant gewechselt zu: [Name] (ID: [ID])` + +**`/list`** +- Zeige die letzten 5 hochgeladenen Medien des aktuellen Tenants +- API: `GET /api/media?where[tenant][equals]=&sort=-createdAt&limit=5` +- Ausgabe als Liste mit Thumbnail-URL, Dateiname, Datum + +**`/status`** +- Zeige: + - Bot-Uptime + - Payload API erreichbar? (Quick-Check: `GET /api/users/me`) + - Aktueller Tenant + - Token-Status (gültig bis...) + +**`/help`** +- Ausführliche Hilfe mit allen Befehlen und Nutzungshinweisen + +#### 4.4 Photo Handler (Kern-Funktionalität) + +**Datei:** `src/telegram/handlers.ts` (fortgesetzt) + +```typescript +// Handler für bot.on('message:photo') +// +// Ablauf: +// 1. Höchste verfügbare Auflösung wählen: +// ctx.message.photo ist ein Array von PhotoSize-Objekten, +// sortiert nach Größe. Letztes Element = höchste Auflösung. +// +// 2. File-Info abrufen: +// const file = await ctx.api.getFile(photo.file_id) +// Download-URL: https://api.telegram.org/file/bot/ +// +// 3. Bild herunterladen (als Buffer): +// fetch() auf die Download-URL +// +// 4. Alt-Text bestimmen: +// - Falls Caption vorhanden (ctx.message.caption) → als Alt-Text verwenden +// - Falls nicht → Generiere: "Upload via Telegram – [Datum] [Uhrzeit]" +// +// 5. Statusmeldung senden: +// "⏳ Bild wird hochgeladen..." +// +// 6. An Payload API hochladen: +// payloadClient.uploadMedia(buffer, filename, { alt, tenantId }) +// +// 7. Erfolgsmeldung: +// "✅ Upload erfolgreich! +// 📎 ID: [id] +// 📁 Dateiname: [filename] +// 🔗 URL: [url] +// 🏷️ Tenant: [tenant-name] +// 📐 Größen: thumbnail, small, medium, large, xlarge, 2k, og" +// +// 8. Bei Fehler: +// "❌ Upload fehlgeschlagen: [Fehlermeldung]" +// Logge den vollständigen Error serverseitig. +``` + +**Akzeptanzkriterien:** +- [ ] Bilder werden in höchster Auflösung heruntergeladen +- [ ] Caption wird als Alt-Text verwendet (falls vorhanden) +- [ ] Statusmeldung wird gesendet BEVOR der Upload startet +- [ ] Erfolgsmeldung enthält Media-ID und URL +- [ ] Fehler werden sauber abgefangen und dem User angezeigt +- [ ] Tenant-Zuordnung ist korrekt + +#### 4.5 Document Handler (Erweitert) + +```typescript +// Handler für bot.on('message:document') +// +// Akzeptiere nur Bildformate: jpg, jpeg, png, webp, avif, gif, svg +// Bei nicht unterstütztem Format: +// "⚠️ Format nicht unterstützt. Erlaubt: JPG, PNG, WebP, AVIF, GIF, SVG" +// +// Vorteil von Document-Upload gegenüber Photo: +// Telegram komprimiert Bilder die als Foto gesendet werden. +// Als Dokument gesendet bleibt die Originalqualität erhalten. +// → Dem User diesen Tipp in /help erklären. +``` + +#### 4.6 Album/Bulk Handler + +```typescript +// Handler für mehrere Bilder gleichzeitig (Media Group / Album) +// +// Telegram sendet Alben als einzelne Messages mit gleicher media_group_id. +// Sammle alle Messages mit gleicher media_group_id über ein kurzes Zeitfenster +// (500ms Debounce), dann lade alle Bilder sequentiell hoch. +// +// Status: "⏳ Album erkannt: [N] Bilder werden hochgeladen..." +// Pro Bild: Fortschritt melden: "📤 [X]/[N] hochgeladen..." +// Am Ende: Zusammenfassung aller Upload-IDs +``` + +#### 4.7 Inline Keyboards + +**Datei:** `src/telegram/keyboards.ts` + +```typescript +// Tenant-Auswahl Keyboard +// Dynamisch aus API geladen (GET /api/tenants) +// Format: 2 Buttons pro Reihe +// Jeder Button: callback_data = "tenant:" +// +// Beispiel: +// [ [porwoll.de] [C2S] ] +// [ [Gunshin] [BlogWoman] ] + +// Callback Query Handler: +// Bei "tenant:" → Session updaten, Bestätigung senden +``` + +--- + +### 5. Utilities + +#### 5.1 Logger + +**Datei:** `src/utils/logger.ts` + +```typescript +// Einfacher Logger mit Levels: debug, info, warn, error +// Format: [TIMESTAMP] [LEVEL] [MODULE] Message +// Beispiel: [2026-03-01 14:30:00] [INFO] [PayloadClient] Login erfolgreich +// +// Kein externes Logging-Framework nötig – console.log basiert reicht. +// LOG_LEVEL aus config bestimmt Mindest-Level. +``` + +#### 5.2 Download Helper + +**Datei:** `src/utils/download.ts` + +```typescript +// Funktion zum Herunterladen einer Datei von einer URL als Buffer. +// Nutze native fetch(). +// Timeout: 30 Sekunden +// Max. Dateigröße: 20 MB (Telegram-Limit) +// Bei Fehler: Spezifische Error-Messages (Timeout, Too Large, Network Error) + +async function downloadFile(url: string): Promise<{ buffer: Buffer; mimeType: string }> { /* ... */ } +``` + +--- + +### 6. PM2 Konfiguration & Deployment + +#### 6.1 PM2 Ecosystem File + +**Datei:** `ecosystem.config.cjs` + +```javascript +module.exports = { + apps: [{ + name: 'telegram-media-bot', + script: './dist/index.js', + instances: 1, // NUR 1 Instanz! Telegram Long-Polling verträgt kein Clustering + autorestart: true, + watch: false, + max_memory_restart: '256M', + env: { + NODE_ENV: 'production' + }, + error_file: './logs/error.log', + out_file: './logs/out.log', + merge_logs: true, + time: true + }] +}; +``` + +**WICHTIG:** Der Bot nutzt Long-Polling (kein Webhook). Deshalb darf nur EINE Instanz laufen. Mehrere Instanzen führen zu Konflikten bei der Telegram API. + +#### 6.2 Deployment auf sv-payload + +Der Bot läuft als zusätzlicher PM2-Prozess auf **sv-payload (LXC 700, 10.10.181.100)**, wo bereits das Payload CMS per PM2 managed wird. + +```bash +# Auf sv-payload (als user 'payload') +cd /home/payload +git clone git@github.com:complexcaresolutions/telegram-media-bot.git +cd telegram-media-bot +pnpm install +cp .env.example .env +# → .env mit echten Werten befüllen + +# Build und Start +pnpm build +pm2 start ecosystem.config.cjs +pm2 save +``` + +#### 6.3 GitHub Actions Workflow (Optional) + +**Datei:** `.github/workflows/deploy.yml` + +```yaml +name: Deploy Telegram Bot +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Deploy via SSH + uses: appleboy/ssh-action@v1 + with: + host: 37.24.237.181 # Externe IP (UDM Pro SE) + username: payload + key: ${{ secrets.STAGING_SSH_KEY }} + port: 22122 # SSH-Port über Port-Forwarding + script: | + cd /home/payload/telegram-media-bot + git pull origin main + pnpm install --frozen-lockfile + pnpm build + pm2 restart telegram-media-bot +``` + +**SSH-Zugang:** Externer Zugang über UDM Pro SE Port-Forwarding (Port 22122 → sv-payload:22). Der SSH-Key `STAGING_SSH_KEY` ist bereits als GitHub Secret konfiguriert. + +--- + +### 7. Sicherheit + +#### 7.1 Maßnahmen + +1. **User-Whitelist:** Nur explizit erlaubte Telegram User-IDs dürfen den Bot nutzen +2. **Token-Sicherheit:** Payload JWT wird nur im Memory gehalten, nie auf Disk geschrieben +3. **Environment Variables:** Alle Secrets in `.env`, nie im Code +4. **Rate Limiting:** Maximal 10 Uploads pro Minute pro User (Bot-seitig implementiert) +5. **Dateigrößen-Limit:** Telegram begrenzt auf 20 MB, zusätzlich Bot-seitiges Limit von 20 MB +6. **Dateityp-Validierung:** Nur erlaubte MIME-Types akzeptieren (image/jpeg, image/png, image/webp, image/avif, image/gif, image/svg+xml) +7. **Kein Webhook-Modus:** Long-Polling vermeidet die Notwendigkeit eines öffentlich erreichbaren Endpoints + +#### 7.2 Telegram Bot erstellen + +1. Öffne Telegram und suche `@BotFather` +2. Sende `/newbot` +3. Name: `CCS Media Upload Bot` (oder ähnlich) +4. Username: `ccs_media_upload_bot` (muss eindeutig sein und auf `bot` enden) +5. Token sichern → in `.env` als `TELEGRAM_BOT_TOKEN` eintragen +6. Optional via BotFather: + - `/setdescription` – Bot-Beschreibung setzen + - `/setcommands` – Bot-Befehle registrieren: + ``` + start - Bot starten und Hilfe anzeigen + tenant - Ziel-Tenant wechseln + list - Letzte Uploads anzeigen + status - Bot- und API-Status prüfen + help - Ausführliche Hilfe + ``` + +--- + +### 8. Error Handling & Edge Cases + +#### 8.1 Zu behandelnde Szenarien + +| Szenario | Verhalten | +|----------|-----------| +| Payload API nicht erreichbar | User informieren, Retry nach 30s, Log Error | +| JWT abgelaufen während Upload | Auto-Relogin + Retry (max. 1x) | +| Telegram File Download fehlschlägt | User informieren mit spezifischem Error | +| Bild zu groß (>20 MB) | `⚠️ Datei zu groß. Maximum: 20 MB` | +| Nicht unterstütztes Format | `⚠️ Format nicht unterstützt. Erlaubt: JPG, PNG, WebP, AVIF, GIF, SVG` | +| Kein Tenant gewählt | Default-Tenant verwenden, User informieren | +| Payload 403 (Tenant-Problem) | `❌ Zugriff verweigert. Prüfe die Tenant-Zuordnung.` | +| Payload 429 (Rate Limit) | `⏳ Zu viele Anfragen. Bitte warte [X] Sekunden.` | +| Bot-Start ohne gültige Config | Sofortiger Exit mit klarem Fehlertext | +| Album mit >10 Bildern | Hinweis dass max. 10 gleichzeitig verarbeitet werden | + +#### 8.2 Graceful Shutdown + +```typescript +// Bei SIGINT/SIGTERM: +// 1. Bot-Polling stoppen +// 2. Laufende Uploads abwarten (max. 60s Timeout) +// 3. Prozess beenden +// PM2 sendet SIGINT, dann nach Timeout SIGKILL. +``` + +--- + +## Erfolgskriterien (Gesamt) + +- [ ] `pnpm lint` (tsc --noEmit) ohne Errors +- [ ] `pnpm build` erfolgreich +- [ ] Bot startet und verbindet sich mit Telegram +- [ ] `/start` zeigt Begrüßung +- [ ] `/tenant` zeigt Inline-Keyboard mit Tenants aus der API +- [ ] Tenant-Wechsel funktioniert und wird in Session gespeichert +- [ ] Bild-Upload (als Foto) → Bild erscheint in Payload CMS Media Collection mit korrektem Tenant +- [ ] Bild-Upload (als Dokument) → Bild in Originalqualität hochgeladen +- [ ] Caption wird als Alt-Text übernommen +- [ ] Album-Upload funktioniert (mehrere Bilder) +- [ ] `/list` zeigt letzte 5 Uploads +- [ ] `/status` zeigt API-Status und Token-Validität +- [ ] Nicht-autorisierte User werden blockiert +- [ ] Fehler werden dem User als verständliche Meldungen angezeigt +- [ ] PM2 managed den Prozess mit Auto-Restart +- [ ] Logs werden in `./logs/` geschrieben + +--- + +## Selbst-Prüfung + +Nach jeder Iteration: +1. `pnpm lint` (tsc --noEmit) +2. `pnpm build` +3. Bei Fehler: korrigieren und wiederholen +4. Manueller Test-Flow: + - Bot starten mit `pnpm dev` + - `/start` senden + - `/tenant` → Tenant wählen + - Bild senden → Upload prüfen + - `/list` → Upload in Liste sichtbar +5. Fortschritt in README.md dokumentieren + +--- + +## Escape Hatch + +Nach 15 Iterationen ohne Fortschritt: +- Dokumentiere was blockiert in BLOCKERS.md +- Liste alle versuchten Ansätze auf +- Schlage 3 alternative Lösungswege vor +- Output BLOCKED + +--- + +## Hinweise für die Implementierung + +1. **Telegram Bot Token** muss vor dem Start via @BotFather erstellt und in `.env` eingetragen werden. +2. **Payload Admin Credentials** müssen einen User mit SuperAdmin-Rechten referenzieren (für Tenant-übergreifenden Zugriff). +3. **Grammy statt node-telegram-bot-api** – Grammy ist moderner, hat bessere TypeScript-Unterstützung und aktive Wartung. +4. **Native fetch statt axios** – Node.js 22 hat native fetch/FormData. Keine zusätzliche HTTP-Library nötig. +5. **Long-Polling statt Webhooks** – Einfacher zu deployen (kein öffentlicher Endpoint nötig), perfekt für den Use Case. +6. **Kein separater LXC-Container** nötig – der Bot läuft als zusätzlicher PM2-Prozess auf sv-payload. + +--- + +## Fertig? + +Wenn ALLE Aufgaben erledigt sind UND alle Erfolgskriterien erfüllt sind: + +TELEGRAM_BOT_COMPLETE