import type { BlipClient } from '../client.ts' import { BlipError } from '../sender/bliperror.ts' import { type AttendanceHour, type DeskAttendanceQueue, type DeskPriorityRule, type DeskRule, type DetailedAttendanceHour, type Identity, Node, type ThreadItem, type Ticket, type TicketStatus, } from '../types/index.ts' import type { WhatsAppTemplateLanguage } from '../types/whatsapp.ts' import { isOnAttendanceTime as isDetailedAttendanceHourOnAttendanceTime, matchesAttendanceHourQueue, } from '../utils/desk.ts' import type { ODataFilter } from '../utils/odata.ts' import { uri } from '../utils/uri.ts' import { type ConsumeOptions, Namespace, type SendCommandOptions } from './namespace.ts' export class DeskNamespace extends Namespace { constructor(blipClient: BlipClient, defaultOptions?: SendCommandOptions) { super(blipClient, 'desk', defaultOptions) } public async createTicket(contact: Identity, opts?: ConsumeOptions): Promise { return await this.sendCommand( { method: 'set', uri: uri`/tickets`, type: 'application/vnd.iris.ticket+json', resource: { customerIdentity: contact, }, }, opts, ) } public async getTicket(ticket: string, opts?: ConsumeOptions): Promise { return await this.sendCommand( { method: 'get', uri: uri`/ticket/${ticket}`, }, opts, ) } public async getTicketMessages(ticket: Ticket, opts?: Omit): Promise> public async getTicketMessages(ticket: string, opts?: Omit): Promise> public async getTicketMessages( ticketOrTicketId: Ticket | string, opts?: Omit, ): Promise> { const ticket = typeof ticketOrTicketId === 'string' ? await this.getTicket(ticketOrTicketId, opts) : ticketOrTicketId // Include the message that originated the ticket const storageDate = new Date(ticket.storageDate) storageDate.setSeconds(storageDate.getSeconds() - 10) const thread = await this.blipClient.account.getThread( ticket.customerIdentity, { referenceDate: storageDate, referenceDateDirection: 'after', }, opts, ) return thread .filter((item) => new Date(item.date) >= new Date(storageDate)) .filter((item) => !ticket.closeDate || new Date(item.date) <= new Date(ticket.closeDate)) } public async getTicketsMetrics(opts?: ConsumeOptions) { const metrics = await this.sendCommand< 'get', { maxQueueTime: string maxFirstResponseTime: string avgQueueTime: string avgFirstResponseTime: string avgWaitTime: string avgResponseTime: string avgAttendanceTime: string ticketsPerAttendant: number } >( { method: 'get', uri: uri`/monitoring/ticket-metrics?${{ version: 2 }}`, }, opts, ) return { ...metrics, ticketsPerAttendant: Number(metrics.ticketsPerAttendant), } } public async getWaitingTicketsMetrics( filters?: { teams?: Array tags?: Array operators?: Array }, opts?: ConsumeOptions, ): Promise< Array<{ id: string sequentialId: number customerIdentity: Identity customerName: string team: string queueTime: string priority: number agentIdentity?: Identity agentName?: string }> > { return await this.sendCommand( { method: 'get', uri: uri`/monitoring/waiting-tickets?${{ teams: filters?.teams?.join(','), tags: filters?.tags?.join(','), operators: filters?.operators?.join(','), }}`, }, { collection: true, take: opts?.fetchall ? (opts?.max ?? 1_000_000) : opts?.take, fetchall: false, ...opts, }, ) } /** @param filters.filter - Optional OData filter applied by the Desk backend when listing queues. */ public async getQueues( filters?: { filter?: ODataFilter }, opts?: ConsumeOptions, ): Promise> { return await this.sendCommand( { method: 'get', uri: uri`/attendance-queues?${{ $filter: filters?.filter?.toString(), }}`, }, { collection: true, ...opts, }, ) } public async getQueue(queueId: string, opts?: ConsumeOptions): Promise { return await this.sendCommand( { method: 'get', uri: uri`/attendance-queues/${queueId}`, }, opts, ) } public async getQueueByName(queueName: string, opts?: ConsumeOptions): Promise { return await this.sendCommand( { method: 'get', uri: uri`/attendance-queues/name/${queueName}`, }, opts, ) } /** * @param queue.id - Optional queue id. If omitted, Iris generates one from `uniqueId`. * @param queue.ownerIdentity - Optional owner identity. Iris usually infers it from the command context. * @param queue.name - Queue display name. * @param queue.isActive - Whether the queue should accept new tickets. * @param queue.storageDate - Optional original creation date for updates. * @param queue.Priority - Optional queue ordering priority. * @param queue.uniqueId - Optional queue unique id. Iris generates one when missing. * @param queue.attendanceHourId - Optional attendance hour id linked to this queue. */ public async setQueue(queue: DeskAttendanceQueue, opts?: ConsumeOptions): Promise { return await this.sendCommand( { method: 'set', uri: uri`/attendance-queues`, type: 'application/vnd.iris.desk.attendancequeue+json', resource: queue, }, opts, ) } public async deleteQueue(queueId: string, opts?: ConsumeOptions): Promise { return await this.sendCommand( { method: 'delete', uri: uri`/attendance-queues/${queueId}`, }, opts, ) } /** @param filters.filter - Optional OData filter applied to the stored rules. */ public async getRules( filters?: { filter?: ODataFilter }, opts?: ConsumeOptions, ): Promise> { return await this.sendCommand( { method: 'get', uri: uri`/rules?${{ $filter: filters?.filter?.toString(), }}`, }, { collection: true, ...opts, }, ) } public async getRule(ruleId: string, opts?: ConsumeOptions): Promise { return await this.sendCommand( { method: 'get', uri: uri`/rules/${ruleId}`, }, opts, ) } /** * @param queue - Queue selector used by `/rules/queue/{queue}`. Iris matches this value against the rule `team` field. * @param filters.filter - Optional OData filter applied after the queue selector. */ public async getRulesByQueue( queue: string, filters?: { filter?: ODataFilter }, opts?: ConsumeOptions, ): Promise> { return await this.sendCommand( { method: 'get', uri: uri`/rules/queue/${queue}?${{ $filter: filters?.filter?.toString(), }}`, }, { collection: true, ...opts, }, ) } /** * @param rule.id - Optional existing rule id. * @param rule.ownerIdentity - Optional owner identity override. * @param rule.title - Rule display name. * @param rule.team - Queue name that receives matching tickets. * @param rule.property - Legacy single-condition property. Prefer `conditions`. * @param rule.relation - Legacy single-condition relation. Prefer `conditions`. * @param rule.isActive - Whether the rule can be applied to new tickets. * @param rule.values - Legacy single-condition values. Prefer `conditions`. * @param rule.conditions - Preferred list of rule conditions evaluated by Iris. * @param rule.operator - Logical operator used between `conditions`. * @param rule.priority - Optional explicit processing order. * @param rule.storageDate - Optional original creation date for updates. * @param rule.queueId - Optional queue unique id. When omitted, Iris resolves it from `team`. */ public async setRule(rule: DeskRule, opts?: ConsumeOptions): Promise { return await this.sendCommand( { method: 'set', uri: uri`/rules`, type: 'application/vnd.iris.desk.rule+json', resource: rule, }, opts, ) } public async deleteRule(ruleId: string, opts?: ConsumeOptions): Promise { return await this.sendCommand( { method: 'delete', uri: uri`/rules/${ruleId}`, }, opts, ) } public async getPriorityRules(opts?: ConsumeOptions): Promise> { return await this.sendCommand( { method: 'get', uri: uri`/priority-rules`, }, { collection: true, ...opts, }, ) } public async getPriorityRule(ruleId: string, opts?: ConsumeOptions): Promise { return await this.sendCommand( { method: 'get', uri: uri`/priority-rules/${ruleId}`, }, opts, ) } /** * @param queueId - Queue unique id used by the priority rule engine. * @param filters.filter - Optional OData filter applied after the queue selector. */ public async getPriorityRulesByQueue( queueId: string, filters?: { filter?: ODataFilter }, opts?: ConsumeOptions, ): Promise> { return await this.sendCommand( { method: 'get', uri: uri`/priority-rules/queue/${queueId}?${{ $filter: filters?.filter?.toString(), }}`, }, { collection: true, ...opts, }, ) } /** * @param rule.id - Optional existing rule id. Iris generates one when missing. * @param rule.ownerIdentity - Optional owner identity override. * @param rule.title - Rule display name. * @param rule.queueId - Queue unique id that receives the urgency. * @param rule.isActive - Whether the rule can be applied to new tickets. * @param rule.conditions - Conditions evaluated when `applyConditions` is true. * @param rule.operator - Logical operator used between `conditions`. * @param rule.priority - Optional explicit processing order. * @param rule.urgency - Priority boost applied to matching tickets. * @param rule.applyConditions - When false, Iris applies the rule without evaluating `conditions`. * @param rule.storageDate - Optional original creation date for updates. */ public async setPriorityRule(rule: DeskPriorityRule, opts?: ConsumeOptions): Promise { return await this.sendCommand( { method: 'set', uri: uri`/priority-rules`, type: 'application/vnd.iris.desk.priority-rules+json', resource: rule, }, opts, ) } public async deletePriorityRule(ruleId: string, opts?: ConsumeOptions): Promise { return await this.sendCommand( { method: 'delete', uri: uri`/priority-rules/${ruleId}`, }, opts, ) } /** @param filters.filter - Optional OData filter applied to the attendance-hour summary list. */ public async getAttendanceHours( filters?: { filter?: ODataFilter }, opts?: ConsumeOptions, ): Promise> { return await this.sendCommand( { method: 'get', uri: uri`/attendance-hour?${{ $filter: filters?.filter?.toString(), }}`, }, { collection: true, ...opts, }, ) } public async getAttendanceHourContainer( attendanceHourId: string, opts?: ConsumeOptions, ): Promise { return await this.sendCommand( { method: 'get', uri: uri`/attendance-hour-container/${attendanceHourId}`, }, opts, ) } /** * @param attendanceHour.attendanceHour - Summary fields for the attendance hour itself. * @param attendanceHour.attendanceHourScheduleItems - Required weekly schedules for the attendance hour. * @param attendanceHour.attendanceHourOffItems - Optional date ranges where attendance is suspended. * @param attendanceHour.queues - Queue ids linked to this attendance hour. * @param attendanceHour.attendants - Agent identities linked to this attendance hour. */ public async setAttendanceHour(attendanceHour: DetailedAttendanceHour, opts?: ConsumeOptions): Promise { return await this.sendCommand( { method: 'set', uri: uri`/attendance-hour`, type: 'application/vnd.iris.desk.attendance-hour-container+json', resource: attendanceHour, }, opts, ) } public async deleteAttendanceHour(attendanceHourId: string, opts?: ConsumeOptions): Promise { return await this.sendCommand( { method: 'delete', uri: uri`/attendance-hour/${attendanceHourId}`, }, opts, ) } public async getTickets(filter?: ODataFilter, opts?: ConsumeOptions): Promise> { return await this.sendCommand( { method: 'get', uri: uri`/tickets?${{ $filter: filter?.toString() }}`, }, { collection: true, ...opts, }, ) } /** * @param filters.beginDate - Required. Start of the `storageDate` range, applied inclusively. * @param filters.endDate - Required. End of the `storageDate` range. Exact-second timestamps are treated as exclusive, * while timestamps with a non-zero seconds component are treated as inclusive. * @param filters.teams - Filters by ticket team. Values are combined as alternatives. * @param filters.agentIdentities - Filters by ticket agent identity. Values are combined as alternatives. * @param filters.customerIdentities - Filters by ticket customer identity. Values are combined as alternatives. * @param filters.tags - Matches tickets that contain at least one of the provided tags. * @param filters.ticketIds - Filters by the Desk sequential ticket id, not the ticket UUID `id`. * @param filters.includeIdentitiesNames - Makes the backend try to resolve `agentName` and `customerName`. */ public async getTicketsHistory( filters: { beginDate: string | Date endDate: string | Date teams?: Array agentIdentities?: Array customerIdentities?: Array tags?: Array ticketIds?: Array includeIdentitiesNames?: boolean }, opts?: ConsumeOptions, ): Promise< Array<{ id: string sequentialId: number customerIdentity: Identity customerName?: string agentIdentity?: Identity agentName?: string status: TicketStatus team?: string waitingTimeInSeconds?: number firstResponseTimeInSeconds?: number attendanceTimeInSeconds?: number hasWaitingTimeSlaExceeded: boolean hasFirstResponseTimeSlaExceeded: boolean hasAttendanceTimeSlaExceeded: boolean }> > { return await this.sendCommand( { method: 'get', uri: uri`/tickets/history/v2?${{ beginDate: filters.beginDate, endDate: filters.endDate, teams: filters.teams?.join(','), agentIdentities: filters.agentIdentities?.join(','), customerIdentities: filters.customerIdentities?.join(','), tags: filters.tags?.join(','), ticketIds: filters.ticketIds?.join(','), includeIdentitiesNames: filters.includeIdentitiesNames, }}`, }, { collection: true, ...opts, }, ) } public async getContactTickets(contact: Identity, opts?: ConsumeOptions): Promise> { return await this.sendCommand( { method: 'get', uri: uri`/tickets/history-merged/${contact}`, }, { collection: true, ...opts, }, ) } public async changeTicketStatus( ticket: string, status: TicketStatus, settings?: { agent?: Identity closedBy?: Identity tags?: Array }, opts?: ConsumeOptions, ): Promise { try { return await this.sendCommand( { method: 'set', uri: uri`/tickets/change-status`, type: 'application/vnd.iris.ticket+json', resource: { id: ticket, status, tags: settings?.tags, agentIdentity: settings?.agent, closedBy: settings?.closedBy, }, }, opts, ) } catch (err) { if (err instanceof BlipError && err.code === 64) { return } throw err } } public async changeTicketTags(ticket: string, tags: Array, opts?: ConsumeOptions): Promise { return await this.sendCommand( { method: 'set', uri: uri`/tickets/${ticket}/change-tags`, type: 'application/vnd.iris.ticket+json', resource: { id: ticket, tags, }, }, opts, ) } public async isOnAttendanceTime(queueIdOrName = 'Default', timezone = -3): Promise { const attendanceHours = await this.getAttendanceHours() const orderedAttendanceHours = attendanceHours // Prioritize non-main attendance hours .sort((a, b) => Number(a.isMain) - Number(b.isMain)) for (const attendanceHour of orderedAttendanceHours) { const detailedAttendanceHour = await this.getAttendanceHourContainer(attendanceHour.id) const now = new Date(Date.now() + timezone * 60 * 60 * 1000) if (!matchesAttendanceHourQueue(detailedAttendanceHour, queueIdOrName)) { // Should keep trying other attendance hours to match the queue continue } if (isDetailedAttendanceHourOnAttendanceTime(detailedAttendanceHour, now)) { return true } return false } return false } public async getApprovedMessageTemplates(opts?: ConsumeOptions): Promise< Array<{ id: string is_favorite: boolean category: string language: WhatsAppTemplateLanguage last_updated_time: string name: string status: 'APPROVED' | 'REJECTED' | 'PENDING' }> > { return await this.sendCommand( { method: 'get', uri: uri`/active-message/approved-message-templates`, }, { collection: true, ...opts, }, ) } public async getMessageTemplateParams(opts?: ConsumeOptions) { const params: Array<{ params?: { paramsId: number ownerIdentity: string templateId: string stateId: string isEnabled: boolean storageDate: string modifyDate: string } template: { Id: string Category: string Components: Array< { Type: string } & Record > Language: WhatsAppTemplateLanguage LastUpdatedTime: string Name: string RejectedReason: string Status: 'APPROVED' | 'REJECTED' | 'PENDING' } }> = await this.sendCommand( { method: 'get', uri: uri`/active-message/message-templates-params`, }, { collection: true, ...opts, }, ) return params.map((param) => ({ template: param.template.Name, category: param.template.Category, language: param.template.Language, status: param.template.Status, ownerIdentity: param.params?.ownerIdentity, stateId: param.params?.stateId, isEnabled: param.params?.isEnabled, components: param.template.Components.map((c) => ({ type: c.Type, text: c.Text as string | undefined, })), })) } public async sendIndividualActiveMessage( phoneNumber: string, message: { template: string; language: WhatsAppTemplateLanguage; variables: Array }, sender: Node, opts?: ConsumeOptions, ) { await this.sendCommand( { method: 'set', uri: uri`/active-message/send-active-message`, type: 'application/vnd.iris.activecampaign.full-campaign+json', resource: { campaign: { campaignType: 'Individual', CampaignSender: sender, attendanceRedirect: sender, }, audience: { recipient: phoneNumber, recipientType: 'phoneNumber', channelType: 'whatsapp', messageParams: { ...message.variables }, }, message: { messageTemplate: message.template, channelType: 'whatsapp', messageParams: message.variables.length ? Object.keys(message.variables).map(Number) : '', messageTemplateLanguage: message.language, }, }, }, opts, ) } public async getTeamsAgentsOnline(opts?: ConsumeOptions): Promise< Array<{ name: string agentsOnline: number }> > { return await this.sendCommand( { method: 'get', uri: uri`/teams/agents-online`, }, { collection: true, ...opts, }, ) } public async getAgents(opts?: ConsumeOptions): Promise< Array<{ email: string fullName: string identity: Identity isEnabled: boolean status: 'Offline' | 'Pause' | 'Online' | 'Invisible' teams: Array }> > { try { return await this.sendCommand( { method: 'get', uri: uri`/agents`, }, { collection: true, ...opts, }, ) } catch (err) { if (err instanceof BlipError && err.code === 67) { return [] } throw err } } /** @returns The new child ticket created from the transfer */ public async transferTicket( ticket: string, teamOrAgentIdentity: Identity | string, transferredBy?: Identity, opts?: ConsumeOptions, ): Promise { return await this.sendCommand( { method: 'set', uri: uri`/tickets/${ticket}/transfer`, type: 'application/vnd.iris.ticket+json', resource: { team: Node.isValid(teamOrAgentIdentity) ? 'DIRECT_TRANSFER' : teamOrAgentIdentity, agentIdentity: Node.isValid(teamOrAgentIdentity) ? teamOrAgentIdentity : undefined, closedBy: transferredBy, }, }, opts, ) } public async sendTicketSurveyAnswer( ticket: string, survey: string, details: { answer: string answerScore: number comment?: string }, opts?: ConsumeOptions, ) { return await this.sendCommand( { method: 'set', uri: uri`/attendance-survey-answer`, type: 'application/vnd.iris.desk.attendance-survey-answer+json', resource: { ticketId: ticket, survey: survey, answer: details.answer, answerScore: details.answerScore.toString(), comment: details.comment ?? '', }, }, opts, ) } public async getTags(team?: string, opts?: ConsumeOptions): Promise> { if (team && team !== 'DIRECT_TRANSFER') { const result = await this.sendCommand<'get', Array<{ tag: string }>>( { method: 'get', uri: uri`/attendance-queues/name/${team}/tags`, }, { collection: true, ...opts, }, ) return result.map((tag) => tag.tag) } else { const bucketTags = await this.blipClient.account.getBucket('blip:desk:tags') const { tags } = JSON.parse(bucketTags ?? '{}') return tags?.map((tag: { text: string }) => tag.text) ?? [] } } public async getSummary( ticket: string, opts?: ConsumeOptions, ): Promise<{ summary: { customer_data: { name: string } contact: { date: string reason: string conclusion: string sentiment: string } } }> { return await this.sendCommand( { method: 'get', uri: uri`/tickets/${ticket}/copilot/thread-end-summary`, }, opts, ) } public async setCustomChannel( url: string, settings?: { token?: string }, opts?: ConsumeOptions, ) { const configurations = await this.blipClient.account.getConfigurations({ ...opts, ownerIdentity: this.identity, }) if (!configurations?.DefaultProvider) { throw new Error('No default provider found, Blip Desk is not enabled') } await this.blipClient.account.setConfigurations( { DefaultProvider: 'Webhook', 'Webhook.ApiEndpoint': url, 'Webhook.AuthenticationToken': settings?.token ?? '', }, { ...opts, ownerIdentity: this.identity, }, ) } public async getCustomChannel(opts?: ConsumeOptions): Promise<{ url: string token: string }> { const configurations = await this.blipClient.account.getConfigurations({ ...opts, ownerIdentity: this.identity, }) if (!configurations?.DefaultProvider) { throw new Error('No default provider found') } if (configurations.DefaultProvider !== 'Webhook') { throw new Error('Default provider is not Webhook') } return { url: configurations['Webhook.ApiEndpoint'], token: configurations['Webhook.AuthenticationToken'], } } public async addExtension( id: string, settings: { name: string view: 'ticket' | 'agent' url: string }, opts?: ConsumeOptions, ): Promise { const configurations = await this.blipClient.account.getConfigurations({ ...opts, ownerIdentity: this.identity, }) const extensions = 'Extensions' in configurations ? JSON.parse(configurations.Extensions) : {} extensions[id] = settings return await this.blipClient.account.setConfigurations( { Extensions: JSON.stringify(extensions) }, { ...opts, ownerIdentity: this.identity }, ) } public async updateExtension( id: string, settings: Partial<{ name: string view: 'ticket' | 'agent' url: string }>, opts?: ConsumeOptions, ): Promise { const configurations = await this.blipClient.account.getConfigurations({ ...opts, ownerIdentity: this.identity, }) if ('Extensions' in configurations) { const extensions = JSON.parse(configurations.Extensions) as Record if (id in extensions) { extensions[id] = { ...extensions[id], ...settings } return await this.blipClient.account.setConfigurations( { Extensions: JSON.stringify(extensions) }, { ...opts, ownerIdentity: this.identity }, ) } else { throw new Error(`Extension with id "${id}" not found`) } } else { throw new Error('No extensions found') } } public async deleteExtension(id: string, opts?: ConsumeOptions): Promise public async deleteExtension(url: string | RegExp, opts?: ConsumeOptions): Promise public async deleteExtension(idOrUrl: string | RegExp, opts?: ConsumeOptions): Promise { const configurations = await this.blipClient.account.getConfigurations({ ...opts, ownerIdentity: this.identity, }) if ('Extensions' in configurations) { const extensions = JSON.parse(configurations.Extensions) as Record for (const id in extensions) { if ( (idOrUrl instanceof RegExp && idOrUrl.test(extensions[id].url)) || (typeof idOrUrl === 'string' && (idOrUrl === id || extensions[id].url === idOrUrl)) ) { delete extensions[id] } } return await this.blipClient.account.setConfigurations( { Extensions: JSON.stringify(extensions) }, { ...opts, ownerIdentity: this.identity }, ) } } public async createAgent( agent: { identity: Identity teams: Array agentSlots?: number }, opts?: ConsumeOptions, ): Promise { return await this.sendCommand( { method: 'set', uri: uri`/agents`, type: 'application/vnd.iris.desk.attendant+json', resource: agent, }, opts, ) } public async deleteAgent(identity: Identity, opts?: ConsumeOptions): Promise { return await this.sendCommand( { method: 'delete', uri: uri`/agents/${identity}`, }, { ...opts, }, ) } }