# 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 ```