- 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>
30 KiB
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