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
@@ -5,7 +5,20 @@
|
||||
import { getDb } from '@barev/db';
|
||||
import { createT, formatDateTime, isLocale, LOCALE_NAMES, LOCALES, type Locale } from '@barev/i18n';
|
||||
import { decodeStartPayload } from '@barev/core';
|
||||
import { addClientMessage, applyAgencyCode, DomainError, getClientByTelegramId, giveConsent, linkWebRequest, setLocale, startClient, type Ctx } from '@barev/services';
|
||||
import {
|
||||
addClientMessage,
|
||||
applyAgencyCode,
|
||||
DomainError,
|
||||
getClientByTelegramId,
|
||||
giveConsent,
|
||||
linkWebRequest,
|
||||
setClientChannel,
|
||||
setLocale,
|
||||
startClient,
|
||||
type ChatChannel,
|
||||
type ChatFile,
|
||||
type Ctx,
|
||||
} from '@barev/services';
|
||||
import { loadRootEnv } from '@barev/services/env';
|
||||
import { createQueueNotifier } from '@barev/services/queue';
|
||||
import { Bot, InlineKeyboard, type Context } from 'grammy';
|
||||
@@ -140,19 +153,50 @@ bot.command('app', async (c) => {
|
||||
|
||||
bot.command('help', async (c) => c.reply(createT(await localeOf(c.from))('bot.client.help')));
|
||||
|
||||
/** ONB-6: ручной ввод кода агентства; любой другой текст — консьержу */
|
||||
/** Сообщение в чат команды: подписчик пишет консьержу, остальные — поддержке. Ответ придёт сюда же */
|
||||
async function toTeam(c: Context, text: string, file?: ChatFile) {
|
||||
const locale = await localeOf(c.from);
|
||||
const client = await getClientByTelegramId(db, BigInt(c.from!.id));
|
||||
if (!client?.consentAt) return askConsent(c, locale);
|
||||
awaitingCode.delete(c.chat!.id);
|
||||
const res = await addClientMessage(ctx, client.id, text, file);
|
||||
// диалог только что открылся — подтверждаем, что сообщение дошло
|
||||
if (res?.opened) await c.reply(createT(locale)(res.channel === 'CONCIERGE' ? 'bot.client.conciergeReceived' : 'bot.client.supportReceived'));
|
||||
}
|
||||
|
||||
/** Переключить канал: /support — поддержка, /concierge — консьерж (только с подпиской) */
|
||||
async function switchChannel(c: Context, channel: ChatChannel) {
|
||||
const locale = await localeOf(c.from);
|
||||
const t = createT(locale);
|
||||
const client = await getClientByTelegramId(db, BigInt(c.from!.id));
|
||||
if (!client?.consentAt) return askConsent(c, locale);
|
||||
try {
|
||||
await setClientChannel(ctx, client.id, channel);
|
||||
await c.reply(t(channel === 'CONCIERGE' ? 'bot.client.channelConcierge' : 'bot.client.channelSupport'));
|
||||
} catch (e) {
|
||||
if (e instanceof DomainError) await c.reply(t(`errors.${e.code}`));
|
||||
else throw e;
|
||||
}
|
||||
}
|
||||
bot.command('support', (c) => switchChannel(c, 'SUPPORT'));
|
||||
bot.command('concierge', (c) => switchChannel(c, 'CONCIERGE'));
|
||||
|
||||
/** Фото и файлы — тоже в чат команды; берём самое крупное превью фото */
|
||||
bot.on('message:photo', async (c) => {
|
||||
const photo = c.message.photo.at(-1)!;
|
||||
await toTeam(c, c.message.caption ?? '', { tgFileId: photo.file_id, tgBot: 'client', fileName: 'photo.jpg', mime: 'image/jpeg', size: photo.file_size ?? null });
|
||||
});
|
||||
bot.on('message:document', async (c) => {
|
||||
const d = c.message.document;
|
||||
await toTeam(c, c.message.caption ?? '', { tgFileId: d.file_id, tgBot: 'client', fileName: d.file_name ?? 'file', mime: d.mime_type ?? 'application/octet-stream', size: d.file_size ?? null });
|
||||
});
|
||||
|
||||
/** ONB-6: ручной ввод кода агентства; любой другой текст — в чат команды */
|
||||
bot.on('message:text', async (c) => {
|
||||
const locale = await localeOf(c.from);
|
||||
const t = createT(locale);
|
||||
const client = await getClientByTelegramId(db, BigInt(c.from.id));
|
||||
if (!awaitingCode.has(c.chat.id)) {
|
||||
if (!client?.consentAt) return askConsent(c, locale);
|
||||
// чат с консьержем: сообщение видит оператор в админке, ответ придёт сюда же
|
||||
const first = !(await db.conciergeMessage.count({ where: { clientId: client.id } }));
|
||||
await addClientMessage(ctx, client.id, c.message.text);
|
||||
if (first) await c.reply(t('bot.client.conciergeReceived'));
|
||||
return;
|
||||
}
|
||||
if (!awaitingCode.has(c.chat.id)) return toTeam(c, c.message.text);
|
||||
if (!client) return;
|
||||
try {
|
||||
const res = await applyAgencyCode(ctx, client.id, c.message.text);
|
||||
@@ -180,6 +224,8 @@ async function main() {
|
||||
{ command: 'start', description: t('bot.client.cmdStart') },
|
||||
{ command: 'app', description: t('bot.client.cmdApp') },
|
||||
{ command: 'code', description: t('bot.client.cmdCode') },
|
||||
{ command: 'support', description: t('bot.client.cmdSupport') },
|
||||
{ command: 'concierge', description: t('bot.client.cmdConcierge') },
|
||||
{ command: 'language', description: t('bot.client.cmdLanguage') },
|
||||
{ command: 'help', description: t('bot.client.cmdHelp') },
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user