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:
zabudko-ruandClaude Opus 5.5 committed 2026-10-21 01:50:37 +04:00
1 parent d452824c62
commit 23ffb2bf1d
46 files changed
+4176 -144

No files matched your search

+447
View File
@@ -0,0 +1,447 @@
/**
* Чаты (этап 2 новой админки): два канала — поддержка (все клиенты и исполнители) и консьерж (только подписчики).
* Клиент пишет боту или в мини-аппе — сообщения попадают в один диалог; ответ команды приходит в чат с ботом.
* У собеседника по одному диалогу в каждом канале: закрытый диалог открывается заново, когда клиент пишет снова.
*/
import { activeSubscription } from '@barev/core';
import type { Prisma } from '@barev/db';
import { DomainError, type Ctx } from './context.ts';
export type ChatChannel = 'SUPPORT' | 'CONCIERGE';
export const CHAT_CHANNELS: ChatChannel[] = ['SUPPORT', 'CONCIERGE'];
export type ChatSource = 'BOT' | 'MINIAPP' | 'ADMIN';
/** С кем переписка: клиент (клиентский бот) или исполнитель (рабочий бот) */
export type Peer = { clientId: string; providerId?: undefined } | { providerId: string; clientId?: undefined };
const peerWhere = (p: Peer) => (p.clientId ? { clientId: p.clientId } : { providerId: p.providerId! });
/** Файл в сообщении: из Telegram (file_id) или из нашего хранилища (ключ) */
export interface ChatFile {
tgFileId?: string | null;
tgBot?: 'client' | 'pro' | null;
fileKey?: string | null;
fileName?: string | null;
mime?: string | null;
size?: number | null;
}
const MAX_TEXT = 4000;
const clean = (s: string | null | undefined) => (s ?? '').trim().slice(0, MAX_TEXT);
const INCOMING = { in: ['CLIENT', 'PROVIDER'] as ('CLIENT' | 'PROVIDER')[] };
async function hasActiveSubscription(ctx: Ctx, clientId: string) {
const subs = await ctx.db.subscription.findMany({ where: { clientId, status: 'ACTIVE' } });
return !!activeSubscription(subs, ctx.now());
}
/** Куда сейчас пишет клиент: выбранный канал, иначе подписчик → консьерж, остальные → поддержка */
export async function clientChannel(ctx: Ctx, clientId: string): Promise<ChatChannel> {
const client = await ctx.db.client.findUniqueOrThrow({ where: { id: clientId }, select: { chatChannel: true } });
const subscriber = await hasActiveSubscription(ctx, clientId);
if (client.chatChannel === 'SUPPORT') return 'SUPPORT';
return subscriber ? 'CONCIERGE' : 'SUPPORT';
}
/** Клиент сам выбирает канал (/support, /concierge в боте). Консьерж — только с подпиской */
export async function setClientChannel(ctx: Ctx, clientId: string, channel: ChatChannel) {
if (channel === 'CONCIERGE' && !(await hasActiveSubscription(ctx, clientId))) throw new DomainError('CONCIERGE_SUBSCRIBERS_ONLY');
await ctx.db.client.update({ where: { id: clientId }, data: { chatChannel: channel } });
return channel;
}
function threadKey(peer: Peer, channel: ChatChannel): Prisma.ChatThreadWhereUniqueInput {
return peer.clientId ? { clientId_channel: { clientId: peer.clientId, channel } } : { providerId_channel: { providerId: peer.providerId!, channel } };
}
async function ensureThread(ctx: Ctx, peer: Peer, channel: ChatChannel) {
return ctx.db.chatThread.upsert({
where: threadKey(peer, channel),
create: { ...peerWhere(peer), channel, status: 'CLOSED', lastMessageAt: ctx.now() },
update: {},
});
}
/**
* Входящее сообщение клиента или исполнителя (бот / мини-апп).
* opened = диалог был закрыт или новый — бот подтверждает «передали, ответим здесь».
*/
export async function receiveMessage(ctx: Ctx, peer: Peer, input: { text?: string | null; file?: ChatFile | null; source?: ChatSource }) {
const text = clean(input.text);
if (!text && !input.file) return null;
const channel: ChatChannel = peer.clientId ? await clientChannel(ctx, peer.clientId) : 'SUPPORT';
const before = await ensureThread(ctx, peer, channel);
const now = ctx.now();
const message = await ctx.db.conciergeMessage.create({
data: {
...peerWhere(peer),
threadId: before.id,
author: peer.clientId ? 'CLIENT' : 'PROVIDER',
text,
source: input.source ?? 'BOT',
...fileData(input.file),
},
});
const thread = await ctx.db.chatThread.update({
where: { id: before.id },
data: { status: 'OPEN', closedAt: null, lastMessageAt: now, waitingSince: before.waitingSince ?? now },
});
await ctx.notify.emit({ type: 'concierge.message', messageId: message.id });
return { message, thread, channel, opened: before.status === 'CLOSED' };
}
function fileData(f: ChatFile | null | undefined) {
if (!f) return {};
return { tgFileId: f.tgFileId ?? null, tgBot: f.tgBot ?? null, fileKey: f.fileKey ?? null, fileName: f.fileName?.slice(0, 200) ?? null, mime: f.mime ?? null, size: f.size ?? null };
}
async function staffName(ctx: Ctx, id: string) {
return (await ctx.db.adminUser.findUnique({ where: { id }, select: { name: true } }))?.name ?? '';
}
/** Служебная строка в ленте: кто взял, передал, закрыл. В text — код события, в fileName — параметр */
async function systemLine(ctx: Ctx, thread: { id: string; clientId: string | null; providerId: string | null }, adminUserId: string, code: string, param?: string) {
await ctx.db.conciergeMessage.create({
data: { threadId: thread.id, clientId: thread.clientId, providerId: thread.providerId, author: 'SYSTEM', adminUserId, text: code, fileName: param ?? null, internal: true, source: 'ADMIN', readAt: ctx.now() },
});
}
async function getThread(ctx: Ctx, id: string) {
const t = await ctx.db.chatThread.findUnique({ where: { id } });
if (!t) throw new DomainError('NOT_FOUND');
return t;
}
const markRead = (ctx: Ctx, threadId: string) =>
ctx.db.conciergeMessage.updateMany({ where: { threadId, author: INCOMING, readAt: null }, data: { readAt: ctx.now() } });
/**
* Сообщение команды: ответ клиенту (уходит в Telegram через воркер) или внутренняя заметка.
* Ответил первым в неназначенном диалоге — диалог за тобой.
*/
export async function staffSend(ctx: Ctx, input: { threadId: string; adminUserId: string; text?: string | null; internal?: boolean; file?: ChatFile | null }) {
const thread = await getThread(ctx, input.threadId);
const text = clean(input.text);
if (!text && !input.file) throw new DomainError('INVALID_PARAMS');
if (!input.internal && thread.providerId) {
const p = await ctx.db.provider.findUnique({ where: { id: thread.providerId } });
// исполнитель ещё не открыл рабочий бот по инвайту — написать ему некуда
if (!p?.userId) throw new DomainError('PROVIDER_NOT_LINKED');
}
const message = await ctx.db.conciergeMessage.create({
data: {
threadId: thread.id,
clientId: thread.clientId,
providerId: thread.providerId,
author: 'OPERATOR',
adminUserId: input.adminUserId,
text,
internal: !!input.internal,
source: 'ADMIN',
...fileData(input.file),
},
});
if (!input.internal) {
await ctx.db.chatThread.update({
where: { id: thread.id },
data: { waitingSince: null, lastMessageAt: ctx.now(), status: 'OPEN', closedAt: null, assigneeId: thread.assigneeId ?? input.adminUserId, botActive: false },
});
await markRead(ctx, thread.id);
await ctx.notify.emit({ type: 'concierge.reply', messageId: message.id });
}
return message;
}
/** Взять диалог себе / передать коллеге / снять ответственного (assigneeId = null) */
export async function assignThread(ctx: Ctx, input: { threadId: string; assigneeId: string | null; byAdminId: string }) {
const thread = await getThread(ctx, input.threadId);
if (thread.assigneeId === input.assigneeId) return thread;
if (input.assigneeId) {
const a = await ctx.db.adminUser.findUnique({ where: { id: input.assigneeId } });
if (!a?.active) throw new DomainError('NOT_FOUND');
const role = thread.channel === 'CONCIERGE' ? 'CONCIERGE' : 'SUPPORT';
if (!a.roles.includes(role) && !a.roles.includes('SUPERADMIN')) throw new DomainError('ASSIGNEE_NO_ACCESS');
}
const updated = await ctx.db.chatThread.update({ where: { id: thread.id }, data: { assigneeId: input.assigneeId, botActive: false } });
if (!input.assigneeId) await systemLine(ctx, thread, input.byAdminId, 'unassigned');
else if (input.assigneeId === input.byAdminId) await systemLine(ctx, thread, input.byAdminId, 'took');
else {
await systemLine(ctx, thread, input.byAdminId, 'transferred', await staffName(ctx, input.assigneeId));
await ctx.notify.emit({ type: 'chat.assigned', threadId: thread.id, assigneeId: input.assigneeId, byAdminId: input.byAdminId });
}
return updated;
}
/** Закрыть: вопрос решён. Клиент напишет снова — диалог откроется сам */
export async function closeThread(ctx: Ctx, input: { threadId: string; byAdminId: string }) {
const thread = await getThread(ctx, input.threadId);
if (thread.status === 'CLOSED') return thread;
await markRead(ctx, thread.id);
await systemLine(ctx, thread, input.byAdminId, 'closed');
return ctx.db.chatThread.update({ where: { id: thread.id }, data: { status: 'CLOSED', closedAt: ctx.now(), waitingSince: null, botActive: false } });
}
export async function reopenThread(ctx: Ctx, input: { threadId: string; byAdminId: string }) {
const thread = await getThread(ctx, input.threadId);
if (thread.status === 'OPEN') return thread;
await systemLine(ctx, thread, input.byAdminId, 'reopened');
return ctx.db.chatThread.update({ where: { id: thread.id }, data: { status: 'OPEN', closedAt: null } });
}
/**
* Перенести диалог клиента в другой канал (например, вопрос подписчика — к поддержке).
* Переписка переезжает целиком, дальше клиент пишет в новый канал.
*/
export async function moveThread(ctx: Ctx, input: { threadId: string; channel: ChatChannel; byAdminId: string }) {
const source = await getThread(ctx, input.threadId);
if (source.channel === input.channel) return source;
if (!source.clientId) throw new DomainError('INVALID_PARAMS');
if (input.channel === 'CONCIERGE' && !(await hasActiveSubscription(ctx, source.clientId))) throw new DomainError('CONCIERGE_SUBSCRIBERS_ONLY');
const peer: Peer = { clientId: source.clientId };
const target = await ensureThread(ctx, peer, input.channel);
await ctx.db.conciergeMessage.updateMany({ where: { threadId: source.id }, data: { threadId: target.id } });
const waiting = [source.waitingSince, target.waitingSince].filter((d): d is Date => !!d).sort((a, b) => a.getTime() - b.getTime())[0] ?? null;
const last = source.lastMessageAt > target.lastMessageAt ? source.lastMessageAt : target.lastMessageAt;
await ctx.db.chatThread.delete({ where: { id: source.id } });
const moved = await ctx.db.chatThread.update({
where: { id: target.id },
data: { status: 'OPEN', closedAt: null, waitingSince: waiting, lastMessageAt: last, assigneeId: target.assigneeId, botActive: false },
});
await ctx.db.client.update({ where: { id: source.clientId }, data: { chatChannel: input.channel } });
await systemLine(ctx, moved, input.byAdminId, 'moved', input.channel);
return moved;
}
/** Открыть (или создать пустой) диалог с клиентом — чтобы написать первым из карточки */
export async function openThreadFor(ctx: Ctx, peer: Peer, channel?: ChatChannel) {
const ch = channel ?? (peer.clientId ? await clientChannel(ctx, peer.clientId) : 'SUPPORT');
return ensureThread(ctx, peer, ch);
}
// ───── списки для команды ─────
export type ThreadFilter = 'mine' | 'unassigned' | 'waiting' | 'open' | 'closed' | 'all';
function filterWhere(filter: ThreadFilter, adminUserId: string): Prisma.ChatThreadWhereInput {
switch (filter) {
case 'mine':
return { assigneeId: adminUserId, status: 'OPEN' };
case 'unassigned':
return { assigneeId: null, status: 'OPEN' };
case 'waiting':
return { status: 'OPEN', waitingSince: { not: null } };
case 'open':
return { status: 'OPEN' };
case 'closed':
return { status: 'CLOSED' };
default:
return {};
}
}
const peerInclude = {
client: { include: { user: { select: { firstName: true, lastName: true, username: true, locale: true } }, subscriptions: { where: { status: 'ACTIVE' as const } } } },
provider: { include: { user: { select: { firstName: true, username: true, locale: true } } } },
assignee: { select: { id: true, name: true } },
} satisfies Prisma.ChatThreadInclude;
export function peerName(t: { client?: { user: { firstName: string | null; lastName: string | null; username: string | null } } | null; provider?: { name: string } | null }) {
if (t.client) {
const u = t.client.user;
return `${u.firstName ?? ''} ${u.lastName ?? ''}`.trim() || (u.username ? `@${u.username}` : '—');
}
return t.provider?.name ?? '—';
}
/** Короткая строка для списка: текст, «📎 файл» или код события */
function preview(m: { author: string; text: string; fileName: string | null; mime: string | null; tgFileId: string | null; fileKey: string | null } | undefined) {
if (!m) return null;
const hasFile = !!(m.tgFileId || m.fileKey);
return { author: m.author, text: m.text, file: hasFile ? (m.fileName ?? (m.mime?.startsWith('image/') ? 'image' : 'file')) : null };
}
export async function listThreads(
ctx: Ctx,
input: { channels: ChatChannel[]; channel?: ChatChannel; filter: ThreadFilter; adminUserId: string; search?: string; limit?: number },
) {
const channels = input.channel ? input.channels.filter((c) => c === input.channel) : input.channels;
const q = input.search?.trim();
const where: Prisma.ChatThreadWhereInput = {
channel: { in: channels },
...filterWhere(input.filter, input.adminUserId),
// пустой диалог (открыли из карточки и ничего не написали) в списке не нужен
messages: { some: {} },
...(q
? {
OR: [
{
client: {
user: {
OR: [
{ firstName: { contains: q, mode: 'insensitive' } },
{ lastName: { contains: q, mode: 'insensitive' } },
{ username: { contains: q.replace(/^@/, ''), mode: 'insensitive' } },
{ phone: { contains: q } },
],
},
},
},
{ provider: { name: { contains: q, mode: 'insensitive' } } },
{ messages: { some: { text: { contains: q, mode: 'insensitive' }, author: { not: 'SYSTEM' } } } },
],
}
: {}),
};
const rows = await ctx.db.chatThread.findMany({
where,
include: { ...peerInclude, messages: { where: { author: { not: 'SYSTEM' } }, orderBy: { createdAt: 'desc' }, take: 1 } },
// ждущие ответа — сверху, дольше всех ждущий — первым; остальные — по свежести
orderBy: [{ lastMessageAt: 'desc' }],
take: input.limit ?? 100,
});
const unread = rows.length
? await ctx.db.conciergeMessage.groupBy({ by: ['threadId'], where: { threadId: { in: rows.map((r) => r.id) }, author: INCOMING, readAt: null }, _count: true })
: [];
const unreadBy = new Map(unread.map((u) => [u.threadId, u._count]));
const now = ctx.now();
return rows
.map((t) => ({
id: t.id,
channel: t.channel,
status: t.status,
clientId: t.clientId,
providerId: t.providerId,
name: peerName(t),
username: t.client?.user.username ?? t.provider?.user?.username ?? null,
plan: t.client ? (activeSubscription(t.client.subscriptions, now)?.plan ?? null) : null,
providerRole: t.provider?.role ?? null,
assignee: t.assignee,
botActive: t.botActive,
waitingSince: t.status === 'OPEN' ? t.waitingSince : null,
lastMessageAt: t.lastMessageAt,
last: preview(t.messages[0]),
unread: unreadBy.get(t.id) ?? 0,
}))
.sort((a, b) => {
if (!!a.waitingSince !== !!b.waitingSince) return a.waitingSince ? -1 : 1;
if (a.waitingSince && b.waitingSince) return a.waitingSince.getTime() - b.waitingSince.getTime();
return b.lastMessageAt.getTime() - a.lastMessageAt.getTime();
});
}
/** Счётчики для вкладок и меню: ждут ответа / без ответственного / мои — по каждому каналу */
export async function threadCounts(ctx: Ctx, input: { channels: ChatChannel[]; adminUserId: string }) {
const base = { channel: { in: input.channels }, status: 'OPEN' as const };
const [waiting, unassigned, mine] = await Promise.all([
ctx.db.chatThread.groupBy({ by: ['channel'], where: { ...base, waitingSince: { not: null } }, _count: true }),
ctx.db.chatThread.groupBy({ by: ['channel'], where: { ...base, assigneeId: null, messages: { some: {} } }, _count: true }),
ctx.db.chatThread.groupBy({ by: ['channel'], where: { ...base, assigneeId: input.adminUserId }, _count: true }),
]);
const by = (rows: { channel: ChatChannel; _count: number }[]) => Object.fromEntries(CHAT_CHANNELS.map((c) => [c, rows.find((r) => r.channel === c)?._count ?? 0])) as Record<ChatChannel, number>;
const w = by(waiting);
return { waiting: w, unassigned: by(unassigned), mine: by(mine), total: input.channels.reduce((s, c) => s + w[c], 0) };
}
/** Диалог целиком для команды; открыл — входящие прочитаны */
export async function getThreadForStaff(ctx: Ctx, id: string, opts: { markRead?: boolean } = {}) {
const t = await ctx.db.chatThread.findUnique({ where: { id }, include: peerInclude });
if (!t) throw new DomainError('NOT_FOUND');
if (opts.markRead !== false) await markRead(ctx, id);
const messages = await ctx.db.conciergeMessage.findMany({ where: { threadId: id }, orderBy: { createdAt: 'asc' }, take: 500 });
const authorIds = [...new Set(messages.map((m) => m.adminUserId).filter((x): x is string => !!x))];
const authors = new Map((await ctx.db.adminUser.findMany({ where: { id: { in: authorIds } }, select: { id: true, name: true } })).map((a) => [a.id, a.name]));
// второй канал этого же клиента — чтобы показать «есть переписка в поддержке/консьерже»
const other = t.clientId ? await ctx.db.chatThread.findFirst({ where: { clientId: t.clientId, id: { not: t.id }, messages: { some: {} } }, select: { id: true, channel: true, status: true } }) : null;
const now = ctx.now();
return {
id: t.id,
channel: t.channel,
status: t.status,
clientId: t.clientId,
providerId: t.providerId,
name: peerName(t),
username: t.client?.user.username ?? t.provider?.user?.username ?? null,
locale: t.client?.user.locale ?? t.provider?.user?.locale ?? 'ru',
plan: t.client ? (activeSubscription(t.client.subscriptions, now)?.plan ?? null) : null,
providerRole: t.provider?.role ?? null,
providerLinked: t.provider ? !!t.provider.userId : null,
assignee: t.assignee,
botActive: t.botActive,
waitingSince: t.waitingSince,
other,
messages: messages.map((m) => ({
id: m.id,
author: m.author,
authorName: m.adminUserId ? (authors.get(m.adminUserId) ?? null) : null,
text: m.text,
internal: m.internal,
source: m.source,
file: m.tgFileId || m.fileKey ? { name: m.fileName, mime: m.mime, size: m.size } : null,
param: m.author === 'SYSTEM' ? m.fileName : null,
deliveryError: m.deliveryError,
readAt: m.readAt,
createdAt: m.createdAt,
})),
};
}
// ───── мини-апп клиента ─────
/** Переписка клиента со всеми каналами — без заметок команды и служебных строк */
export async function clientConversation(ctx: Ctx, clientId: string) {
const [messages, channel, subscriber] = await Promise.all([
ctx.db.conciergeMessage.findMany({
where: { clientId, internal: false, author: { in: ['CLIENT', 'OPERATOR', 'BOT'] } },
orderBy: { createdAt: 'asc' },
take: 300,
include: { thread: { select: { channel: true } } },
}),
clientChannel(ctx, clientId),
hasActiveSubscription(ctx, clientId),
]);
return {
channel,
canConcierge: subscriber,
messages: messages.map((m) => ({
id: m.id,
mine: m.author === 'CLIENT',
channel: m.thread?.channel ?? 'SUPPORT',
text: m.text,
file: m.tgFileId || m.fileKey ? { name: m.fileName, mime: m.mime } : null,
createdAt: m.createdAt,
})),
};
}
// ───── шаблоны ответов ─────
export function listTemplates(ctx: Ctx, channels: ChatChannel[]) {
return ctx.db.chatTemplate.findMany({ where: { OR: [{ channel: null }, { channel: { in: channels } }] }, orderBy: [{ title: 'asc' }] });
}
export async function saveTemplate(ctx: Ctx, input: { id?: string; channel: ChatChannel | null; title: string; text: string; adminUserId: string }) {
const data = { channel: input.channel, title: input.title.trim().slice(0, 80), text: clean(input.text) };
if (!data.title || !data.text) throw new DomainError('INVALID_PARAMS');
if (input.id) return ctx.db.chatTemplate.update({ where: { id: input.id }, data });
return ctx.db.chatTemplate.create({ data: { ...data, createdById: input.adminUserId } });
}
export async function deleteTemplate(ctx: Ctx, id: string) {
await ctx.db.chatTemplate.delete({ where: { id } });
}
/** Подставить имя клиента в шаблон */
export function fillTemplate(text: string, vars: { name: string }) {
return text.replaceAll('{name}', vars.name);
}
/** Кому из команды сообщить о новом сообщении в Telegram */
export async function chatRecipients(ctx: Ctx, thread: { channel: ChatChannel; assigneeId: string | null }) {
const staff = await ctx.db.adminUser.findMany({ where: { active: true, tgNotify: true, telegramId: { not: null } } });
if (thread.assigneeId) {
const a = staff.find((s) => s.id === thread.assigneeId);
if (a) return [a];
}
const role = thread.channel === 'CONCIERGE' ? 'CONCIERGE' : 'SUPPORT';
// без ролей — сотрудник только старой админки, ему приходят все уведомления, как раньше
return staff.filter((s) => s.roles.length === 0 || s.roles.includes('SUPERADMIN') || s.roles.includes(role));
}
+47 -38
View File
@@ -1,26 +1,27 @@
/**
* Переписка для старой админки и кнопки «Ответить» в рабочем боте.
* Данные — те же диалоги, что в новой админке (chats.ts); заметки команды и служебные строки здесь не видны.
*/
import { DomainError, type Ctx } from './context.ts';
import { clientChannel, receiveMessage, staffSend, type ChatFile, type Peer } from './chats.ts';
/** С кем переписка: клиент (клиентский бот) или исполнитель (рабочий бот) */
export type Peer = { clientId: string; providerId?: undefined } | { providerId: string; clientId?: undefined };
const peerWhere = (p: Peer) => (p.clientId ? { clientId: p.clientId } : { providerId: p.providerId });
export type { Peer } from './chats.ts';
const peerWhere = (p: Peer) => (p.clientId ? { clientId: p.clientId } : { providerId: p.providerId! });
/** что видит старая админка: переписка без заметок и служебных строк */
const VISIBLE = { internal: false, author: { in: ['CLIENT', 'PROVIDER', 'OPERATOR', 'BOT'] as ('CLIENT' | 'PROVIDER' | 'OPERATOR' | 'BOT')[] } };
const withFileText = <M extends { text: string; fileName: string | null; tgFileId: string | null; fileKey: string | null }>(m: M) =>
m.text || !(m.tgFileId || m.fileKey) ? m : { ...m, text: `📎 ${m.fileName ?? 'file'}` };
async function incoming(ctx: Ctx, peer: Peer, text: string) {
const body = text.trim().slice(0, 4000);
if (!body) return null;
const msg = await ctx.db.conciergeMessage.create({ data: { ...peerWhere(peer), author: peer.clientId ? 'CLIENT' : 'PROVIDER', text: body } });
await ctx.notify.emit({ type: 'concierge.message', messageId: msg.id });
return msg;
}
/** Сообщение клиента боту — в чат консьержа в админке и уведомление операторам */
export const addClientMessage = (ctx: Ctx, clientId: string, text: string) => incoming(ctx, { clientId }, text);
/** Сообщение исполнителя рабочему боту */
export const addProviderMessage = (ctx: Ctx, providerId: string, text: string) => incoming(ctx, { providerId }, text);
/** Сообщение клиента боту — в чат поддержки или консьержа и уведомление команде */
export const addClientMessage = (ctx: Ctx, clientId: string, text: string, file?: ChatFile | null) => receiveMessage(ctx, { clientId }, { text, file });
/** Сообщение исполнителя рабочему боту — в поддержку */
export const addProviderMessage = (ctx: Ctx, providerId: string, text: string, file?: ChatFile | null) => receiveMessage(ctx, { providerId }, { text, file });
/** Список диалогов для оператора: последнее сообщение и сколько непрочитанных, свежие сверху */
export async function listConversations(ctx: Ctx) {
const [last, unread] = await Promise.all([
ctx.db.conciergeMessage.findMany({
where: VISIBLE,
distinct: ['clientId', 'providerId'],
orderBy: [{ clientId: 'asc' }, { providerId: 'asc' }, { createdAt: 'desc' }],
include: {
@@ -28,30 +29,34 @@ export async function listConversations(ctx: Ctx) {
provider: { include: { user: { select: { username: true, locale: true } } } },
},
}),
ctx.db.conciergeMessage.groupBy({ by: ['clientId', 'providerId'], where: { author: { not: 'OPERATOR' }, readAt: null }, _count: true }),
ctx.db.conciergeMessage.groupBy({ by: ['clientId', 'providerId'], where: { author: { in: ['CLIENT', 'PROVIDER'] }, readAt: null }, _count: true }),
]);
const key = (m: { clientId: string | null; providerId: string | null }) => (m.clientId ? `c:${m.clientId}` : `p:${m.providerId}`);
const unreadBy = new Map(unread.map((u) => [key(u), u._count]));
return last
.map((m) => ({
key: key(m),
clientId: m.clientId,
providerId: m.providerId,
name: m.client ? `${m.client.user.firstName ?? ''} ${m.client.user.lastName ?? ''}`.trim() : (m.provider?.name ?? ''),
username: m.client?.user.username ?? m.provider?.user?.username ?? null,
locale: m.client?.user.locale ?? m.provider?.user?.locale ?? 'ru',
segment: m.client?.segment ?? null,
providerRole: m.provider?.role ?? null,
last: { text: m.text, author: m.author, createdAt: m.createdAt },
unread: unreadBy.get(key(m)) ?? 0,
}))
.map((raw) => {
const m = withFileText(raw);
return {
key: key(m),
clientId: m.clientId,
providerId: m.providerId,
name: m.client ? `${m.client.user.firstName ?? ''} ${m.client.user.lastName ?? ''}`.trim() : (m.provider?.name ?? ''),
username: m.client?.user.username ?? m.provider?.user?.username ?? null,
locale: m.client?.user.locale ?? m.provider?.user?.locale ?? 'ru',
segment: m.client?.segment ?? null,
providerRole: m.provider?.role ?? null,
last: { text: m.text, author: m.author, createdAt: m.createdAt },
unread: unreadBy.get(key(m)) ?? 0,
};
})
.sort((a, b) => b.last.createdAt.getTime() - a.last.createdAt.getTime());
}
/** Переписка; открыл — входящие прочитаны. Пустая — можно написать первым */
export async function conversation(ctx: Ctx, peer: Peer) {
await ctx.db.conciergeMessage.updateMany({ where: { ...peerWhere(peer), author: { not: 'OPERATOR' }, readAt: null }, data: { readAt: ctx.now() } });
return ctx.db.conciergeMessage.findMany({ where: peerWhere(peer), orderBy: { createdAt: 'asc' }, take: 500 });
await ctx.db.conciergeMessage.updateMany({ where: { ...peerWhere(peer), author: { in: ['CLIENT', 'PROVIDER'] }, readAt: null }, data: { readAt: ctx.now() } });
const list = await ctx.db.conciergeMessage.findMany({ where: { ...peerWhere(peer), ...VISIBLE }, orderBy: { createdAt: 'asc' }, take: 500 });
return list.map(withFileText);
}
/** Кто это — для заголовка нового диалога, который оператор начинает первым */
@@ -65,22 +70,26 @@ export async function peerInfo(ctx: Ctx, peer: Peer) {
}
export function unreadConciergeCount(ctx: Ctx) {
return ctx.db.conciergeMessage.count({ where: { author: { not: 'OPERATOR' }, readAt: null } });
return ctx.db.conciergeMessage.count({ where: { author: { in: ['CLIENT', 'PROVIDER'] }, readAt: null } });
}
/** Ответ оператора: сохраняем и отправляем в чат с ботом (воркер) */
/**
* Ответ оператора по собеседнику (старая админка, «Ответить» в боте):
* в диалог, где клиент писал последним, иначе — в канал, куда он сейчас пишет.
*/
export async function replyTo(ctx: Ctx, input: Peer & { adminUserId: string; text: string }) {
const peer: Peer = input.clientId ? { clientId: input.clientId } : { providerId: input.providerId! };
if (peer.providerId) {
const p = await ctx.db.provider.findUnique({ where: { id: peer.providerId } });
// исполнитель ещё не открыл рабочий бот по инвайту — написать ему некуда
if (!p?.userId) throw new DomainError('PROVIDER_NOT_LINKED');
}
const msg = await ctx.db.conciergeMessage.create({ data: { ...peerWhere(peer), author: 'OPERATOR', adminUserId: input.adminUserId, text: input.text.trim().slice(0, 4000) } });
// ответили — входящие этого диалога прочитаны
await ctx.db.conciergeMessage.updateMany({ where: { ...peerWhere(peer), author: { not: 'OPERATOR' }, readAt: null }, data: { readAt: ctx.now() } });
await ctx.notify.emit({ type: 'concierge.reply', messageId: msg.id });
return msg;
const latest = await ctx.db.chatThread.findFirst({ where: peerWhere(peer), orderBy: { lastMessageAt: 'desc' } });
const thread =
latest ??
(await ctx.db.chatThread.create({
data: { ...peerWhere(peer), channel: peer.clientId ? await clientChannel(ctx, peer.clientId) : 'SUPPORT', status: 'OPEN', lastMessageAt: ctx.now() },
}));
return staffSend(ctx, { threadId: thread.id, adminUserId: input.adminUserId, text: input.text });
}
/** Совместимость: ответ клиенту */
+5 -1
View File
@@ -23,6 +23,8 @@ export type DomainEvent =
| { type: 'subscription.activated'; subscriptionId: string }
/** уведомления операторам в рабочий бот */
| { type: 'concierge.message'; messageId: string }
/** диалог передали коллеге — ему уведомление в рабочий бот */
| { type: 'chat.assigned'; threadId: string; assigneeId: string; byAdminId: string }
| { type: 'lead.submitted'; leadId: string }
| { type: 'clientRequest.created'; requestId: string }
| { type: 'webRequest.created'; requestId: string };
@@ -77,7 +79,9 @@ export type DomainErrorCode =
| 'PROVIDER_NOT_LINKED'
| 'CARD_EXPIRED'
| 'OFFER_NOT_AVAILABLE'
| 'OFFER_LIMIT_REACHED';
| 'OFFER_LIMIT_REACHED'
| 'CONCIERGE_SUBSCRIBERS_ONLY'
| 'ASSIGNEE_NO_ACCESS';
/** Имя очереди BullMQ, через которую API и боты передают события воркеру */
export const EVENTS_QUEUE = 'barev-events';
+1
View File
@@ -14,4 +14,5 @@ export * from './leads.ts';
export * from './relocation.ts';
export * from './clientRequests.ts';
export * from './concierge.ts';
export * from './chats.ts';
export * from './partners.ts';
+43
View File
@@ -0,0 +1,43 @@
/**
* Приватное объектное хранилище (S3-совместимое) для файлов чатов — общее для API и воркера.
* Настройки — из S3_* в .env.
*/
import { randomUUID } from 'node:crypto';
import { GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
let client: S3Client | null = null;
function s3() {
client ??= new S3Client({
endpoint: process.env.S3_ENDPOINT ?? 'http://localhost:9000',
region: process.env.S3_REGION ?? 'us-east-1',
forcePathStyle: true,
credentials: { accessKeyId: process.env.S3_ACCESS_KEY ?? '', secretAccessKey: process.env.S3_SECRET_KEY ?? '' },
});
return client;
}
const bucket = () => process.env.S3_BUCKET ?? 'barev-private';
/** Расширение из имени файла (только буквы/цифры), иначе bin */
function extOf(name: string | null | undefined) {
const m = name?.match(/\.([a-z0-9]{1,8})$/i);
return m ? m[1]!.toLowerCase() : 'bin';
}
export async function putFile(prefix: string, body: Buffer, mime: string, fileName?: string | null) {
const key = `${prefix}/${randomUUID()}.${extOf(fileName)}`;
await s3().send(
new PutObjectCommand({
Bucket: bucket(),
Key: key,
Body: body,
ContentType: mime,
...(process.env.NODE_ENV === 'production' ? { ServerSideEncryption: 'AES256' as const } : {}),
}),
);
return key;
}
export async function getFile(key: string): Promise<Buffer> {
const res = await s3().send(new GetObjectCommand({ Bucket: bucket(), Key: key }));
return Buffer.from(await res.Body!.transformToByteArray());
}
+37 -3
View File
@@ -25,11 +25,12 @@ export class TelegramSender {
private label: string,
) {}
async send(msg: OutgoingMessage): Promise<void> {
/** false — получатель недоступен (заблокировал бота / бот ему не писал) */
async send(msg: OutgoingMessage): Promise<boolean> {
if (!this.token) {
const buttons = msg.buttons?.flat().map((b) => `[${b.text}]`).join(' ') ?? '';
console.log(`[${this.label} → ${msg.chatId}] ${msg.text.replace(/<[^>]+>/g, '')} ${buttons}`);
return;
return true;
}
const res = await fetch(`https://api.telegram.org/bot${this.token}/sendMessage`, {
method: 'POST',
@@ -48,10 +49,43 @@ export class TelegramSender {
// (например, тестовые пользователи seed); повторять бессмысленно
if (res.status === 403 || (res.status === 400 && body.includes('chat not found'))) {
console.warn(`[${this.label}] chat ${msg.chatId} unreachable: ${res.status}`);
return;
return false;
}
throw new Error(`Telegram ${res.status}: ${body}`);
}
return true;
}
/** Файл с подписью: картинки — как фото (видно сразу), остальное — документом. false — получатель недоступен */
async sendFile(msg: { chatId: bigint | number | string; caption: string; body: Buffer; fileName: string; mime: string }): Promise<boolean> {
const asPhoto = /^image\/(jpeg|png|webp)$/.test(msg.mime) && msg.body.length <= 10 * 1024 * 1024;
if (!this.token) {
console.log(`[${this.label} → ${msg.chatId}] 📎 ${msg.fileName} (${msg.body.length} B) ${msg.caption.replace(/<[^>]+>/g, '')}`);
return true;
}
const form = new FormData();
form.set('chat_id', String(msg.chatId));
form.set('caption', msg.caption.slice(0, 1024));
form.set('parse_mode', 'HTML');
form.set(asPhoto ? 'photo' : 'document', new Blob([new Uint8Array(msg.body)], { type: msg.mime }), msg.fileName);
const res = await fetch(`https://api.telegram.org/bot${this.token}/${asPhoto ? 'sendPhoto' : 'sendDocument'}`, { method: 'POST', body: form });
if (!res.ok) {
const body = await res.text();
if (res.status === 403 || (res.status === 400 && body.includes('chat not found'))) return false;
throw new Error(`Telegram ${res.status}: ${body}`);
}
return true;
}
/** Скачать файл, который прислали боту (до 20 МБ — ограничение Bot API) */
async download(fileId: string): Promise<Response | null> {
if (!this.token) return null;
const info = await fetch(`https://api.telegram.org/bot${this.token}/getFile?file_id=${encodeURIComponent(fileId)}`);
if (!info.ok) return null;
const json = (await info.json()) as { ok: boolean; result?: { file_path?: string } };
if (!json.result?.file_path) return null;
const file = await fetch(`https://api.telegram.org/file/bot${this.token}/${json.result.file_path}`);
return file.ok ? file : null;
}
}