/** * Telegram webhook controller (POST /api/webhooks/telegram). * * Steps: * 1. Optionally verify X-Telegram-Bot-Api-Secret-Token header. * 2. Dedupe by provider_event_id in webhook_events (onConflictDoNothing). * 3. Normalize the Telegram update to NormalizedInboundMessage. * 4. Call ConversationsService.ingestInbound. * 5. Emit Inngest events (message.received + evidence.uploaded per item). * * Day 2 is INBOUND ONLY — no messages are sent back (CLAUDE.md #2, #3). */ import { Controller, Headers, HttpCode, HttpException, HttpStatus, Inject, Post, Req, Logger } from "@nestjs/common"; import type { FastifyRequest } from "fastify"; import { eq, and, desc } from "drizzle-orm"; import { type Database, webhookEvents, userIdentities, conversations, missions, missionStatusSnapshots, } from "@courtmitra/db"; import type { NormalizedInboundMessage } from "@courtmitra/contracts"; import { DB } from "../db/db.module.js"; import { ConversationsService } from "../conversations/conversations.service.js"; import { WorkflowService } from "../workflow/inngest.module.js"; import { createTelegramChannelAdapter } from "@courtmitra/adapters"; import { Public } from "../auth/public.decorator.js"; // ---- Telegram update shape (minimal, only what we need) ------------------- interface TgPhotoSize { file_id: string; file_size?: number; width: number; height: number; } interface TgDocument { file_id: string; mime_type?: string; file_name?: string; } interface TgVoice { file_id: string; mime_type?: string; duration?: number; } interface TgMessage { message_id: number; chat: { id: number }; from?: { id: number; username?: string; first_name?: string }; text?: string; photo?: TgPhotoSize[]; document?: TgDocument; voice?: TgVoice; } interface TgUpdate { update_id: number; message?: TgMessage; } function pickLargestPhoto(photos: TgPhotoSize[]): TgPhotoSize | undefined { return photos.reduce((best, p) => { if (!best) return p; return (p.file_size ?? 0) > (best.file_size ?? 0) ? p : best; }, undefined); } function normalizeTelegramUpdate(update: TgUpdate): NormalizedInboundMessage | null { const msg = update.message; if (!msg) return null; // e.g. edited_message, callback_query — ignore for now const chatId = String(msg.chat.id); const fromId = String(msg.from?.id ?? chatId); const media: NormalizedInboundMessage["media"] = []; if (msg.photo) { const largest = pickLargestPhoto(msg.photo); if (largest) { media.push({ kind: "photo", providerFileId: largest.file_id, mimeType: "image/jpeg", filename: null }); } } if (msg.document) { media.push({ kind: "document", providerFileId: msg.document.file_id, mimeType: msg.document.mime_type ?? null, filename: msg.document.file_name ?? null, }); } if (msg.voice) { media.push({ kind: "audio", providerFileId: msg.voice.file_id, mimeType: msg.voice.mime_type ?? "audio/ogg", filename: null, }); } return { channel: "telegram", externalThreadId: chatId, providerMessageId: String(msg.message_id), fromSubject: fromId, text: msg.text ?? null, media, rawPayloadRef: update, }; } // --------------------------------------------------------------------------- @Public() @Controller("webhooks/telegram") export class TelegramWebhookController { private readonly logger = new Logger(TelegramWebhookController.name); constructor( @Inject(DB) private readonly db: Database, @Inject(ConversationsService) private readonly conversations: ConversationsService, @Inject(WorkflowService) private readonly workflow: WorkflowService, ) {} @Post() @HttpCode(200) async handleUpdate( @Req() req: FastifyRequest, @Headers("x-telegram-bot-api-secret-token") secretToken?: string, ) { // 1. Secret token verification. // Fix 13: in production, reject requests if the secret is unset. const expectedSecret = process.env["TELEGRAM_WEBHOOK_SECRET"]; if (!expectedSecret && process.env["NODE_ENV"] === "production") { throw new HttpException("Forbidden: TELEGRAM_WEBHOOK_SECRET not configured", HttpStatus.FORBIDDEN); } if (expectedSecret && secretToken !== expectedSecret) { throw new HttpException("Forbidden", HttpStatus.FORBIDDEN); } const update = req.body as TgUpdate; if (!update || typeof update.update_id !== "number") { return { ok: true, skipped: "no update_id" }; } const providerEventId = `telegram:${update.update_id}`; // 2. Dedupe insert — if already processed, return early const [inserted] = await this.db .insert(webhookEvents) .values({ provider: "telegram", providerEventId, payload: update as unknown as Record, }) .onConflictDoNothing() .returning(); if (!inserted) { return { ok: true, deduped: true }; } // 3. Normalize const normalized = normalizeTelegramUpdate(update); if (!normalized) { // Mark as processed but nothing to ingest await this.db .update(webhookEvents) .set({ processedAt: new Date() }) .where(eq(webhookEvents.id, inserted.id)); return { ok: true, skipped: "unsupported update type" }; } const chatId = normalized.externalThreadId; const fromId = normalized.fromSubject; const text = normalized.text?.trim(); // Command Interceptor for /list, /switch, /status, /help if (text && text.startsWith("/")) { const parts = text.split(/\s+/); const command = parts[0].toLowerCase(); const args = parts.slice(1); if ( command === "/list" || command === "/switch" || command === "/status" || command === "/help" ) { try { await this.handleTelegramCommand(chatId, fromId, command, args); } catch (err) { this.logger.error(`Failed to handle telegram command ${command}: ${String(err)}`); } // Mark webhook event as processed await this.db .update(webhookEvents) .set({ processedAt: new Date() }) .where(eq(webhookEvents.id, inserted.id)); return { ok: true, command: true }; } } // 4. Ingest const result = await this.conversations.ingestInbound(normalized, {}); // Send a welcoming message to the Telegram user if a new mission/complaint is initialized if (result.createdMission) { try { const channel = createTelegramChannelAdapter(); await channel.sendMessage({ channel: "telegram", to: chatId, body: `🚀 *New Complaint Started!*\n\nI have initialized a new complaint for you. Please send details about your grievance (mention what happened, dates, amounts, etc.) and upload any photos/documents as evidence.\n\nType /help to see all available commands.`, }); } catch (err) { this.logger.warn(`Failed to send welcome message: ${String(err)}`); } } // 5. Emit Inngest events. If this fails, return a retryable error and // leave webhook_events.processedAt null so we don't mark a dropped event as done. try { await this.workflow.send("message.received", { missionId: result.missionId, conversationId: result.conversationId, messageId: result.messageId, }); for (const evidenceItemId of result.evidenceItemIds) { await this.workflow.send("evidence.uploaded", { missionId: result.missionId, evidenceItemId, }); } } catch (err) { throw new HttpException( `Failed to emit workflow events: ${String(err)}`, HttpStatus.SERVICE_UNAVAILABLE, ); } // Mark webhook event as processed await this.db .update(webhookEvents) .set({ processedAt: new Date() }) .where(eq(webhookEvents.id, inserted.id)); return { ok: true }; } private escapeHtml(text: string): string { return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } private async handleTelegramCommand( chatId: string, fromId: string, command: string, args: string[], ) { const telegramChannel = createTelegramChannelAdapter(); // 1. Look up user identity const existingIdentity = await this.db .select({ userId: userIdentities.userId }) .from(userIdentities) .where( and( eq(userIdentities.provider, "telegram"), eq(userIdentities.providerSubject, fromId), ), ) .limit(1); const userId = existingIdentity[0]?.userId; if (command === "/help") { const helpText = `🤖 *CourtMitra Bot Commands* /start or /new - Start a brand new complaint (archives current active one) /list - List all your complaints/missions /switch [id-prefix] - Switch active complaint context (e.g. /switch 915685ee) /status - Show details of your active complaint`; await telegramChannel.sendMessage({ channel: "telegram", to: chatId, body: helpText, }); return; } if (!userId) { await telegramChannel.sendMessage({ channel: "telegram", to: chatId, body: "❌ You don't have any complaints registered yet. Send a message describing your grievance to start one!", }); return; } if (command === "/list") { // Show ALL missions in the system (for the demo). Telegram users can see // both their own missions and web-created missions (under Demo User). const allMissions = await this.db .select() .from(missions) .orderBy(desc(missions.createdAt)) .limit(20); const webBaseUrl = process.env["NEXT_PUBLIC_API_BASE_URL"]?.replace("/api", "") ?? "http://localhost:3000"; if (allMissions.length === 0) { await telegramChannel.sendMessage({ channel: "telegram", to: chatId, body: "📋 No complaints registered.", }); return; } let body = "📋 All Complaints/Missions:\n\n"; allMissions.forEach((m, idx) => { const shortId = m.id.substring(0, 8); const missionUrl = `${webBaseUrl}/missions/${m.id}`; body += `${idx + 1}. ${this.escapeHtml(m.title)}\n`; body += ` ID: ${shortId} — Open in browser ↗\n`; body += ` Status: ${m.state}\n\n`; }); body += "To switch active context, type:\n/switch [ID]\n\nTip: Tap the ID to copy it, or open in browser for full details."; await telegramChannel.sendMessage({ channel: "telegram", to: chatId, body, }); return; } if (command === "/switch") { const prefix = args[0]?.trim(); if (!prefix) { await telegramChannel.sendMessage({ channel: "telegram", to: chatId, body: "❌ Please provide a complaint ID or prefix. Example: /switch 915685ee or /switch 915685ee-...", }); return; } // Query all missions (not just this user's, for demo) const allMissions = await this.db .select() .from(missions) .orderBy(desc(missions.createdAt)); const matched = allMissions.find( (m) => m.id === prefix || m.id.startsWith(prefix) || m.id.replace(/-/g, "").startsWith(prefix), ); if (!matched) { await telegramChannel.sendMessage({ channel: "telegram", to: chatId, body: `❌ No complaint found matching "${this.escapeHtml(prefix)}". Type /list to see all complaints.`, }); return; } // Update the active mission ID for the conversation await this.db .update(conversations) .set({ activeMissionId: matched.id, lastMessageAt: new Date() }) .where( and( eq(conversations.channel, "telegram"), eq(conversations.externalThreadId, chatId), ), ); await telegramChannel.sendMessage({ channel: "telegram", to: chatId, body: `✅ Switched active context to: *${matched.title}*\n\nFuture messages will be added to this complaint.`, }); return; } if (command === "/status") { const webBaseUrl = process.env["NEXT_PUBLIC_API_BASE_URL"]?.replace("/api", "") ?? "http://localhost:3000"; // Find current active conversation const [conv] = await this.db .select() .from(conversations) .where( and( eq(conversations.channel, "telegram"), eq(conversations.externalThreadId, chatId), ), ) .limit(1); if (!conv || !conv.activeMissionId) { await telegramChannel.sendMessage({ channel: "telegram", to: chatId, body: "❌ No active complaint. Send /new to start a new one.", }); return; } const [missionRow] = await this.db .select() .from(missions) .where(eq(missions.id, conv.activeMissionId)) .limit(1); if (!missionRow) { await telegramChannel.sendMessage({ channel: "telegram", to: chatId, body: "❌ Active complaint not found in system.", }); return; } const [latestSnapshot] = await this.db .select() .from(missionStatusSnapshots) .where(eq(missionStatusSnapshots.missionId, missionRow.id)) .orderBy(desc(missionStatusSnapshots.createdAt)) .limit(1); const missionUrl = `${webBaseUrl}/missions/${missionRow.id}`; let body = `â„šī¸ Active Complaint Status:\n\n`; body += `Title: ${this.escapeHtml(missionRow.title)}\n`; body += `State: ${missionRow.state}\n`; body += `ID: ${missionRow.id.substring(0, 8)}\n\n`; if (latestSnapshot) { body += `Current Step: ${this.escapeHtml(latestSnapshot.headline)}\n`; if (latestSnapshot.nextUserAction) { body += `Next User Action: ${this.escapeHtml(latestSnapshot.nextUserAction)}\n`; } } body += `\nOpen in browser ↗`; await telegramChannel.sendMessage({ channel: "telegram", to: chatId, body, }); return; } } }