Skip to content

Commit f626eb5

Browse files
authored
fix(bot): clean up Slack integration on uninstall (#2987)
* fix(bot): clean up Slack integration on uninstall * Simplify logic
1 parent be643b2 commit f626eb5

3 files changed

Lines changed: 153 additions & 2 deletions

File tree

apps/web/src/lib/bot.ts

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import { Chat, type ActionEvent, type Message, type Thread } from 'chat';
1+
import crypto from 'node:crypto';
2+
import { Chat, type ActionEvent, type Message, type Thread, type WebhookOptions } from 'chat';
23
import { createSlackAdapter, SlackAdapter } from '@chat-adapter/slack';
34
import { captureException } from '@sentry/nextjs';
45
import type { HomeView } from '@slack/types';
5-
import { resolveKiloUserId, unlinkKiloUser } from '@/lib/bot-identity';
6+
import { resolveKiloUserId, unlinkKiloUser, unlinkTeamKiloUsers } from '@/lib/bot-identity';
67
import { isSlackMissingScopeError, postSlackReinstallInstruction } from '@/lib/bot/helpers';
8+
import { deleteInstallationByTeamId } from '@/lib/integrations/slack-service';
79
import {
810
getPlatformIdentity,
911
getPlatformIntegration,
@@ -37,11 +39,125 @@ const SLACK_ASSISTANT_SUGGESTED_PROMPTS = [
3739

3840
const ASSISTANT_PROMPTS_TITLE = 'Try asking Kilo Bot';
3941

42+
const SLACK_SIGNATURE_VERSION = 'v0';
43+
const SLACK_SIGNATURE_TOLERANCE_SECONDS = 60 * 5;
44+
4045
const SLACK_CHANNEL_INVITE_MESSAGE = {
4146
markdown:
4247
"Hey, I'm Kilo, an AI coding assistant. Mention me in this channel when you want help investigating bugs, reviewing PRs, explaining code, or starting implementation work. AI can make mistakes, so please review responses before relying on them. Sessions created with Kilo from Slack are stored at https://app.kilo.ai.",
4348
} as const;
4449

50+
type SlackAppUninstalledPayload = {
51+
type: 'event_callback';
52+
team_id: string;
53+
event: {
54+
type: 'app_uninstalled';
55+
};
56+
};
57+
58+
function verifySlackSignature(body: string, request: Request): boolean {
59+
const timestamp = request.headers.get('x-slack-request-timestamp');
60+
const signature = request.headers.get('x-slack-signature');
61+
62+
if (!timestamp || !signature) return false;
63+
64+
const timestampSeconds = Number.parseInt(timestamp, 10);
65+
if (Number.isNaN(timestampSeconds)) return false;
66+
67+
const nowSeconds = Math.floor(Date.now() / 1000);
68+
if (Math.abs(nowSeconds - timestampSeconds) > SLACK_SIGNATURE_TOLERANCE_SECONDS) {
69+
return false;
70+
}
71+
72+
const signatureBaseString = `${SLACK_SIGNATURE_VERSION}:${timestamp}:${body}`;
73+
const expectedSignature = `${SLACK_SIGNATURE_VERSION}=${crypto
74+
.createHmac('sha256', SLACK_SIGNING_SECRET)
75+
.update(signatureBaseString)
76+
.digest('hex')}`;
77+
78+
try {
79+
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature));
80+
} catch {
81+
return false;
82+
}
83+
}
84+
85+
function isSlackAppUninstalledPayload(payload: unknown): payload is SlackAppUninstalledPayload {
86+
return (
87+
!!payload &&
88+
typeof payload === 'object' &&
89+
'type' in payload &&
90+
payload.type === 'event_callback' &&
91+
'team_id' in payload &&
92+
typeof payload.team_id === 'string' &&
93+
'event' in payload &&
94+
!!payload.event &&
95+
typeof payload.event === 'object' &&
96+
'type' in payload.event &&
97+
payload.event.type === 'app_uninstalled'
98+
);
99+
}
100+
101+
async function handleSlackAppUninstalled(teamId: string, chatBot: Chat): Promise<void> {
102+
try {
103+
await deleteInstallationByTeamId(teamId);
104+
await slackAdapter.deleteInstallation(teamId);
105+
await unlinkTeamKiloUsers(chatBot.getState(), 'slack', teamId);
106+
} catch (error) {
107+
captureException(error, {
108+
level: 'error',
109+
tags: { component: 'kilo-bot', op: 'slack-app-uninstalled' },
110+
extra: { teamId },
111+
});
112+
}
113+
}
114+
115+
async function handleSlackWebhook(
116+
request: Request,
117+
options: WebhookOptions | undefined,
118+
chatBot: Chat,
119+
slackAdapter: SlackAdapter
120+
): Promise<Response> {
121+
const body = await request.text();
122+
123+
if (!verifySlackSignature(body, request)) {
124+
return new Response('Invalid signature', { status: 401 });
125+
}
126+
127+
await chatBot.initialize();
128+
129+
let payload: unknown;
130+
try {
131+
payload = JSON.parse(body);
132+
} catch {
133+
return slackAdapter.handleWebhook(cloneSlackRequest(request, body), options);
134+
}
135+
136+
if (isSlackAppUninstalledPayload(payload)) {
137+
try {
138+
await handleSlackAppUninstalled(payload.team_id, chatBot);
139+
} catch (error) {
140+
console.error('[Bot] Failed to handle Slack app_uninstalled event:', error);
141+
captureException(error, {
142+
tags: { component: 'kilo-bot', op: 'slack-app-uninstalled' },
143+
extra: { teamId: payload.team_id },
144+
});
145+
}
146+
147+
return new Response('ok', { status: 200 });
148+
}
149+
150+
return slackAdapter.handleWebhook(cloneSlackRequest(request, body), options);
151+
}
152+
153+
function cloneSlackRequest(request: Request, body: BodyInit): Request {
154+
return new Request(request.url, {
155+
method: request.method,
156+
headers: request.headers,
157+
body,
158+
});
159+
}
160+
45161
export function buildSlackAppHomeView() {
46162
return {
47163
type: 'home',
@@ -136,6 +252,9 @@ function createKiloBot(slackAdapter: ReturnType<typeof createSlackAdapter>) {
136252
state: createChatState(),
137253
});
138254

255+
chatBot.webhooks.slack = (request, options) =>
256+
handleSlackWebhook(request, options, chatBot, slackAdapter);
257+
139258
chatBot.onNewMention(async function handleIncomingMessage(
140259
thread: Thread,
141260
message: Message

apps/web/src/lib/integrations/slack-service.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ jest.mock('@slack/web-api', () => ({
3939
import type { Owner } from '@/lib/integrations/core/types';
4040
import type { SlackInstallation } from '@chat-adapter/slack';
4141
import {
42+
deleteInstallationByTeamId,
4243
getMissingSlackScopes,
4344
SLACK_SCOPES,
4445
testConnection,
@@ -137,6 +138,25 @@ describe('slack-service uninstallApp', () => {
137138
});
138139
});
139140

141+
describe('slack-service deleteInstallationByTeamId', () => {
142+
beforeEach(() => {
143+
mockLimit.mockReset();
144+
mockDeleteWhere.mockReset();
145+
mockDeleteWhere.mockResolvedValue(undefined);
146+
});
147+
148+
it('deletes the platform integration and Chat SDK state for a Slack team', async () => {
149+
mockLimit.mockResolvedValue([buildSlackIntegration()]);
150+
151+
await expect(deleteInstallationByTeamId('T123')).resolves.toEqual({
152+
success: true,
153+
deleted: true,
154+
});
155+
156+
expect(mockDeleteWhere).toHaveBeenCalledTimes(1);
157+
});
158+
});
159+
140160
describe('slack-service testConnection', () => {
141161
beforeEach(() => {
142162
mockLimit.mockReset();

apps/web/src/lib/integrations/slack-service.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,18 @@ export async function uninstallApp(owner: Owner, options: SlackUninstallOptions
264264
return { success: true };
265265
}
266266

267+
export async function deleteInstallationByTeamId(teamId: string) {
268+
const integration = await getInstallationByTeamId(teamId);
269+
270+
if (!integration) {
271+
return { success: true, deleted: false };
272+
}
273+
274+
await db.delete(platform_integrations).where(eq(platform_integrations.id, integration.id));
275+
276+
return { success: true, deleted: true };
277+
}
278+
267279
/**
268280
* Remove only the database row for a Slack integration without revoking the token on Slack's side.
269281
* This is useful for development when you want to re-test the OAuth flow without

0 commit comments

Comments
 (0)