|
| 1 | +import pMap from "p-map"; |
| 2 | +import { ZendeskClient, ZendeskTicket } from "./zendesk"; |
| 3 | +import { ConversationInput, ConversationPartMessage } from '@gitbook/api'; |
| 4 | + |
| 5 | +/** |
| 6 | + * Ingest the last closed tickets from Zendesk. |
| 7 | + */ |
| 8 | +export async function ingestTickets( |
| 9 | + client: ZendeskClient, |
| 10 | + onConversations: (conversation: ConversationInput[]) => Promise<void>, |
| 11 | + options: { |
| 12 | + /** From when to ingest tickets. */ |
| 13 | + startTime: Date; |
| 14 | + /** Maximum number of tickets to ingest. */ |
| 15 | + maxTickets: number; |
| 16 | + } |
| 17 | +) { |
| 18 | + let cursor: string | null = null; |
| 19 | + let ticketsIngested = 0; |
| 20 | + |
| 21 | + do { |
| 22 | + const { tickets, after_cursor } = await client.listTicketsIncremental({ |
| 23 | + startTime: options.startTime, |
| 24 | + cursor, |
| 25 | + }); |
| 26 | + cursor = after_cursor; |
| 27 | + |
| 28 | + const closedTickets = tickets.filter(isTicketClosed); |
| 29 | + ticketsIngested += closedTickets.length; |
| 30 | + |
| 31 | + const conversations = await pMap(closedTickets, async (ticket) => { |
| 32 | + return await parseTicketAsConversation(client, ticket); |
| 33 | + }, { |
| 34 | + concurrency: 3 |
| 35 | + }); |
| 36 | + |
| 37 | + await onConversations(conversations); |
| 38 | + } while (ticketsIngested < options.maxTickets && !!cursor); |
| 39 | +} |
| 40 | + |
| 41 | +/** |
| 42 | + * Fetch the conversation from Zendesk and parse it into a GitBook conversation. |
| 43 | + */ |
| 44 | +export async function parseTicketAsConversation(client: ZendeskClient, ticket: ZendeskTicket): Promise<ConversationInput> { |
| 45 | + if (!isTicketClosed(ticket)) { |
| 46 | + throw new Error(`Ticket ${ticket.id} is not closed or solved`); |
| 47 | + } |
| 48 | + |
| 49 | + const comments = await client.listTicketComments(ticket.id); |
| 50 | + const conversation: ConversationInput = { |
| 51 | + id: `${client.subdomain}/${ticket.id}`, |
| 52 | + subject: ticket.subject, |
| 53 | + metadata: { |
| 54 | + attributes: { |
| 55 | + id: ticket.id.toString(), |
| 56 | + subdomain: client.subdomain, |
| 57 | + }, |
| 58 | + createdAt: ticket.created_at, |
| 59 | + }, |
| 60 | + parts: await pMap(comments.comments, async (comment) => { |
| 61 | + return { |
| 62 | + type: 'message', |
| 63 | + role: await getUserRole(client, comment.author_id), |
| 64 | + body: comment.plain_body, |
| 65 | + } |
| 66 | + }, { |
| 67 | + concurrency: 3, |
| 68 | + }), |
| 69 | + }; |
| 70 | + |
| 71 | + return conversation; |
| 72 | +} |
| 73 | + |
| 74 | +const cachedRoles = new Map<string, ConversationPartMessage['role']>(); |
| 75 | + |
| 76 | +/** |
| 77 | + * Evaluate if a user is an end-user or a team member. |
| 78 | + * We cache the result to avoid making too many requests to Zendesk. |
| 79 | + */ |
| 80 | +async function getUserRole(client: ZendeskClient, userId: number): Promise<ConversationPartMessage['role']> { |
| 81 | + const cacheKey = `${client.subdomain}/${userId}`; |
| 82 | + const cachedRole = cachedRoles.get(cacheKey); |
| 83 | + if (cachedRole) return cachedRole; |
| 84 | + |
| 85 | + const { user } = await client.getUser(userId); |
| 86 | + const role = user.role === 'end-user' ? 'user' : 'team-member'; |
| 87 | + cachedRoles.set(cacheKey, role); |
| 88 | + return role; |
| 89 | +} |
| 90 | + |
| 91 | +/** |
| 92 | + * Check if a ticket is closed or solved. |
| 93 | + */ |
| 94 | +function isTicketClosed(ticket: ZendeskTicket) { |
| 95 | + return ticket.status === 'closed' || ticket.status === 'solved'; |
| 96 | +} |
0 commit comments