|
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'; |
2 | 3 | import { createSlackAdapter, SlackAdapter } from '@chat-adapter/slack'; |
3 | 4 | import { captureException } from '@sentry/nextjs'; |
4 | 5 | import type { HomeView } from '@slack/types'; |
5 | | -import { resolveKiloUserId, unlinkKiloUser } from '@/lib/bot-identity'; |
| 6 | +import { resolveKiloUserId, unlinkKiloUser, unlinkTeamKiloUsers } from '@/lib/bot-identity'; |
6 | 7 | import { isSlackMissingScopeError, postSlackReinstallInstruction } from '@/lib/bot/helpers'; |
| 8 | +import { deleteInstallationByTeamId } from '@/lib/integrations/slack-service'; |
7 | 9 | import { |
8 | 10 | getPlatformIdentity, |
9 | 11 | getPlatformIntegration, |
@@ -37,11 +39,125 @@ const SLACK_ASSISTANT_SUGGESTED_PROMPTS = [ |
37 | 39 |
|
38 | 40 | const ASSISTANT_PROMPTS_TITLE = 'Try asking Kilo Bot'; |
39 | 41 |
|
| 42 | +const SLACK_SIGNATURE_VERSION = 'v0'; |
| 43 | +const SLACK_SIGNATURE_TOLERANCE_SECONDS = 60 * 5; |
| 44 | + |
40 | 45 | const SLACK_CHANNEL_INVITE_MESSAGE = { |
41 | 46 | markdown: |
42 | 47 | "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.", |
43 | 48 | } as const; |
44 | 49 |
|
| 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 | + |
45 | 161 | export function buildSlackAppHomeView() { |
46 | 162 | return { |
47 | 163 | type: 'home', |
@@ -136,6 +252,9 @@ function createKiloBot(slackAdapter: ReturnType<typeof createSlackAdapter>) { |
136 | 252 | state: createChatState(), |
137 | 253 | }); |
138 | 254 |
|
| 255 | + chatBot.webhooks.slack = (request, options) => |
| 256 | + handleSlackWebhook(request, options, chatBot, slackAdapter); |
| 257 | + |
139 | 258 | chatBot.onNewMention(async function handleIncomingMessage( |
140 | 259 | thread: Thread, |
141 | 260 | message: Message |
|
0 commit comments