Skip to content

Commit 0aff3ae

Browse files
committed
Start ingestion of tickets
1 parent 0368320 commit 0aff3ae

File tree

6 files changed

+282
-50
lines changed

6 files changed

+282
-50
lines changed

bun.lock

Lines changed: 4 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

integrations/zendesk-conversations/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
"private": true,
55
"dependencies": {
66
"@gitbook/runtime": "*",
7-
"@gitbook/api": "*"
7+
"@gitbook/api": "*",
8+
"p-map": "^7.0.3"
89
},
910
"devDependencies": {
1011
"@gitbook/cli": "workspace:*",

integrations/zendesk-conversations/src/client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export function getZendeskOAuthConfig(context: ZendeskRuntimeContext) {
1111
redirectURL: `${context.environment.integration.urls.publicEndpoint}/oauth`,
1212
clientId: context.environment.secrets.CLIENT_ID,
1313
clientSecret: context.environment.secrets.CLIENT_SECRET,
14-
scopes: ['tickets:read', 'webhooks:write', 'webhooks:read'],
14+
scopes: ['read', 'webhooks:write'],
1515
authorizeURL: (installation) => {
1616
const subdomain = assertInstallationSubdomain(installation);
1717
return `https://${subdomain}.zendesk.com/oauth/authorizations/new`
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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+
}

integrations/zendesk-conversations/src/index.tsx

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,62 @@
11
import {
22
createIntegration,
3-
createOAuthHandler,
3+
createOAuthHandler
44
} from '@gitbook/runtime';
55
import { ZendeskRuntimeContext } from './types';
66
import { configComponent } from './config';
77
import { getZendeskClient, getZendeskOAuthConfig } from './client';
8+
import { ingestTickets } from './conversations';
89

910
export default createIntegration<ZendeskRuntimeContext>({
10-
fetch: (request, context) => {
11-
const oauthHandler = createOAuthHandler(getZendeskOAuthConfig(context), { replace: false });
12-
return oauthHandler(request, context);
11+
fetch: async (request, context) => {
12+
const url = new URL(request.url);
13+
14+
if (url.pathname.endsWith('/webhook')) {
15+
const payload = await request.json();
16+
console.log(`Webhook received: ${JSON.stringify(payload)}`);
17+
return new Response('OK', { status: 200 });
18+
}
19+
20+
if (url.pathname.endsWith('/oauth')) {
21+
const oauthHandler = createOAuthHandler(getZendeskOAuthConfig(context), { replace: false });
22+
return oauthHandler(request, context);
23+
24+
}
25+
26+
return new Response('Not found', { status: 404 });
1327
},
1428
components: [configComponent],
1529
events: {
30+
/**
31+
* When the integration is installed, we:
32+
* - Setup a webhook to be notified when tickets are closed
33+
* - Fetch all recent tickets and ingest them
34+
*/
1635
installation_setup: async (event, context) => {
1736
const { installation } = context.environment;
1837
if (installation?.configuration.subdomain && installation?.configuration.oauth_credentials) {
19-
// Properly configured
2038
const client = await getZendeskClient(context);
2139

2240
// Recreate the webhook.
2341
const name = `GitBook - ${installation.target.organization}`;
2442
const { webhooks } = await client.listWebhooks({ name });
2543
await Promise.all(webhooks.map(webhook => client.deleteWebhook(webhook.id)));
26-
const webhook = await client.createWebhook({
44+
const { webhook } = await client.createWebhook({
2745
name: `GitBook - ${installation.target.organization}`,
2846
endpoint: `${installation.urls.publicEndpoint}/webhook`,
2947
subscriptions: [
3048
'zen:event-type:ticket.status_changed'
3149
]
3250
});
33-
34-
console.log('Webhook created', webhook);
51+
console.log(`Webhook created: ${webhook.id}`);
52+
53+
await ingestTickets(client, async (conversations) => {
54+
console.log(`Ingesting ${conversations.length} conversations`);
55+
await context.api.orgs.ingestConversation(installation.target.organization, conversations);
56+
}, {
57+
startTime: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30),
58+
maxTickets: 300,
59+
});
3560
}
3661
},
3762

0 commit comments

Comments
 (0)