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
@@ -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));
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/** Совместимость: ответ клиенту */
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user