Backoffice stage 2: chats (support + concierge, files)
- ChatThread per client/provider and channel; messages get files, internal notes, source (bot/mini app) and delivery errors; reply templates. Migration moves old concierge messages into threads. - Routing: subscribers write to the concierge, others to support; /support and /concierge in the client bot; staff can take, transfer, close and move threads. - Telegram: photos/documents from clients and providers, replies with the channel header, files sent from storage, notifications only to the assignee or the channel's staff, "Reply" into a specific thread. - New admin: /chats with list, conversation, notes, files, templates, client panel; "Message" button on the client card; dashboard counts threads awaiting reply. - Client mini app: in-app chat screen sharing the same conversation. - Old admin keeps working on the same data (notes hidden there). - Tests for routing, notes, assign/close/move, lists, recipients; separate test DB via TEST_DB_NAME; dev:chats-demo script. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d452824c62
commit
23ffb2bf1d
46 files changed
+4176
-144
No files matched your search
+50
-11
@@ -4,7 +4,8 @@
|
||||
*/
|
||||
import { getDb } from '@barev/db';
|
||||
import { createT, formatAmd, formatDateTime, type Locale } from '@barev/i18n';
|
||||
import { dueReminders, EVENTS_QUEUE, expireOffers, type Ctx, type DomainEvent } from '@barev/services';
|
||||
import { chatRecipients, dueReminders, EVENTS_QUEUE, expireOffers, type Ctx, type DomainEvent } from '@barev/services';
|
||||
import { getFile } from '@barev/services/storage';
|
||||
import { loadRootEnv } from '@barev/services/env';
|
||||
import { createQueueNotifier, redisConnection } from '@barev/services/queue';
|
||||
import { compactRows, escapeHtml, TelegramSender, webAppButton, type InlineButton } from '@barev/services/telegram';
|
||||
@@ -30,6 +31,12 @@ async function toAdmins(text: string, buttons: InlineButton[][] = []) {
|
||||
const adminT = createT('ru');
|
||||
/** Ссылка на раздел админки — Telegram принимает url-кнопки только с https */
|
||||
const adminButton = (path: string): InlineButton | null => (ADMIN_URL.startsWith('https://') ? { text: adminT('bot.admin.open'), url: `${ADMIN_URL}/#${path}` } : null);
|
||||
/** Новая админка, если уже на сервере; иначе — раздел переписки в старой */
|
||||
const BACKOFFICE_URL = process.env.BACKOFFICE_URL ?? '';
|
||||
const chatButton = (threadId: string | null, peer: string): InlineButton | null =>
|
||||
BACKOFFICE_URL.startsWith('https://') && threadId
|
||||
? { text: adminT('bot.admin.open'), url: `${BACKOFFICE_URL}/#/chats/${threadId}` }
|
||||
: adminButton(`/concierge?${peer.startsWith('c:') ? 'client' : 'provider'}=${peer.slice(2)}`);
|
||||
const who = (u: { firstName: string | null; lastName?: string | null; username: string | null }) =>
|
||||
`${escapeHtml(`${u.firstName ?? ''} ${u.lastName ?? ''}`.trim())}${u.username ? ` (@${escapeHtml(u.username)})` : ''}`;
|
||||
|
||||
@@ -204,23 +211,55 @@ async function handle(event: DomainEvent) {
|
||||
return;
|
||||
}
|
||||
case 'concierge.reply': {
|
||||
const msg = await db.conciergeMessage.findUnique({ where: { id: event.messageId }, include: { client: { include: { user: true } }, provider: { include: { user: true } } } });
|
||||
if (!msg) return;
|
||||
if (msg.client) {
|
||||
const t = createT(msg.client.user.locale as Locale);
|
||||
await clientBot.send({ chatId: msg.client.user.telegramId, text: `<b>${t('notify.concierge')}</b>\n${escapeHtml(msg.text)}` });
|
||||
} else if (msg.provider?.user) {
|
||||
const t = createT(msg.provider.user.locale as Locale);
|
||||
await proBot.send({ chatId: msg.provider.user.telegramId, text: `<b>${t('notify.operator')}</b>\n${escapeHtml(msg.text)}` });
|
||||
const msg = await db.conciergeMessage.findUnique({
|
||||
where: { id: event.messageId },
|
||||
include: { client: { include: { user: true } }, provider: { include: { user: true } }, thread: { select: { channel: true } } },
|
||||
});
|
||||
if (!msg || msg.internal) return;
|
||||
const to = msg.client ? { bot: clientBot, user: msg.client.user } : msg.provider?.user ? { bot: proBot, user: msg.provider.user } : null;
|
||||
if (!to) return;
|
||||
const t = createT(to.user.locale as Locale);
|
||||
// клиенту — от кого ответ: консьерж или поддержка; исполнителю — оператор
|
||||
const head = msg.client ? t(msg.thread?.channel === 'CONCIERGE' ? 'notify.concierge' : 'notify.support') : t('notify.operator');
|
||||
const text = `<b>${head}</b>${msg.text ? `\n${escapeHtml(msg.text)}` : ''}`;
|
||||
let delivered: boolean;
|
||||
if (msg.fileKey) {
|
||||
const body = await getFile(msg.fileKey);
|
||||
delivered = await to.bot.sendFile({ chatId: to.user.telegramId, caption: text, body, fileName: msg.fileName ?? 'file', mime: msg.mime ?? 'application/octet-stream' });
|
||||
} else {
|
||||
delivered = await to.bot.send({ chatId: to.user.telegramId, text });
|
||||
}
|
||||
if (!delivered) await db.conciergeMessage.update({ where: { id: msg.id }, data: { deliveryError: 'UNREACHABLE' } });
|
||||
return;
|
||||
}
|
||||
case 'concierge.message': {
|
||||
const msg = await db.conciergeMessage.findUnique({ where: { id: event.messageId }, include: { client: { include: { user: true } }, provider: { include: { user: true } } } });
|
||||
const msg = await db.conciergeMessage.findUnique({ where: { id: event.messageId }, include: { client: { include: { user: true } }, provider: { include: { user: true } }, thread: true } });
|
||||
if (!msg) return;
|
||||
const from = msg.client ? who(msg.client.user) : `${escapeHtml(msg.provider?.name ?? '')} · ${adminT('bot.admin.provider')}`;
|
||||
const peer = msg.clientId ? `c:${msg.clientId}` : `p:${msg.providerId}`;
|
||||
await toAdmins(`💬 <b>${from}</b>\n${escapeHtml(msg.text)}`, compactRows([[{ text: adminT('bot.admin.reply'), callback_data: `ar:${peer}` }, adminButton(`/concierge?${msg.clientId ? 'client' : 'provider'}=${msg.clientId ?? msg.providerId}`)]]));
|
||||
const channel = msg.thread ? `${adminT(msg.thread.channel === 'CONCIERGE' ? 'bot.admin.channelConcierge' : 'bot.admin.channelSupport')} · ` : '';
|
||||
const body = msg.text ? escapeHtml(msg.text) : '';
|
||||
const file = msg.tgFileId || msg.fileKey ? `📎 ${escapeHtml(msg.fileName ?? adminT('bot.admin.file'))}` : '';
|
||||
const text = `💬 ${channel}<b>${from}</b>\n${[file, body].filter(Boolean).join('\n')}`;
|
||||
const buttons = compactRows([[{ text: adminT('bot.admin.reply'), callback_data: msg.threadId ? `ar:t:${msg.threadId}` : `ar:${peer}` }, chatButton(msg.threadId, peer)]]);
|
||||
const recipients = msg.thread ? await chatRecipients(ctx, msg.thread) : await db.adminUser.findMany({ where: { active: true, tgNotify: true, telegramId: { not: null } } });
|
||||
for (const a of recipients) await proBot.send({ chatId: a.telegramId!, text, buttons });
|
||||
return;
|
||||
}
|
||||
case 'chat.assigned': {
|
||||
const [thread, assignee, by] = await Promise.all([
|
||||
db.chatThread.findUnique({ where: { id: event.threadId }, include: { client: { include: { user: true } }, provider: true } }),
|
||||
db.adminUser.findUnique({ where: { id: event.assigneeId } }),
|
||||
db.adminUser.findUnique({ where: { id: event.byAdminId }, select: { name: true } }),
|
||||
]);
|
||||
if (!thread || !assignee?.telegramId || !assignee.tgNotify) return;
|
||||
const name = thread.client ? who(thread.client.user) : escapeHtml(thread.provider?.name ?? '');
|
||||
const peer = thread.clientId ? `c:${thread.clientId}` : `p:${thread.providerId}`;
|
||||
await proBot.send({
|
||||
chatId: assignee.telegramId,
|
||||
text: `👋 ${adminT('bot.admin.chatAssigned', { by: escapeHtml(by?.name ?? ''), name })}`,
|
||||
buttons: compactRows([[{ text: adminT('bot.admin.reply'), callback_data: `ar:t:${thread.id}` }, chatButton(thread.id, peer)]]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'lead.submitted': {
|
||||
|
||||
Reference in new issue
Block a user