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

30 KiB
Raw Permalink Blame History

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

mkdir -p /home/payload/telegram-media-bot
cd /home/payload/telegram-media-bot
pnpm init

Step 2: Write package.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

pnpm add grammy dotenv
pnpm add -D typescript @types/node tsx

Step 4: Write tsconfig.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

# 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

mkdir -p src/{payload,telegram,middleware,utils}
mkdir -p logs

Step 8: Commit

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.

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.

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

pnpm lint

Step 4: Commit

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.

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.

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

pnpm lint

Step 4: Commit

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.

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.

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

pnpm lint

Step 4: Commit

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.

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.

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

pnpm lint

Step 4: Commit

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.

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

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

pnpm lint    # tsc --noEmit
pnpm build   # tsc → dist/
ls dist/     # verify output files exist

Step 4: Commit

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

pnpm lint          # No errors
pnpm build         # Clean build
ls -la dist/       # All files present

Step 3: Final commit

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

cp .env.example .env
# Fill with real values (bot token, payload credentials, user IDs)

Step 2: Test in dev mode

pnpm dev
# In Telegram: /start, /tenant, send photo, /list, /status

Step 3: Production start

pnpm build
pm2 start ecosystem.config.cjs
pm2 save
pm2 list   # Verify telegram-media-bot is online

Step 4: Verify in PM2

pm2 logs telegram-media-bot --lines 20