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
+52
-13
@@ -4,7 +4,21 @@
|
||||
*/
|
||||
import { getDb } from '@barev/db';
|
||||
import { createT, formatDateTime, type Locale } from '@barev/i18n';
|
||||
import { acceptPartnerInvite, addProviderMessage, checkCard, DomainError, linkByInvite, proIdentity, redeemOffer, replyTo, respondToOffer, type Ctx, type Peer } from '@barev/services';
|
||||
import {
|
||||
acceptPartnerInvite,
|
||||
addProviderMessage,
|
||||
checkCard,
|
||||
DomainError,
|
||||
linkByInvite,
|
||||
peerName,
|
||||
proIdentity,
|
||||
redeemOffer,
|
||||
replyTo,
|
||||
respondToOffer,
|
||||
staffSend,
|
||||
type Ctx,
|
||||
type Peer,
|
||||
} from '@barev/services';
|
||||
import { loadRootEnv } from '@barev/services/env';
|
||||
import { createQueueNotifier } from '@barev/services/queue';
|
||||
import { Bot, InlineKeyboard, type Context } from 'grammy';
|
||||
@@ -53,7 +67,7 @@ const adminOf = (c: Context) => (c.from ? db.adminUser.findFirst({ where: { tele
|
||||
const adminT = createT('ru');
|
||||
|
||||
/** Оператор нажал «Ответить» — следующий его текст уйдёт этому клиенту или исполнителю */
|
||||
const replyingTo = new Map<number, Peer & { name: string }>();
|
||||
const replyingTo = new Map<number, { threadId?: string; peer?: Peer; name: string }>();
|
||||
|
||||
const LEVEL_NAME: Record<string, string> = { BAREV: 'Barev', BAREV_PLUS: 'Barev+', ELITE: 'Barev Elite' };
|
||||
type Check = Awaited<ReturnType<typeof checkCard>>;
|
||||
@@ -198,19 +212,31 @@ bot.callbackQuery(/^rd:(\w+):(\w+)$/, async (c) => {
|
||||
});
|
||||
|
||||
/** «Ответить» под уведомлением о сообщении */
|
||||
bot.callbackQuery(/^ar:(c|p):(\w+)$/, async (c) => {
|
||||
bot.callbackQuery(/^ar:(c|p|t):(\w+)$/, async (c) => {
|
||||
const admin = await adminOf(c);
|
||||
if (!admin) {
|
||||
await c.answerCallbackQuery({ text: adminT('bot.admin.notAdmin'), show_alert: true });
|
||||
return;
|
||||
}
|
||||
const [, kind, id] = c.match;
|
||||
const peer: Peer = kind === 'c' ? { clientId: id! } : { providerId: id! };
|
||||
const name =
|
||||
kind === 'c'
|
||||
? ((await db.client.findUnique({ where: { id: id! }, include: { user: true } }))?.user.firstName ?? '')
|
||||
: ((await db.provider.findUnique({ where: { id: id! } }))?.name ?? '');
|
||||
replyingTo.set(c.chat!.id, { ...peer, name });
|
||||
let name = '';
|
||||
if (kind === 't') {
|
||||
// ответ в конкретный диалог (поддержка или консьерж)
|
||||
const thread = await db.chatThread.findUnique({ where: { id: id! }, include: { client: { include: { user: true } }, provider: true } });
|
||||
if (!thread) {
|
||||
await c.answerCallbackQuery({ text: adminT('errors.NOT_FOUND'), show_alert: true });
|
||||
return;
|
||||
}
|
||||
name = peerName(thread);
|
||||
replyingTo.set(c.chat!.id, { threadId: thread.id, name });
|
||||
} else {
|
||||
const peer: Peer = kind === 'c' ? { clientId: id! } : { providerId: id! };
|
||||
name =
|
||||
kind === 'c'
|
||||
? ((await db.client.findUnique({ where: { id: id! }, include: { user: true } }))?.user.firstName ?? '')
|
||||
: ((await db.provider.findUnique({ where: { id: id! } }))?.name ?? '');
|
||||
replyingTo.set(c.chat!.id, { peer, name });
|
||||
}
|
||||
await c.answerCallbackQuery();
|
||||
await c.reply(adminT('bot.admin.replyPrompt', { name }), { reply_markup: { force_reply: true, input_field_placeholder: adminT('bot.admin.replyPlaceholder') } });
|
||||
});
|
||||
@@ -226,7 +252,8 @@ bot.on('message:text', async (c) => {
|
||||
const admin = await adminOf(c);
|
||||
if (target && admin) {
|
||||
try {
|
||||
await replyTo(ctx, { ...(target.clientId ? { clientId: target.clientId } : { providerId: target.providerId! }), adminUserId: admin.id, text: c.message.text });
|
||||
if (target.threadId) await staffSend(ctx, { threadId: target.threadId, adminUserId: admin.id, text: c.message.text });
|
||||
else await replyTo(ctx, { ...target.peer!, adminUserId: admin.id, text: c.message.text });
|
||||
replyingTo.delete(c.chat.id);
|
||||
await c.reply(adminT('bot.admin.replySent', { name: target.name }));
|
||||
} catch (e) {
|
||||
@@ -238,9 +265,8 @@ bot.on('message:text', async (c) => {
|
||||
// исполнитель пишет оператору — сообщение появится в админке
|
||||
const id = await identity(c);
|
||||
if (id?.provider) {
|
||||
const first = !(await db.conciergeMessage.count({ where: { providerId: id.provider.id } }));
|
||||
await addProviderMessage(ctx, id.provider.id, c.message.text);
|
||||
if (first) await c.reply(createT(localeOf(id))('bot.pro.messageReceived'));
|
||||
const res = await addProviderMessage(ctx, id.provider.id, c.message.text);
|
||||
if (res?.opened) await c.reply(createT(localeOf(id))('bot.pro.messageReceived'));
|
||||
return;
|
||||
}
|
||||
if (admin) {
|
||||
@@ -250,6 +276,19 @@ bot.on('message:text', async (c) => {
|
||||
await greet(c);
|
||||
});
|
||||
|
||||
/** Исполнитель прислал фото или файл — тоже в поддержку */
|
||||
bot.on(['message:photo', 'message:document'], async (c, next) => {
|
||||
const id = await identity(c);
|
||||
if (!id?.provider) return next();
|
||||
const photo = c.message.photo?.at(-1);
|
||||
const doc = c.message.document;
|
||||
const file = photo
|
||||
? { tgFileId: photo.file_id, tgBot: 'pro' as const, fileName: 'photo.jpg', mime: 'image/jpeg', size: photo.file_size ?? null }
|
||||
: { tgFileId: doc!.file_id, tgBot: 'pro' as const, fileName: doc!.file_name ?? 'file', mime: doc!.mime_type ?? 'application/octet-stream', size: doc!.file_size ?? null };
|
||||
const res = await addProviderMessage(ctx, id.provider.id, c.message.caption ?? '', file);
|
||||
if (res?.opened) await c.reply(createT(localeOf(id))('bot.pro.messageReceived'));
|
||||
});
|
||||
|
||||
bot.on('message', greet);
|
||||
bot.catch((err) => console.error('[bot-pro]', err.error));
|
||||
|
||||
|
||||
Reference in new issue
Block a user