import { DomainError, type Ctx } from './context.ts'; /** С кем переписка: клиент (клиентский бот) или исполнитель (рабочий бот) */ export type Peer = { clientId: string; providerId?: undefined } | { providerId: string; clientId?: undefined }; const peerWhere = (p: Peer) => (p.clientId ? { clientId: p.clientId } : { providerId: p.providerId }); 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 async function listConversations(ctx: Ctx) { const [last, unread] = await Promise.all([ ctx.db.conciergeMessage.findMany({ distinct: ['clientId', 'providerId'], orderBy: [{ clientId: 'asc' }, { providerId: 'asc' }, { createdAt: 'desc' }], include: { client: { include: { user: { select: { firstName: true, lastName: true, username: true, locale: true } } } }, provider: { include: { user: { select: { username: true, locale: true } } } }, }, }), ctx.db.conciergeMessage.groupBy({ by: ['clientId', 'providerId'], where: { author: { not: 'OPERATOR' }, 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, })) .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 }); } /** Кто это — для заголовка нового диалога, который оператор начинает первым */ export async function peerInfo(ctx: Ctx, peer: Peer) { if (peer.clientId) { const c = await ctx.db.client.findUniqueOrThrow({ where: { id: peer.clientId }, include: { user: true } }); return { name: `${c.user.firstName ?? ''} ${c.user.lastName ?? ''}`.trim(), username: c.user.username, locale: c.user.locale, reachable: true }; } const p = await ctx.db.provider.findUniqueOrThrow({ where: { id: peer.providerId! }, include: { user: true } }); return { name: p.name, username: p.user?.username ?? null, locale: p.user?.locale ?? 'ru', reachable: !!p.user }; } export function unreadConciergeCount(ctx: Ctx) { return ctx.db.conciergeMessage.count({ where: { author: { not: 'OPERATOR' }, 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; } /** Совместимость: ответ клиенту */ export const replyToClient = (ctx: Ctx, input: { clientId: string; adminUserId: string; text: string }) => replyTo(ctx, input);