import path from 'node:path'; import { Router, type Request, type Response, type NextFunction } from 'express'; import { z } from 'zod'; import { config } from '../../config.js'; import { badRequest } from '../../errors.js'; import { requireAgentUiAuth } from '../middleware/auth.js'; import { TranslationClient, normaliseLanguage, translationHash } from '../../clients/translationClient.js'; import { ChatwootClient, type ChatwootMessage } from '../../clients/chatwootClient.js'; import { db } from '../../store/db.js'; import { audit } from '../../store/auditLog.js'; export const agentToolsRouter = Router(); const ASSET_PATH = path.resolve(import.meta.dirname, '../../../public/chatwoot-ai-tools.js'); const translateSchema = z.object({ conversationId: z.coerce.number().int().positive().optional(), messageId: z.union([z.string(), z.number()]).optional(), targetLanguage: z.string().min(2).max(16).default('pl'), sourceLanguage: z.string().min(2).max(16).optional(), sourceText: z.string().min(1).max(20_000).optional(), draftText: z.string().min(1).max(20_000).optional(), }); agentToolsRouter.get('/chatwoot-ai-tools.js', (_req, res, next) => { res.setHeader('Cache-Control', 'public, max-age=60'); res.setHeader('Content-Type', 'application/javascript; charset=utf-8'); res.sendFile(ASSET_PATH, (err) => { if (err) next(err); }); }); agentToolsRouter.options('/agent-tools/{*path}', corsForAgentTools, (_req, res) => res.status(204).end()); agentToolsRouter.use('/agent-tools', corsForAgentTools, requireAgentUiAuth); agentToolsRouter.post('/agent-tools/translate-message', async (req, res, next) => { try { const body = translateSchema.parse(req.body ?? {}); const sourceText = body.sourceText?.trim(); if (!sourceText) throw badRequest('MISSING_TEXT', 'sourceText is required.'); const result = await translateWithCache({ text: sourceText, targetLanguage: body.targetLanguage, sourceLanguage: body.sourceLanguage, conversationId: body.conversationId, messageId: body.messageId === undefined ? undefined : String(body.messageId), mode: 'message', }); res.json({ ok: true, ...result }); } catch (err) { next(err); } }); agentToolsRouter.post('/agent-tools/translate-draft', async (req, res, next) => { try { const body = translateSchema.parse(req.body ?? {}); const draftText = body.draftText?.trim(); if (!draftText) throw badRequest('MISSING_TEXT', 'draftText is required.'); const targetLanguage = normaliseLanguage(body.targetLanguage); const firstCustomerMessage = body.conversationId ? await firstIncomingCustomerMessage(body.conversationId) : null; if (targetLanguage === 'auto' && !firstCustomerMessage) { throw badRequest( 'MISSING_CUSTOMER_LANGUAGE_SAMPLE', 'conversationId with at least one incoming customer message is required for auto target language.', ); } const result = await translateWithCache({ text: draftText, targetLanguage, sourceLanguage: body.sourceLanguage ?? 'pl', conversationId: body.conversationId, messageId: body.messageId === undefined ? undefined : String(body.messageId), mode: 'draft', firstCustomerMessage, }); res.json({ ok: true, ...result }); } catch (err) { next(err); } }); async function translateWithCache(input: { text: string; targetLanguage: string; sourceLanguage?: string; conversationId?: number; messageId?: string; mode: 'message' | 'draft'; firstCustomerMessage?: string | null; }): Promise<{ translatedText: string; sourceLanguage: string | null; targetLanguage: string; provider: string; cached: boolean; }> { const targetLanguage = normaliseLanguage(input.targetLanguage); const sourceHash = translationHash( input.mode === 'draft' && input.firstCustomerMessage ? `${input.text}\n\n[FIRST_CUSTOMER_MESSAGE]\n${input.firstCustomerMessage}` : input.text, ); const cached = await db().translationCache.findUnique({ where: { sourceHash_targetLanguage: { sourceHash, targetLanguage } }, }); if (cached) { return { translatedText: cached.translatedText, sourceLanguage: cached.sourceLanguage, targetLanguage: cached.targetLanguage, provider: cached.provider, cached: true, }; } const translated = await new TranslationClient().translate({ text: input.text, targetLanguage, sourceLanguage: input.sourceLanguage, context: { conversationId: input.conversationId ?? null, messageId: input.messageId ?? null, mode: input.mode, firstCustomerMessage: input.firstCustomerMessage ?? null, }, }); await db().translationCache.create({ data: { conversationId: input.conversationId ?? null, messageId: input.messageId ?? null, sourceHash, sourceLanguage: translated.sourceLanguage, targetLanguage: translated.targetLanguage, translatedText: translated.translatedText, provider: translated.provider, }, }); await audit({ conversationId: input.conversationId, messageId: input.messageId, eventType: 'agent_translation', summary: `Agent UI translated ${input.mode} to ${translated.targetLanguage}`, meta: { mode: input.mode, provider: translated.provider }, }); return { ...translated, cached: false }; } async function firstIncomingCustomerMessage(conversationId: number): Promise { const messages = await new ChatwootClient().getConversationMessages(conversationId); const incoming = messages .filter(isPublicIncoming) .sort((a, b) => timestamp(a) - timestamp(b)) .find((m) => typeof m.content === 'string' && m.content.trim().length > 0); return incoming?.content?.trim().slice(0, 5_000) ?? null; } function isPublicIncoming(message: ChatwootMessage): boolean { return message.private !== true && message.message_type === 'incoming'; } function timestamp(message: ChatwootMessage): number { if (typeof message.created_at === 'number') return message.created_at; if (typeof message.created_at === 'string') { const parsed = Date.parse(message.created_at); return Number.isFinite(parsed) ? parsed : 0; } return 0; } function corsForAgentTools(req: Request, res: Response, next: NextFunction): void { const origin = req.header('origin') ?? ''; const allowed = allowedOrigins(); if (origin && allowed.has(origin)) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Vary', 'Origin'); res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type'); res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); } next(); } function allowedOrigins(): Set { const cfg = config(); const entries = [cfg.CHATWOOT_BASE_URL, ...cfg.AGENT_UI_ALLOWED_ORIGINS.split(',')] .map((s) => s.trim().replace(/\/+$/, '')) .filter(Boolean); return new Set(entries); }