Чат с клиентом из карточки через бот анкеты

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-26 03:17:12 +04:00
1 parent 7fdabd07a4
commit dba03b8970
19 files changed
+5145 -62

No files matched your search

+177
View File
@@ -0,0 +1,177 @@
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<number>`(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: `<b>${esc(ctx.user.name)}, ${esc(intakeTexts(chat.lang).agent)}:</b>` };
};
/** Клиент заблокировал бота — понятная ошибка вместо «что-то пошло не так». */
const delivered = async <T>(send: () => Promise<T>): Promise<T> => {
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<number>`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) };
}