mirror of
https://github.com/complexcaresolutions/cms.c2sgmbh.git
synced 2026-03-17 20:54:11 +00:00
- 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>
1155 lines
30 KiB
Markdown
1155 lines
30 KiB
Markdown
# 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
|
||
```
|