cms.c2sgmbh/docs/plans/2026-03-01-telegram-media-bot.md
Martin Porwoll 52a266d72d docs: add telegram media bot plan and sensualmoment design docs
- Telegram media bot implementation plan and prompt
- sensualmoment.de design prototypes (color scheme, prototype, design doc)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 22:14:44 +00:00

1155 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<string, { url?: string; width?: number; height?: number }>;
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<void> {
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<string> {
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<Response> {
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<MediaDoc> {
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<MediaListResponse> {
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<void> {
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<TenantDoc[]> {
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<boolean> {
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<void> {
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<SessionData>;
// Rate limiting: track uploads per user
const uploadCounts = new Map<number, { count: number; resetAt: number }>();
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<BotContext> {
const bot = new Bot<BotContext>(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<TenantDoc[]> {
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<InlineKeyboard> {
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<string, { photos: Array<{ fileId: string; caption?: string }>; timer: ReturnType<typeof setTimeout> }>();
export function registerHandlers(bot: Bot<BotContext>): 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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
```