Чат с клиентом из карточки через бот анкеты
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
7fdabd07a4
commit
dba03b8970
19 files changed
+5145
-62
No files matched your search
@@ -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) };
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user