import { and, asc, desc, eq, isNull, sql } from 'drizzle-orm'; import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import type { Db } from '../db/client.ts'; import { clientMessages, intakeSessions, users } from '../db/schema.ts'; import { intakeTexts } from '../intake/texts.ts'; import type { Guards, MemberCtx } from '../leads/routes.ts'; import { badRequest, conflict, forbidden } from '../lib/errors.ts'; import type { FileSender } from '../support/service.ts'; import { TelegramError, type TelegramApi } from '../telegram/client.ts'; import { esc } from '../telegram/texts.ts'; import { loadClient } from './service.ts'; // Чат с клиентом, оставившим анкету в клиентском боте. Пишут ответственный агент и руководитель — из // карточки клиента в CRM; клиент получает ответ в клиентском боте с именем агента. Сообщения клиента // приходят в intake/service.ts (receiveClientMessage). Вложения хранятся у Telegram клиентского бота. /** Клиентский бот: отправка текста и файлов, скачивание вложений по их идентификатору. */ export interface ClientBot { tg: TelegramApi; sendFile: FileSender; /** Файл по идентификатору Telegram; null — не нашёлся. */ download: (fileId: string) => Promise<{ data: Buffer; type: string | null; path: string } | null>; } /** Скачивание вложения клиентского бота через Bot API (токен наружу не уходит). */ export const telegramClientDownload = (token: string): ClientBot['download'] => async (fileId) => { const base = 'https://api.telegram.org'; const info = (await (await fetch(`${base}/bot${token}/getFile?file_id=${encodeURIComponent(fileId)}`)).json()) as { ok: boolean; result?: { file_path?: string }; }; if (!info.ok || !info.result?.file_path) return null; const file = await fetch(`${base}/file/bot${token}/${info.result.file_path}`); if (!file.ok) return null; return { data: Buffer.from(await file.arrayBuffer()), type: file.headers.get('content-type'), path: info.result.file_path }; }; const Id = z.object({ id: z.coerce.number().int().positive() }); const MAX_FILE = 20 * 1024 * 1024; /** Чат клиента в клиентском боте: последняя анкета, привязанная к карточке. */ export async function clientChat(db: Db, clientId: number) { const [s] = await db .select({ chatId: intakeSessions.chatId, lang: intakeSessions.lang }) .from(intakeSessions) .where(eq(intakeSessions.clientId, clientId)) .orderBy(desc(intakeSessions.id)) .limit(1); return s ?? null; } /** Сколько сообщений клиента ещё не прочитано ответственным. */ export const unreadMessages = sql`(select count(*)::int from client_messages m where m.client_id = "clients"."id" and m.direction = 'in' and m.read_at is null)`; export function registerClientChatRoutes(app: FastifyInstance, db: Db, g: Guards, bot: ClientBot | null) { /** Читать и писать в чат могут ответственный агент и руководитель — как и видеть контакты клиента. */ const own = async (ctx: MemberCtx, id: number) => { const client = await loadClient(db, ctx, id); if (ctx.role !== 'manager' && client.assigneeId !== ctx.user.id) throw forbidden('NOT_YOUR_CLIENT'); return client; }; /** Куда отправлять: чат клиента и подпись «Имя, агент». */ const target = async (ctx: MemberCtx, clientId: number) => { if (!bot) throw badRequest('CLIENT_BOT_DISABLED'); const chat = await clientChat(db, clientId); if (!chat) throw conflict('NO_CLIENT_CHAT'); return { chat, signature: `${esc(ctx.user.name)}, ${esc(intakeTexts(chat.lang).agent)}:` }; }; /** Клиент заблокировал бота — понятная ошибка вместо «что-то пошло не так». */ const delivered = async (send: () => Promise): Promise => { try { return await send(); } catch (error) { if (error instanceof TelegramError && error.code === 403) throw conflict('CLIENT_BLOCKED_BOT'); if (error instanceof TelegramError && error.code === 400) throw conflict('CLIENT_CHAT_UNAVAILABLE'); throw error; } }; app.get('/api/clients/:id/messages', async (req) => { const ctx = g.member(req); const { id } = Id.parse(req.params); const client = await own(ctx, id); // Сообщения клиента прочитаны, когда чат открыл ответственный (руководитель, заглянув, их не «съедает»). if (client.assigneeId === ctx.user.id) { await db .update(clientMessages) .set({ readAt: new Date() }) .where(and(eq(clientMessages.clientId, id), eq(clientMessages.direction, 'in'), isNull(clientMessages.readAt))); } const messages = await db .select({ id: clientMessages.id, direction: clientMessages.direction, text: clientMessages.text, fileKind: clientMessages.fileKind, fileName: clientMessages.fileName, authorName: users.name, at: clientMessages.at, }) .from(clientMessages) .leftJoin(users, eq(users.id, clientMessages.authorId)) .where(eq(clientMessages.clientId, id)) .orderBy(asc(clientMessages.id)) .limit(500); return { available: Boolean(bot && (await clientChat(db, id))), messages }; }); app.post('/api/clients/:id/messages', async (req) => { const ctx = g.writable(g.member(req)); const { id } = Id.parse(req.params); const { text } = z.object({ text: z.string().trim().min(1).max(3500) }).parse(req.body); const client = await own(ctx, id); const { chat, signature } = await target(ctx, id); await delivered(() => bot!.tg.sendMessage(chat.chatId, `${signature}\n${esc(text)}`)); const [m] = await db.insert(clientMessages).values({ clientId: id, companyId: client.companyId, direction: 'out', authorId: ctx.user.id, text }).returning(); return { id: m!.id }; }); /** Файл или фото: имя, тип и подпись — в адресе, сам файл — в теле запроса. */ app.post('/api/clients/:id/messages/file', { bodyLimit: MAX_FILE }, async (req) => { const ctx = g.writable(g.member(req)); const { id } = Id.parse(req.params); const q = z .object({ name: z.string().trim().min(1).max(200), type: z.string().trim().max(100).default('application/octet-stream'), text: z.string().trim().max(900).optional(), }) .parse(req.query); if (!Buffer.isBuffer(req.body) || req.body.length === 0) throw badRequest('VALIDATION'); const client = await own(ctx, id); const { chat, signature } = await target(ctx, id); const caption = [signature, q.text ? esc(q.text) : ''].filter(Boolean).join('\n'); const sent = await delivered(() => bot!.sendFile(chat.chatId, { name: q.name, type: q.type, data: req.body as Buffer }, caption)); const [m] = await db .insert(clientMessages) .values({ clientId: id, companyId: client.companyId, direction: 'out', authorId: ctx.user.id, text: q.text || null, fileId: sent.fileId, fileKind: sent.kind, fileName: q.name, }) .returning(); return { id: m!.id }; }); /** Вложение из чата: файл лежит у Telegram клиентского бота. */ app.get('/api/clients/:id/messages/:messageId/file', async (req, reply) => { const ctx = g.member(req); const { id, messageId } = z.object({ id: z.coerce.number().int().positive(), messageId: z.coerce.number().int().positive() }).parse(req.params); await own(ctx, id); const [m] = await db.select().from(clientMessages).where(and(eq(clientMessages.id, messageId), eq(clientMessages.clientId, id))); if (!m?.fileId || !bot) throw badRequest('FILE_NOT_FOUND'); const file = await bot.download(m.fileId); if (!file) throw badRequest('FILE_NOT_FOUND'); const name = m.fileName ?? file.path.split('/').pop() ?? 'file'; const type = m.fileKind === 'photo' ? 'image/jpeg' : (file.type ?? 'application/octet-stream'); return reply .header('content-type', type) .header('cache-control', 'private, max-age=3600') .header('content-disposition', `${m.fileKind === 'photo' ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(name)}`) .send(file.data); }); } /** Для карточки: есть ли чат и сколько непрочитанных. */ export async function chatSummary(db: Db, clientId: number, bot: ClientBot | null) { const [row] = await db .select({ n: sql`count(*)::int` }) .from(clientMessages) .where(and(eq(clientMessages.clientId, clientId), eq(clientMessages.direction, 'in'), isNull(clientMessages.readAt))); const [any] = await db.select({ id: clientMessages.id }).from(clientMessages).where(eq(clientMessages.clientId, clientId)).limit(1); return { available: Boolean(bot && (await clientChat(db, clientId))), unread: row?.n ?? 0, hasMessages: Boolean(any) }; }