Skip to content

Commit 5df9eff

Browse files
authored
Slack pagination and bug fixes (GitbookIO#70)
* Slack pagination and bug fixes The Slack integration had some issues, which this PR solves. Namely: - The channels endpoint didn't support pagination and that could lead to some unexpected behaviors due to the inherent weirdness of the slack API. See context here: https://github.com/GitbookIO/support-bucket/issues/961 - The README was missing the signing secret step for publishing an integration. - There was a bug where the signature validation code would lock the body by reading it, which made it throw once the event tried to read from it again. Solved with a request clone. - There was an issue in the slack manifests where we would call the events path for `url_verifications`, but that should happen under `events_task`. * Add changeset * Improve changeset with the PR description * Update the handle to keep asynchronicity * Forgot to remove a test return * Update integrations/slack/slack-manifest.yaml * Update integrations/slack/README.md
1 parent e36efa5 commit 5df9eff

File tree

8 files changed

+115
-28
lines changed

8 files changed

+115
-28
lines changed

.changeset/rare-bears-type.md

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@gitbook/integration-slack': patch
3+
'@gitbook/runtime': patch
4+
---
5+
6+
Fixed a few bugs in the slack integration
7+
- The channels endpoint didn't support pagination and that could lead to some unexpected behaviors due to the inherent weirdness of the slack API. See context here: https://github.com/GitbookIO/support-bucket/issues/961
8+
- The README was missing the signing secret step for publishing an integration.
9+
- There was a bug where the signature validation code would lock the body by reading it, which made it throw once the event tried to read from it again. Solved with a request clone.
10+
- There was an issue in the slack manifests where we would call the events path for `url_verifications`, but that should happen under `events_task`.

integrations/slack/README.md

+2-2
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
To configure it for your environment, create an application on Slack and use the `slack-manifest.yaml` file to configure it. Don't forget to update the URLs to natch your environment.
44

55

6-
To publish it, run the command with the `SLACK_CLIENT_ID` and `SLACK_CLIENT_SECRET` environment variables defined:
6+
To publish it, run the command with the `SLACK_CLIENT_ID`, `SLACK_CLIENT_SECRET`, and `SLACK_SIGNING_SECRET ` environment variables defined:
77

88
```
9-
SLACK_CLIENT_ID=xxx SLACK_CLIENT_SECRET=xxxx npm run publish-integrations
9+
SLACK_CLIENT_ID=xxx SLACK_CLIENT_SECRET=xxxx SLACK_SIGNING_SECRET=xxxx npm run publish-integrations
1010
```
1111

integrations/slack/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,4 @@
1818
"publish-integrations-staging": "gitbook publish .",
1919
"publish-integrations": "gitbook publish ."
2020
}
21-
}
21+
}

integrations/slack/src/events.ts

+13-4
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,15 @@ import { SlackRuntimeContext } from './configuration';
55
/**
66
* Handle an event from Slack.
77
*/
8-
export function createSlackEventsHandler(handlers: {
9-
[type: string]: (event: object, context: SlackRuntimeContext) => Promise<any>;
10-
}): FetchEventCallback {
8+
export function createSlackEventsHandler(
9+
handlers: {
10+
[type: string]: (event: object, context: SlackRuntimeContext) => Promise<any>;
11+
},
12+
fallback?: FetchEventCallback
13+
): FetchEventCallback {
1114
return async (request, context) => {
12-
const event = await request.json();
15+
// Clone the request so its body is still available to the fallback
16+
const event = await request.clone().json<{ event?: { type: string }; type?: string }>();
1317

1418
if (!event.type) {
1519
return new Response(`Invalid event`, {
@@ -18,8 +22,13 @@ export function createSlackEventsHandler(handlers: {
1822
}
1923

2024
const eventType = event.event?.type || event.type;
25+
// Find the handle for the event type, or use the fallback if that's missing
2126
const handler = handlers[eventType];
2227
if (!handler) {
28+
if (fallback) {
29+
return fallback(request, context);
30+
}
31+
2332
return new Response(`No handler for event type "${eventType}"`, {
2433
status: 404,
2534
});

integrations/slack/src/middlewares.ts

+7-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { Request } from 'itty-router';
21
import { sha256 } from 'js-sha256';
32

43
import { SlackRuntimeContext } from './configuration';
@@ -7,7 +6,10 @@ import { SlackRuntimeContext } from './configuration';
76
* Verify the authenticity of a Slack request.
87
* Reference - https://api.slack.com/authentication/verifying-requests-from-slack
98
*/
10-
export async function verifySlackRequest(req: Request, { environment }: SlackRuntimeContext) {
9+
export async function verifySlackRequest(request: Request, { environment }: SlackRuntimeContext) {
10+
// Clone the request as to not use up the only read the body allows for future requests
11+
const req = request.clone();
12+
1113
// @ts-ignore
1214
const slackSignature = req.headers.get('x-slack-signature');
1315
// @ts-ignore
@@ -48,7 +50,9 @@ export async function acknowledgeSlackRequest(req: Request) {
4850
});
4951

5052
return new Response(JSON.stringify({ acknowledged: true }), {
51-
'Content-Type': 'application/json',
53+
headers: {
54+
'Content-Type': 'application/json',
55+
},
5256
});
5357
}
5458

integrations/slack/src/router.ts

+15-16
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { createOAuthHandler, FetchEventCallback } from '@gitbook/runtime';
55
import { createSlackEventsHandler } from './events';
66
import { unfurlLink } from './links';
77
import { acknowledgeSlackRequest, verifySlackRequest } from './middlewares';
8-
import { slackAPI } from './slack';
8+
import { getChannelsPaginated } from './slack';
99

1010
/**
1111
* Handle incoming HTTP requests:
@@ -46,24 +46,26 @@ export const handleFetchEvent: FetchEventCallback = async (request, context) =>
4646
})
4747
);
4848

49-
router.post('/events', acknowledgeSlackRequest);
49+
router.post(
50+
'/events',
51+
verifySlackRequest,
52+
createSlackEventsHandler(
53+
{
54+
url_verification: async (event: { challenge: string }) => {
55+
return { challenge: event.challenge };
56+
},
57+
},
58+
acknowledgeSlackRequest
59+
)
60+
);
5061

5162
/*
5263
* List the channels the user can select in the configuration flow.
5364
*/
5465
router.get('/channels', async () => {
55-
// TODO: list from all pages
56-
const result = await slackAPI(context, {
57-
method: 'GET',
58-
path: 'conversations.list',
59-
payload: {
60-
limit: 1000,
61-
exclude_archived: true,
62-
types: 'public_channel,private_channel',
63-
},
64-
});
66+
const channels = await getChannelsPaginated(context);
6567

66-
const completions = result?.channels.map((channel) => ({
68+
const completions = channels.map((channel) => ({
6769
label: channel.name,
6870
value: channel.id,
6971
}));
@@ -82,9 +84,6 @@ export const handleFetchEvent: FetchEventCallback = async (request, context) =>
8284
'/events_task',
8385
verifySlackRequest,
8486
createSlackEventsHandler({
85-
url_verification: async (event) => {
86-
return { challenge: event.challenge };
87-
},
8887
link_shared: unfurlLink,
8988
})
9089
);

integrations/slack/src/slack.ts

+53
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,59 @@ import { SlackRuntimeContext } from './configuration';
44

55
const logger = Logger('slack:api');
66

7+
/**
8+
* Cloudflare workers have a maximum number of subrequests we can call (20 according to my
9+
* tests) https://developers.cloudflare.com/workers/platform/limits/#how-many-subrequests-can-i-make
10+
* TODO: Test with 50
11+
*/
12+
const maximumSubrequests = 20;
13+
14+
/**
15+
* Executes a Slack API request to fetch channels, handles pagination, then returns the merged
16+
* results.
17+
*/
18+
export async function getChannelsPaginated(context: SlackRuntimeContext) {
19+
const channels = [];
20+
21+
let response = await slackAPI(context, {
22+
method: 'GET',
23+
path: 'conversations.list',
24+
payload: {
25+
limit: 1000,
26+
exclude_archived: true,
27+
types: 'public_channel,private_channel',
28+
},
29+
});
30+
channels.push(...response?.channels);
31+
32+
let numberOfCalls = 0;
33+
// Handle pagination. Slack can be weird when it comes to paginating requests and not return
34+
// all the channels even if we tell it to fetch a thousand of them. Pagination may be called
35+
// even with workspaces with less than 1000 channels. We also keep a reference to number of
36+
// subsequent calls to avoid hitting the hard limit of calls on workers.
37+
while (response.response_metadata.next_cursor && numberOfCalls < maximumSubrequests) {
38+
logger.debug('Request was paginated, calling the next cursor');
39+
response = await slackAPI(context, {
40+
method: 'GET',
41+
path: 'conversations.list',
42+
payload: {
43+
limit: 1000,
44+
exclude_archived: true,
45+
types: 'public_channel,private_channel',
46+
cursor: response.response_metadata.next_cursor,
47+
},
48+
});
49+
channels.push(...response?.channels);
50+
numberOfCalls++;
51+
}
52+
53+
// Remove any duplicate as the pagination API could return duplicated channels
54+
return channels.filter(
55+
(value, index, self) =>
56+
index === self.findIndex((t) => t.place === value.place && t.id === value.id)
57+
);
58+
}
59+
760
/**
861
* Execute a Slack API request and return the result.
962
*/

packages/runtime/src/oauth.ts

+14-2
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,18 @@ import { RequestUpdateIntegrationInstallation } from '@gitbook/api';
33
import { RuntimeCallback } from './context';
44
import { Logger } from './logger';
55

6+
/**
7+
* Utility interface for typing the response from a Slack OAuth call.
8+
* TODO: Use the official Slack type
9+
*/
10+
export interface OAuthResponse {
11+
ok?: boolean;
12+
access_token: string;
13+
team: {
14+
id: string;
15+
};
16+
}
17+
618
export interface OAuthConfig {
719
/**
820
* Redirect URL to use. When the OAuth identity provider only accept a static one.
@@ -33,7 +45,7 @@ export interface OAuthConfig {
3345
* Extract the credentials from the code exchange response.
3446
*/
3547
extractCredentials?: (
36-
response: object
48+
response: OAuthResponse
3749
) => RequestUpdateIntegrationInstallation | Promise<RequestUpdateIntegrationInstallation>;
3850
}
3951

@@ -110,7 +122,7 @@ export function createOAuthHandler(
110122
throw new Error('Failed to exchange code for access token');
111123
}
112124

113-
const json = await response.json();
125+
const json = await response.json<OAuthResponse>();
114126

115127
if (!json.ok) {
116128
throw new Error(`Failed to exchange code for access token ${JSON.stringify(json)}`);

0 commit comments

Comments
 (0)