Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
88 lines
5.5 KiB
TypeScript
88 lines
5.5 KiB
TypeScript
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);
|