diff --git a/packages/backend/src/ee/support/libredesk.client.ts b/packages/backend/src/ee/support/libredesk.client.ts index ab36a62cd..fddf2a677 100644 --- a/packages/backend/src/ee/support/libredesk.client.ts +++ b/packages/backend/src/ee/support/libredesk.client.ts @@ -11,6 +11,11 @@ export interface LibredeskMessage { created_at?: string; } +export interface LibredeskMention { + type: 'agent' | 'team'; + id: number; +} + /** Minimal REST client for the endpoints the triage uses (API-key auth). */ export class LibredeskClient { constructor( @@ -67,11 +72,13 @@ export class LibredeskClient { }); } - addPrivateNote(uuid: string, html: string) { + /** Mentioned agents and team members get an in-app and email notification. */ + addPrivateNote(uuid: string, html: string, mentions: LibredeskMention[] = []) { return this.call('POST', `/api/v1/conversations/${encodeURIComponent(uuid)}/messages`, { message: html, private: true, sender_type: 'agent', + ...(mentions.length ? { mentions } : {}), }); } diff --git a/packages/backend/src/ee/support/support-triage.service.spec.ts b/packages/backend/src/ee/support/support-triage.service.spec.ts index e90ef50b7..9bbf82e6b 100644 --- a/packages/backend/src/ee/support/support-triage.service.spec.ts +++ b/packages/backend/src/ee/support/support-triage.service.spec.ts @@ -126,12 +126,20 @@ describe('SupportTriageService', () => { expect(desk.addPrivateNote).toHaveBeenCalledTimes(1); }); - it('routes dedicated instances to the Enterprise team', async () => { + it('routes dedicated instances to the Enterprise team and mentions it in the note', async () => { const desk = fakeDesk([{ private: false, sender_type: 'contact', text_content: 'help' }]); jest.spyOn(llm, 'chatJson').mockResolvedValue({ json: { category: 'bug', priority: 'Low' } }); await new TestTriage(desk).triage({ uuid: 'c2', contact: { custom_attributes: { deployment: 'dedicated' } } }); expect(desk.assignTeam).toHaveBeenCalledWith('c2', 2); expect(desk.setPriority).toHaveBeenCalledWith('c2', 'High'); + expect(desk.addPrivateNote).toHaveBeenCalledWith('c2', expect.any(String), [{ type: 'team', id: 2 }]); + }); + + it('does not mention a team for non-enterprise conversations', async () => { + const desk = fakeDesk([{ private: false, sender_type: 'contact', text_content: 'help' }]); + jest.spyOn(llm, 'chatJson').mockResolvedValue({ json: { category: 'bug', priority: 'Low' } }); + await new TestTriage(desk).triage({ uuid: 'c7', contact: { custom_attributes: { deployment: 'cloud', plan: 'starter' } } }); + expect(desk.addPrivateNote).toHaveBeenCalledWith('c7', expect.any(String), []); }); it('still tags and annotates when the LLM is unavailable', async () => { @@ -151,7 +159,7 @@ describe('SupportTriageService', () => { expect(desk.addTags).toHaveBeenCalledTimes(1); }); - it('hands safe live chats to the AI assistant after the team, never billing or enterprise', async () => { + it('hands safe live chats to the AI assistant after the team, enterprise included, never billing', async () => { const msgs = [{ private: false, sender_type: 'contact', text_content: 'help' }]; const chat = jest.spyOn(llm, 'chatJson'); @@ -174,7 +182,14 @@ describe('SupportTriageService', () => { chat.mockResolvedValueOnce({ json: { category: 'connector-setup', priority: 'Low' } }); const ent = fakeDesk(msgs); await new TestTriage(ent, undefined, 5).triage({ uuid: 'a3', inbox_channel: 'livechat', contact: { custom_attributes: { deployment: 'dedicated' } } }); - expect(ent.assignUser).not.toHaveBeenCalled(); + expect(ent.assignTeam).toHaveBeenCalledWith('a3', 2); + expect(ent.assignUser).toHaveBeenCalledWith('a3', 5); + expect(ent.addPrivateNote).toHaveBeenCalledWith('a3', expect.any(String), [{ type: 'team', id: 2 }]); + + chat.mockResolvedValueOnce({ json: { category: 'billing', priority: 'Low' } }); + const entBilling = fakeDesk(msgs); + await new TestTriage(entBilling, undefined, 5).triage({ uuid: 'a6', inbox_channel: 'livechat', contact: { custom_attributes: { deployment: 'cloud', plan: 'enterprise' } } }); + expect(entBilling.assignUser).not.toHaveBeenCalled(); chat.mockResolvedValueOnce({ json: { category: 'connector-setup', priority: 'Low' } }); const email = fakeDesk(msgs); diff --git a/packages/backend/src/ee/support/support-triage.service.ts b/packages/backend/src/ee/support/support-triage.service.ts index 480d7b080..ec5a1e39d 100644 --- a/packages/backend/src/ee/support/support-triage.service.ts +++ b/packages/backend/src/ee/support/support-triage.service.ts @@ -19,8 +19,8 @@ export const TRIAGE_CATEGORIES = [ export type TriageCategory = (typeof TRIAGE_CATEGORIES)[number]; /** - * Categories the AI assistant may answer on its own. Billing, bugs, feature - * requests and sales always go to a person. + * Categories the AI assistant may answer on its own, enterprise customers + * included. Billing, bugs, feature requests and sales always go to a person. */ export const AI_HANDLED_CATEGORIES: readonly TriageCategory[] = [ 'connector-setup', @@ -162,18 +162,21 @@ export class SupportTriageService { ) { const enterprise = isEnterprise(attrs); const assistant = this.aiAssistantUserId(); - const toAssistant = routeToAssistant(r, attrs, channel, assistant); + const toAssistant = routeToAssistant(r, channel, assistant); const tags = [r.category, ...segmentTags(attrs), 'ai-triaged']; if (toAssistant) tags.push('ai-assistant'); + let teamId: number | undefined; const steps: Array<[string, () => Promise]> = [ ['tags', () => desk.addTags(uuid, tags)], ['priority', () => desk.setPriority(uuid, r.priority)], // Team first: Libredesk clears the user assignee when the team changes. ['team', async () => { - const id = await this.teamId(desk, enterprise ? 'Enterprise' : 'Support'); - if (id) await desk.assignTeam(uuid, id); + teamId = await this.teamId(desk, enterprise ? 'Enterprise' : 'Support'); + if (teamId) await desk.assignTeam(uuid, teamId); }], - ['note', () => desk.addPrivateNote(uuid, triageNote(r, attrs))], + // Mentioning the Enterprise team notifies its members at once, even + // when the assistant answers first. + ['note', () => desk.addPrivateNote(uuid, triageNote(r, attrs), enterprise && teamId ? [{ type: 'team', id: teamId }] : [])], ]; if (toAssistant && assistant) { // Assigning the assistant starts its reply; it hands back to the @@ -235,17 +238,15 @@ export function normaliseAttrs(raw: unknown): Record { return out; } -/** Live chat, a category the assistant handles, and never enterprise. */ +/** Live chat in a category the assistant handles. */ export function routeToAssistant( r: TriageResult, - attrs: Record, channel: string | undefined, assistantUserId: number | null, ): boolean { return ( !!assistantUserId && channel === 'livechat' && - !isEnterprise(attrs) && AI_HANDLED_CATEGORIES.includes(r.category) ); }