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

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

+14 -2
View File
@@ -76,6 +76,8 @@ import { registerBarevRoutes } from './barev/routes.ts';
import { validSlug } from './showcase/service.ts';
import { registerSupportRoutes } from './support/routes.ts';
import { telegramFileSender, type FileSender } from './support/service.ts';
import { registerClientChatRoutes, telegramClientDownload, type ClientBot } from './clients/chat.ts';
import { HttpTelegram } from './telegram/client.ts';
import { contactOf, enqueueForSuperadmins } from './telegram/outbox.ts';
import { registerBotStartRoutes } from './telegram/start.ts';
import { enqueueText } from './telegram/outbox.ts';
@@ -118,6 +120,8 @@ export interface AppDeps {
fetchImage?: ImageFetcher;
/** Отправка в группу компании в Telegram; в тестах подменяется. */
groupSender?: GroupSender;
/** Клиентский бот для чата с клиентом из карточки; в тестах подменяется. */
clientBot?: ClientBot | null;
}
const Email = z.string().trim().toLowerCase().pipe(z.email());
@@ -128,7 +132,7 @@ const Lang_ = z.enum(LANGS);
const langOf = (code: string | undefined): Lang =>
(LANGS as readonly string[]).includes(code ?? '') ? (code as Lang) : code === 'am' ? 'hy' : 'ru';
export function buildApp({ db, config, mailer = consoleMailer, source = null, payments = null, sendFile, fetchImage, groupSender }: AppDeps) {
export function buildApp({ db, config, mailer = consoleMailer, source = null, payments = null, sendFile, fetchImage, groupSender, clientBot }: AppDeps) {
const app = Fastify({ logger: false });
void app.register(cookie);
// Файлы (вложения поддержки, фото клиентов) приходят телом запроса как есть; предел размера задаёт каждый маршрут.
@@ -866,7 +870,15 @@ export function buildApp({ db, config, mailer = consoleMailer, source = null, pa
});
registerLeadRoutes(app, db, { member, manager, writable }, config, fetchImage, source, groupSender);
registerClientRoutes(app, db, { member, manager, writable }, config);
// Клиентский бот для чата из карточки клиента: без токена чат выключен.
const chatBot: ClientBot | null =
clientBot !== undefined
? clientBot
: config.clientBotToken
? { tg: new HttpTelegram(config.clientBotToken), sendFile: telegramFileSender(config.clientBotToken), download: telegramClientDownload(config.clientBotToken) }
: null;
registerClientRoutes(app, db, { member, manager, writable }, config, chatBot);
registerClientChatRoutes(app, db, { member, manager, writable }, chatBot);
registerDepartmentRoutes(app, db, { member, manager, writable });
registerPipelineRoutes(app, db, { member, manager, writable });
registerArchiveRoutes(app, db, { member, manager, writable });
+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) };
}
+10 -2
View File
@@ -1,3 +1,4 @@
import { chatSummary, unreadMessages, type ClientBot } from './chat.ts';
import { CLIENT_KINDS, ClientRequest, CommissionRule, CURRENCIES, fitsHousehold, Household, matchesClientRequest, RENT_TERMS, type Currency, type DealType } from '@lr/shared';
import { and, desc, eq, ilike, inArray, isNull, notInArray, or, sql, type SQL } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
@@ -45,7 +46,7 @@ const PHOTO_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/heic', 'ima
const MAX_PHOTO = 5 * 1024 * 1024;
const MAX_PHOTOS = 10;
export function registerClientRoutes(app: FastifyInstance, db: Db, g: Guards, config: Config) {
export function registerClientRoutes(app: FastifyInstance, db: Db, g: Guards, config: Config, chatBot: ClientBot | null = null) {
type Ctx = ReturnType<Guards['member']>;
const actor = (ctx: Ctx): Actor => ctx;
@@ -102,6 +103,7 @@ export function registerClientRoutes(app: FastifyInstance, db: Db, g: Guards, co
assigneeName: users.name,
departmentName: departments.name,
leadsCount: sql<number>`(select count(*)::int from client_leads cl where cl.client_id = "clients"."id")`,
unreadMessages,
})
.from(clients)
.leftJoin(users, eq(users.id, clients.assigneeId))
@@ -109,7 +111,11 @@ export function registerClientRoutes(app: FastifyInstance, db: Db, g: Guards, co
.where(and(...where))
.orderBy(desc(clients.createdAt))
.limit(300);
return rows.map((r) => ({ ...present(ctx, r.client, r.assigneeName), departmentName: r.departmentName, leadsCount: r.leadsCount }));
return rows.map((r) => ({
...present(ctx, r.client, r.assigneeName), departmentName: r.departmentName, leadsCount: r.leadsCount,
// Непрочитанные сообщения клиента в чате — тем, кто видит чат (ответственному и руководителю).
unreadMessages: ctx.role === 'manager' || r.client.assigneeId === ctx.user.id ? r.unreadMessages : 0,
}));
});
app.get('/api/clients-summary', async (req) => {
@@ -194,6 +200,8 @@ export function registerClientRoutes(app: FastifyInstance, db: Db, g: Guards, co
/** Страница подборки для клиента, если ссылка создана. */
pageUrl: page?.slug ? pageUrl(config, ctx.company, page.slug, page.token) : null,
viewings: await clientViewings(db, id),
/** Чат с клиентом в клиентском боте — ответственному и руководителю. */
chat: ctx.role === 'manager' || client.assigneeId === ctx.user.id ? await chatSummary(db, id, chatBot) : null,
leads: linked,
deal: d ? { ...dealView(d.deal, names), leadId: d.deal.leadId, leadTitle: d.leadTitle } : null,
events: history.map(({ event: e, userName }) => ({
+27
View File
@@ -396,6 +396,33 @@ export const clientPhotos = pgTable(
(t) => [index('client_photos_client_idx').on(t.clientId)],
);
/**
* Чат с клиентом, оставившим анкету в клиентском боте. in — клиент написал в бот, out — ответ агента
* или руководителя из карточки клиента. Вложения хранятся у Telegram (клиентского бота): здесь только
* идентификатор файла.
*/
export const clientMessages = pgTable(
'client_messages',
{
id: serial('id').primaryKey(),
clientId: integer('client_id').notNull().references(() => clients.id, { onDelete: 'cascade' }),
companyId: integer('company_id').notNull().references(() => companies.id),
/** in | out */
direction: text('direction').notNull(),
/** Кто ответил (для out). */
authorId: integer('author_id').references(() => users.id),
text: text('text'),
fileId: text('file_id'),
/** photo | file */
fileKind: text('file_kind'),
fileName: text('file_name'),
/** Когда ответственный прочитал сообщение клиента (для in). */
readAt: ts('read_at'),
at: ts('at').notNull().defaultNow(),
},
(t) => [index('client_messages_client_idx').on(t.clientId, t.id)],
);
/** Объекты, предложенные или показанные клиенту. */
export const clientLeads = pgTable(
'client_leads',
+60 -29
View File
@@ -2,7 +2,7 @@ import { normalizePhone, RENT_TERMS, YEREVAN_DISTRICTS, type ClientRequest, type
import { and, desc, eq, isNotNull, sql } from 'drizzle-orm';
import { newToken } from '../auth/sessions.ts';
import type { Db } from '../db/client.ts';
import { clientPhotos, clients, companies, events, intakeLinks, intakeSessions, outbox, users } from '../db/schema.ts';
import { clientMessages, clientPhotos, clients, companies, events, intakeLinks, intakeSessions, outbox, users } from '../db/schema.ts';
import type { Config } from '../lib/config.ts';
import { statusByCode } from '../pipelines/service.ts';
import { REMOVE_KEYBOARD, type Keyboard, type TelegramApi, type TgUpdate } from '../telegram/client.ts';
@@ -256,23 +256,57 @@ async function complete(db: Db, s: Session, a: Answers) {
});
}
/** Сообщение клиента в бот после анкеты: текст и (или) вложение — у Telegram клиентского бота. */
export interface IncomingClientMessage {
text: string | null;
fileId?: string;
fileKind?: 'photo' | 'file';
fileName?: string | null;
}
/** Сообщения клиента подряд не подтверждаются каждый раз: «передали агенту» — не чаще раза в 30 минут. */
const ACK_GAP_MS = 30 * 60_000;
/**
* Клиент написал в бот после анкеты (например, прислал телефон по просьбе агента). Сообщение — в историю
* карточки, номер — в карточку, если его там нет; агенту и суперадминам — уведомление в основной бот.
* Клиент написал в бот после анкеты — сообщение попадает в чат карточки клиента. Номер телефона из текста
* сохраняется в карточку, если его там нет. Ответственному агенту — уведомление в основной бот с кнопкой
* «Открыть». Возвращает, нужно ли подтвердить клиенту, что сообщение передано.
*/
async function saveReply(db: Db, clientId: number, reply: string): Promise<boolean> {
export async function receiveClientMessage(db: Db, clientId: number, m: IncomingClientMessage): Promise<{ ack: boolean } | null> {
return db.transaction(async (tx) => {
const [client] = await tx.select().from(clients).where(eq(clients.id, clientId));
if (!client) return false;
const message = reply.slice(0, 1000);
const phone = message.replace(/\D/g, '').length >= 6 ? normalizePhone(message) : null;
if (!client) return null;
const text = m.text?.trim().slice(0, 3500) || null;
const [prev] = await tx
.select({ at: clientMessages.at })
.from(clientMessages)
.where(and(eq(clientMessages.clientId, clientId), eq(clientMessages.direction, 'in')))
.orderBy(desc(clientMessages.id))
.limit(1);
await tx.insert(clientMessages).values({
clientId, companyId: client.companyId, direction: 'in', text,
fileId: m.fileId ?? null, fileKind: m.fileKind ?? null, fileName: m.fileName ?? null,
});
const phone = text && text.replace(/\D/g, '').length >= 8 ? normalizePhone(text) : null;
const phoneSaved = Boolean(phone && !client.phone);
if (phoneSaved) await tx.update(clients).set({ phone }).where(eq(clients.id, clientId));
await tx.insert(events).values({ companyId: client.companyId, clientId, userId: null, type: 'comment', text: `Клиент написал в бот анкеты: ${message}` });
const admins = await tx.select({ id: users.id }).from(users).where(and(eq(users.role, 'superadmin'), isNotNull(users.telegramId), sql`${users.removedAt} is null`));
const note = [`💬 ${client.name} (клиент #${clientId}) написал(а) в бот анкеты:`, message, phoneSaved ? `📞 Телефон ${phone} сохранён в карточку.` : ''].filter(Boolean).join('\n');
await enqueueRaw(tx, [...new Set([client.assigneeId, ...admins.map((a) => a.id)])], note);
return true;
const body = [text, m.fileKind === 'photo' ? '📷' : m.fileKind === 'file' ? `📎 ${m.fileName ?? ''}`.trim() : null].filter(Boolean).join('\n');
const note = [`💬 ${client.name}:`, body, phoneSaved ? `📞 Телефон ${phone} сохранён в карточку.` : ''].filter(Boolean).join('\n');
await tx.insert(outbox).values({ kind: 'text', userId: client.assigneeId, payload: { raw: note, openPath: `/clients/${clientId}` } });
return { ack: !prev || Date.now() - prev.at.getTime() > ACK_GAP_MS };
});
}
/** Фото клиента — ещё и в фото карточки (до 10 штук, до 5 МБ). Не вышло — фото всё равно есть в чате. */
async function addCardPhoto(db: Db, clientId: number, fileId: string, download?: FileDownloader) {
const [client] = await db.select().from(clients).where(eq(clients.id, clientId));
if (!client) return;
const [{ n } = { n: 0 }] = await db.select({ n: sql<number>`count(*)::int` }).from(clientPhotos).where(eq(clientPhotos.clientId, clientId));
if (n >= 10) return;
const file = await download?.(fileId).catch(() => null);
if (!file || file.data.length > 5 * 1024 * 1024) return;
await db.insert(clientPhotos).values({
clientId, companyId: client.companyId, contentType: file.type, size: file.data.length, data: file.data.toString('base64'),
});
}
@@ -355,25 +389,22 @@ export async function handleIntakeUpdate({ db, tg, download }: IntakeDeps, updat
}
const st = intakeTexts(session.lang);
// После анкеты: фото добавляются к карточке клиента.
// После анкеты: всё, что клиент пишет, — в чат карточки клиента; фото ещё и к фото карточки.
if (session.step === 'done') {
if (update.callback_query) return tg.answerCallback(update.callback_query.id);
const photo = update.message?.photo?.at(-1)?.file_id;
const reply = update.message?.contact?.phone_number ?? text;
if (!photo && reply && session.clientId) {
return void (await tg.sendMessage(chatId, (await saveReply(db, session.clientId, reply)) ? st.replySaved : st.after));
}
if (!photo || !session.clientId) return void (await tg.sendMessage(chatId, st.after));
const [client] = await db.select().from(clients).where(eq(clients.id, session.clientId));
if (!client) return void (await tg.sendMessage(chatId, st.after));
const [{ n } = { n: 0 }] = await db.select({ n: sql<number>`count(*)::int` }).from(clientPhotos).where(eq(clientPhotos.clientId, client.id));
if (n >= 10) return void (await tg.sendMessage(chatId, st.photoLimit));
const file = await download?.(photo).catch(() => null);
if (!file || file.data.length > 5 * 1024 * 1024) return void (await tg.sendMessage(chatId, st.photoFail));
await db.insert(clientPhotos).values({
clientId: client.id, companyId: client.companyId, contentType: file.type, size: file.data.length, data: file.data.toString('base64'),
});
return void (await tg.sendMessage(chatId, st.photoSaved));
const msg = update.message;
if (!session.clientId || !msg) return void (await tg.sendMessage(chatId, st.after));
const photo = msg.photo?.at(-1)?.file_id;
const incoming: IncomingClientMessage = {
text: msg.contact?.phone_number ?? msg.text ?? msg.caption ?? null,
...(photo ? { fileId: photo, fileKind: 'photo' as const } : msg.document ? { fileId: msg.document.file_id, fileKind: 'file' as const, fileName: msg.document.file_name ?? null } : {}),
};
if (!incoming.text && !incoming.fileId) return void (await tg.sendMessage(chatId, st.after));
const saved = await receiveClientMessage(db, session.clientId, incoming);
if (!saved) return void (await tg.sendMessage(chatId, st.after));
if (photo) await addCardPhoto(db, session.clientId, photo, download);
if (saved.ack) await tg.sendMessage(chatId, esc(st.replySaved));
return;
}
const step = session.step as Step;
+3 -3
View File
@@ -22,7 +22,7 @@ const ru = {
skip: 'Пропустить',
done: (agent: string) => `Спасибо, анкета отправлена. Агент ${agent} свяжется с вами.\n\nЕсли хотите, пришлите сюда фото — себя, питомца — они добавятся к анкете.`,
photoSaved: 'Фото добавлено к анкете.', photoLimit: 'К анкете уже приложено 10 фото — больше добавить нельзя.', photoFail: 'Не получилось сохранить фото. Попробуйте другое, до 5 МБ.',
replySaved: 'Спасибо, передали агенту — он скоро свяжется с вами.',
replySaved: 'Спасибо, передали агенту — он скоро свяжется с вами.', agent: 'агент',
after: 'Анкета уже у агента — он свяжется с вами. Чтобы заполнить её заново, откройте ссылку от агента ещё раз.',
useButtons: 'Выберите вариант кнопкой под сообщением.',
langSet: 'Язык: русский. Сменить язык — команда /lang.',
@@ -51,7 +51,7 @@ const hy: Texts = {
skip: 'Բաց թողնել',
done: (agent) => `Շնորհակալություն, հարցաթերթիկն ուղարկված է։ Գործակալ ${agent}-ը կկապվի Ձեզ հետ։\n\nԵթե ցանկանում եք, ուղարկեք այստեղ լուսանկար՝ Ձեր կամ ընտանի կենդանու, այն կավելացվի հարցաթերթիկին։`,
photoSaved: 'Լուսանկարն ավելացվեց հարցաթերթիկին։', photoLimit: 'Հարցաթերթիկին արդեն կցված է 10 լուսանկար։', photoFail: 'Չհաջողվեց պահպանել լուսանկարը։ Փորձեք մեկ այլը՝ մինչև 5 ՄԲ։',
replySaved: 'Շնորհակալություն, փոխանցեցինք գործակալին — նա շուտով կկապվի Ձեզ հետ։',
replySaved: 'Շնորհակալություն, փոխանցեցինք գործակալին — նա շուտով կկապվի Ձեզ հետ։', agent: 'գործակալ',
after: 'Հարցաթերթիկն արդեն գործակալի մոտ է, նա կկապվի Ձեզ հետ։ Նորից լրացնելու համար կրկին բացեք գործակալի հղումը։',
useButtons: 'Ընտրեք տարբերակը հաղորդագրության տակի կոճակով։',
langSet: 'Լեզուն՝ հայերեն։ Լեզուն փոխելու համար՝ /lang հրամանը։',
@@ -78,7 +78,7 @@ const en: Texts = {
skip: 'Skip',
done: (agent) => `Thank you, the questionnaire has been sent. Agent ${agent} will contact you.\n\nIf you like, send a photo here — of yourself or your pet — it will be added to the questionnaire.`,
photoSaved: 'The photo was added to the questionnaire.', photoLimit: 'There are already 10 photos attached.', photoFail: 'Could not save the photo. Try another one, up to 5 MB.',
replySaved: 'Thank you, we have passed it to the agent — they will contact you soon.',
replySaved: 'Thank you, we have passed it to the agent — they will contact you soon.', agent: 'agent',
after: 'The agent already has your questionnaire and will contact you. To fill it in again, open the agent’s link once more.',
useButtons: 'Choose an option with a button under the message.',
langSet: 'Language: English. To change it, use /lang.',
+1 -1
View File
@@ -43,7 +43,7 @@ import {
type Lead,
} from './service.ts';
type MemberCtx = { user: typeof users.$inferSelect; role: Role; company: typeof companies.$inferSelect; access: AccessState };
export type MemberCtx = { user: typeof users.$inferSelect; role: Role; company: typeof companies.$inferSelect; access: AccessState };
export interface Guards {
member: (req: FastifyRequest) => MemberCtx;
+51 -10
View File
@@ -1,3 +1,4 @@
import { TelegramError } from './telegram/client.ts';
import { and, eq } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { hashPassword } from './auth/password.ts';
@@ -171,19 +172,59 @@ describe('анкета в клиентском боте', () => {
household: { who: 'couple', children: { count: 2, age: '3 и 7 лет' } },
});
// Фото после анкеты добавляется к карточке клиента.
// После анкеты всё, что пишет клиент, — в чат карточки; фото ещё и к фото карточки.
await handleIntakeUpdate({ db: t.db, tg: seekerBot.tg, config, download: async () => ({ data: Buffer.from('JPEG'), type: 'image/jpeg' }) },
fromSeeker(seeker, { message: { chat: { id: seeker }, from: { id: seeker, first_name: 'Анна' }, photo: [{ file_id: 's' }, { file_id: 'big' }] } }));
expect(last(seeker).text).toContain('Фото добавлено');
expect(await t.db.select().from(clientPhotos).where(eq(clientPhotos.clientId, client!.id))).toHaveLength(1);
// Текст после анкеты не теряется: попадает в историю карточки, агенту приходит уведомление.
await say(seeker, 'Удобно звонить после 18:00');
fromSeeker(seeker, { message: { chat: { id: seeker }, from: { id: seeker, first_name: 'Анна' }, photo: [{ file_id: 's' }, { file_id: 'big' }], caption: 'Это мы' } }));
expect(last(seeker).text).toContain('передали агенту');
const notes = await t.db.select().from(events).where(and(eq(events.clientId, client!.id), eq(events.type, 'comment')));
expect(notes.at(-1)!.text).toContain('после 18:00');
expect(await t.db.select().from(clientPhotos).where(eq(clientPhotos.clientId, client!.id))).toHaveLength(1);
// Второе сообщение подряд клиенту не подтверждается — бот не засоряет переписку.
const repliesBefore = seekerBot.inbox(seeker).length;
await say(seeker, 'Удобно звонить после 18:00');
expect(seekerBot.inbox(seeker)).toHaveLength(repliesBefore);
await handleIntakeUpdate({ db: t.db, tg: seekerBot.tg, config },
fromSeeker(seeker, { message: { chat: { id: seeker }, from: { id: seeker, first_name: 'Анна' }, document: { file_id: 'doc1', file_name: 'паспорт.pdf' } } }));
await flush();
expect(main.inbox(agent.chat).some((m) => m.text.includes('Удобно звонить после 18:00'))).toBe(true);
const note = main.inbox(agent.chat).find((m) => m.text.includes('Удобно звонить после 18:00'))!;
expect(note.text).toContain('💬 Анна:');
// Карточка: чат есть, 3 непрочитанных; список клиентов показывает счётчик.
const chatCard = (await agent.api.get(`/api/clients/${client!.id}`)).body;
expect(chatCard.chat).toEqual({ available: true, unread: 3, hasMessages: true });
expect(((await agent.api.get('/api/clients')).body as { id: number; unreadMessages: number }[]).find((x) => x.id === client!.id)!.unreadMessages).toBe(3);
const chat = (await agent.api.get(`/api/clients/${client!.id}/messages`)).body;
expect(chat.available).toBe(true);
expect(chat.messages.map((m: { direction: string; text: string | null; fileKind: string | null; fileName: string | null }) => [m.direction, m.text, m.fileKind, m.fileName])).toEqual([
['in', 'Это мы', 'photo', null],
['in', 'Удобно звонить после 18:00', null, null],
['in', null, 'file', 'паспорт.pdf'],
]);
// Открыл ответственный — прочитано.
expect((await agent.api.get(`/api/clients/${client!.id}`)).body.chat.unread).toBe(0);
// Вложение клиента открывается из CRM.
const docId = chat.messages[2].id;
const file = await agent.api.get(`/api/clients/${client!.id}/messages/${docId}/file`);
expect(file.status).toBe(200);
expect(file.headers['content-disposition']).toContain(encodeURIComponent('паспорт.pdf'));
// Ответ агента уходит клиенту в клиентский бот с именем агента.
expect((await agent.api.post(`/api/clients/${client!.id}/messages`, { text: 'Позвоню в 18:30' })).status).toBe(200);
const answer = t.clientBotTg.inbox(seeker).at(-1)!;
expect(answer.text).toBe('<b>Арам, агент:</b>\nПозвоню в 18:30');
// Файл — тоже, с подписью.
expect((await agent.api.post(`/api/clients/${client!.id}/messages/file?name=plan.pdf&type=application/pdf&text=${encodeURIComponent('План квартиры')}`, Buffer.from('%PDF'))).status).toBe(200);
expect(t.clientBotFiles.at(-1)).toMatchObject({ chatId: seeker, name: 'plan.pdf', caption: '<b>Арам, агент:</b>\nПлан квартиры' });
const after = (await agent.api.get(`/api/clients/${client!.id}/messages`)).body.messages;
expect(after.slice(-2).map((m: { direction: string; authorName: string; fileName: string | null }) => [m.direction, m.authorName, m.fileName])).toEqual([
['out', 'Арам', null],
['out', 'Арам', 'plan.pdf'],
]);
// Клиент заблокировал бота — понятная ошибка.
t.clientBotTg.failOnce(new TelegramError(403, 'Forbidden: bot was blocked by the user'));
expect((await agent.api.post(`/api/clients/${client!.id}/messages`, { text: 'Алло?' })).body.code).toBe('CLIENT_BLOCKED_BOT');
// Клиент заведён вручную, анкеты не было — писать некуда.
const manual = (await agent.api.post('/api/clients', { name: 'Без бота', kind: 'buyer', request: {} })).body.id;
expect((await agent.api.get(`/api/clients/${manual}`)).body.chat).toEqual({ available: false, unread: 0, hasMessages: false });
expect((await agent.api.post(`/api/clients/${manual}/messages`, { text: 'Здравствуйте' })).body.code).toBe('NO_CLIENT_CHAT');
// Автору ссылки в основной бот: сводка и вопрос с кнопками.
await flush();
+8 -4
View File
@@ -79,10 +79,13 @@ export interface OutboxDeps {
sleep?: (ms: number) => Promise<void>;
}
const openButton = (config: Config, lang: string, leadId: number): Keyboard[number] => {
const openButton = (config: Config, lang: string, leadId: number): Keyboard[number] => openPathButton(config, lang, `/leads/${leadId}`);
/** Кнопка «Открыть» страницу CRM в Mini App. */
const openPathButton = (config: Config, lang: string, path: string): Keyboard[number] => {
// Telegram принимает в кнопках только https-адреса; на локальном стенде кнопки «Открыть» нет.
if (!config.appUrl.startsWith('https://')) return [];
return [{ text: texts(lang).open, webApp: `${config.appUrl}/leads/${leadId}` }];
return [{ text: texts(lang).open, webApp: `${config.appUrl}${path}` }];
};
/** Отправляет одну пачку сообщений из очереди. Возвращает число обработанных. */
@@ -117,7 +120,7 @@ export async function processOutbox({ db, tg, config, sleep }: OutboxDeps): Prom
const t = texts(user.lang);
if (job.kind === 'text') {
const p = job.payload as { raw?: string; key?: string; args?: unknown[]; buttons?: { key: string; callback: string }[] };
const p = job.payload as { raw?: string; key?: string; args?: unknown[]; buttons?: { key: string; callback: string }[]; openPath?: string };
// Кнопки под сообщением: подпись берётся из текстов бота на языке получателя.
const buttons: Keyboard | undefined = p.buttons?.length
? [p.buttons.map((b) => ({ text: String((t as unknown as Record<string, unknown>)[b.key] ?? b.key), callback: b.callback }))]
@@ -129,7 +132,8 @@ export async function processOutbox({ db, tg, config, sleep }: OutboxDeps): Prom
: typeof fn === 'function'
? (fn as (...a: unknown[]) => string)(...(p.args ?? []).map((a) => (typeof a === 'string' ? esc(a) : a)))
: String(fn ?? '');
if (text) await tg.sendMessage(chatId, text, buttons ?? (job.leadId ? [openButton(config, user.lang, job.leadId)].filter((r) => r.length) : undefined));
const open = p.openPath ? openPathButton(config, user.lang, p.openPath) : job.leadId ? openButton(config, user.lang, job.leadId) : [];
if (text) await tg.sendMessage(chatId, text, buttons ?? (open.length ? [open] : undefined));
await finish();
} else if (job.kind === 'lead') {
const [row] = await db
+18 -4
View File
@@ -55,18 +55,32 @@ export async function testApp(name = 'app', source: PagedSource | null = null, p
album: async (bot, photos) => void groupSent.push({ kind: 'album', chatId: bot.chatId, threadId: bot.threadId, photos: photos.length }),
text: async (bot, text) => void groupSent.push({ kind: 'text', chatId: bot.chatId, threadId: bot.threadId, text }),
};
const app = buildApp({ db, config, source, payments, sendFile, groupSender, fetchImage: async () => testImage, mailer: { send: async (m) => void mails.push(m) } });
/** Клиентский бот для чата из карточки клиента: что ушло клиенту. */
const clientBotTg = fakeTelegram();
const clientBotFiles: { chatId: number; name: string; type: string; caption: string }[] = [];
const clientBot = {
tg: clientBotTg.tg,
sendFile: (async (chatId, file, caption) => {
clientBotFiles.push({ chatId, name: file.name, type: file.type, caption });
return { fileId: `client-file-${clientBotFiles.length}`, kind: file.type.startsWith('image/') ? 'photo' : 'file' };
}) as FileSender,
download: async (fileId: string) => ({ data: Buffer.from(`file:${fileId}`), type: 'application/pdf', path: `documents/${fileId}.pdf` }),
};
const app = buildApp({ db, config, source, payments, sendFile, groupSender, clientBot, fetchImage: async () => testImage, mailer: { send: async (m) => void mails.push(m) } });
/** Клиент с собственной сессией. */
const client = (token?: string, host?: string) => {
const call = async (method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE', path: string, body?: unknown) => {
const raw = Buffer.isBuffer(body);
const res = await app.inject({
method,
url: path,
payload: body as object | undefined,
headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(host ? { host } : {}) },
headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(host ? { host } : {}), ...(raw ? { 'content-type': 'application/octet-stream' } : {}) },
});
return { status: res.statusCode, body: res.json() as any };
let parsed: any;
try { parsed = res.json(); } catch { parsed = res.rawPayload; }
return { status: res.statusCode, body: parsed, headers: res.headers };
};
return {
get: (p: string) => call('GET', p),
@@ -87,7 +101,7 @@ export async function testApp(name = 'app', source: PagedSource | null = null, p
return client(res.body.token as string);
};
return { app, db, config, mails, sentFiles, groupSent, client, register, close: async () => { await app.close(); await close(); } };
return { app, db, config, mails, sentFiles, groupSent, clientBotTg, clientBotFiles, client, register, close: async () => { await app.close(); await close(); } };
}
export interface FakeMessage extends SentMessage {