diff --git a/apps/web/src/components/integrations/SlackIntegrationDetails.tsx b/apps/web/src/components/integrations/SlackIntegrationDetails.tsx index aabe8c6e2d..5ae303c329 100644 --- a/apps/web/src/components/integrations/SlackIntegrationDetails.tsx +++ b/apps/web/src/components/integrations/SlackIntegrationDetails.tsx @@ -3,7 +3,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; -import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { CheckCircle2, XCircle, @@ -12,6 +12,7 @@ import { ExternalLink, Send, Trash2, + TriangleAlert, } from 'lucide-react'; import { toast } from 'sonner'; import { useEffect, useMemo, useState } from 'react'; @@ -238,6 +239,9 @@ export function SlackIntegrationDetails({ const isInstalled = installationData?.installed; const installation = installationData?.installation; + const isStartingOAuth = isStartingSlackConnection || isFetchingOAuthUrl; + const reinstallButtonText = isStartingOAuth ? 'Loading...' : 'Reinstall Slack'; + const connectButtonText = isStartingOAuth ? 'Loading...' : 'Connect Slack'; return (
@@ -255,10 +259,17 @@ export function SlackIntegrationDetails({
{isInstalled ? ( - - - Connected - + installation?.requiresReinstall ? ( + + + Needs Reinstall + + ) : ( + + + Connected + + ) ) : ( @@ -270,6 +281,22 @@ export function SlackIntegrationDetails({ {isInstalled && installation ? ( <> + {installation.requiresReinstall && ( + + + Reinstall Slack to restore all bot features + + Kilo tried to use Slack permissions this workspace has not granted yet. + Reinstall the Slack app to refresh its scopes. + {installation.missingScopes.length > 0 && ( + + Missing scopes: {installation.missingScopes.join(', ')} + + )} + + + )} + {/* Installation Details */}
@@ -313,6 +340,10 @@ export function SlackIntegrationDetails({ {/* Actions */}
+ )} diff --git a/apps/web/src/lib/bot/webhook-handler.ts b/apps/web/src/lib/bot/webhook-handler.ts index b1533f0245..f999ac28d3 100644 --- a/apps/web/src/lib/bot/webhook-handler.ts +++ b/apps/web/src/lib/bot/webhook-handler.ts @@ -1,6 +1,7 @@ import 'server-only'; import { after } from 'next/server'; import { bot } from '@/lib/bot'; +import { markSlackInstallationRequiresReinstall } from '@/lib/integrations/slack-service'; type Platform = keyof typeof bot.webhooks; @@ -12,13 +13,75 @@ export function cloneRequestWithBody(request: Request, body: BodyInit): Request }); } -export function handleWebhook(platform: string, request: Request): Response | Promise { +function getSlackTeamIdFromBody(body: string, contentType: string): string | undefined { + if (contentType.includes('application/x-www-form-urlencoded')) { + const params = new URLSearchParams(body); + const teamId = params.get('team_id'); + if (teamId) return teamId; + + const payload = params.get('payload'); + if (!payload) return undefined; + + try { + const parsed: unknown = JSON.parse(payload); + if (typeof parsed !== 'object' || parsed === null) return undefined; + if (!('team' in parsed) || typeof parsed.team !== 'object' || parsed.team === null) { + return undefined; + } + const team = parsed.team; + if (!('id' in team) || typeof team.id !== 'string') return undefined; + return team.id; + } catch { + return undefined; + } + } + + try { + const parsed: unknown = JSON.parse(body); + if (typeof parsed !== 'object' || parsed === null) return undefined; + if (!('team_id' in parsed) || typeof parsed.team_id !== 'string') return undefined; + return parsed.team_id; + } catch { + return undefined; + } +} + +async function handleSlackWebhookError(error: unknown, teamId: string | undefined): Promise { + if (!teamId) return; + try { + await markSlackInstallationRequiresReinstall(teamId, error); + } catch (markError) { + console.error('[Webhook] Failed to mark Slack installation as requires_reinstall:', markError); + } +} + +export async function handleWebhook(platform: string, request: Request): Promise { const handler = bot.webhooks[platform as Platform]; if (!handler) { return new Response('Unknown platform', { status: 404 }); } - return handler(request, { - waitUntil: task => after(() => task), - }); + const body = await request.text(); + const clonedRequest = cloneRequestWithBody(request, body); + const slackTeamId = + platform === 'slack' + ? getSlackTeamIdFromBody(body, request.headers.get('content-type') ?? '') + : undefined; + + try { + return await handler(clonedRequest, { + waitUntil: task => + after(async () => { + try { + await task; + } catch (error) { + await handleSlackWebhookError(error, slackTeamId); + throw error; + } + }), + }); + } catch (error) { + await handleSlackWebhookError(error, slackTeamId); + throw error; + } } diff --git a/apps/web/src/lib/integrations/slack-service.test.ts b/apps/web/src/lib/integrations/slack-service.test.ts index 0cde7f1759..46849142af 100644 --- a/apps/web/src/lib/integrations/slack-service.test.ts +++ b/apps/web/src/lib/integrations/slack-service.test.ts @@ -3,6 +3,9 @@ process.env.TURNSTILE_SECRET_KEY ||= 'test-turnstile-secret'; const mockLimit = jest.fn(); const mockDeleteWhere = jest.fn(); +const mockUpdateSet = jest.fn(); +const mockUpdateWhere = jest.fn(); +const mockUpdateReturning = jest.fn(); const mockAuthRevoke = jest.fn(); jest.mock('@/lib/drizzle', () => ({ @@ -17,6 +20,9 @@ jest.mock('@/lib/drizzle', () => ({ delete: jest.fn(() => ({ where: mockDeleteWhere, })), + update: jest.fn(() => ({ + set: mockUpdateSet, + })), }, })); @@ -29,7 +35,13 @@ jest.mock('@slack/web-api', () => ({ })); import type { Owner } from '@/lib/integrations/core/types'; -import { uninstallApp } from './slack-service'; +import { + getSlackMissingScopeErrorInfo, + isSlackMissingScopeError, + markSlackInstallationRequiresReinstall, + uninstallApp, + upsertSlackInstallation, +} from './slack-service'; const owner = { type: 'user', id: 'user-1' } satisfies Owner; @@ -44,13 +56,30 @@ function buildSlackIntegration(overrides: Record = {}) { }; } +function buildSlackMissingScopeError() { + return Object.assign(new Error('An API error occurred: missing_scope'), { + code: 'slack_webapi_platform_error', + data: { + ok: false, + error: 'missing_scope', + needed: 'assistant:write,views:write', + provided: 'chat:write', + }, + }); +} + describe('slack-service uninstallApp', () => { beforeEach(() => { mockLimit.mockReset(); mockDeleteWhere.mockReset(); + mockUpdateSet.mockReset(); + mockUpdateWhere.mockReset(); + mockUpdateReturning.mockReset(); mockAuthRevoke.mockReset(); mockAuthRevoke.mockResolvedValue({ ok: true }); mockDeleteWhere.mockResolvedValue(undefined); + mockUpdateWhere.mockReturnValue({ returning: mockUpdateReturning }); + mockUpdateSet.mockReturnValue({ where: mockUpdateWhere }); }); it('deletes Chat SDK Slack state before removing the platform integration row', async () => { @@ -115,3 +144,132 @@ describe('slack-service uninstallApp', () => { expect(mockDeleteWhere).toHaveBeenCalledTimes(1); }); }); + +describe('slack-service reinstall metadata', () => { + beforeEach(() => { + mockLimit.mockReset(); + mockDeleteWhere.mockReset(); + mockUpdateSet.mockReset(); + mockUpdateWhere.mockReset(); + mockUpdateReturning.mockReset(); + mockAuthRevoke.mockReset(); + mockUpdateWhere.mockReturnValue({ returning: mockUpdateReturning }); + mockUpdateSet.mockReturnValue({ where: mockUpdateWhere }); + }); + + it('detects Slack missing_scope platform errors', () => { + const error = buildSlackMissingScopeError(); + + expect(isSlackMissingScopeError(error)).toBe(true); + expect(getSlackMissingScopeErrorInfo(error)).toEqual({ + error: 'missing_scope', + missingScopes: ['assistant:write', 'views:write'], + providedScopes: ['chat:write'], + }); + expect( + getSlackMissingScopeErrorInfo( + Object.assign(new Error('An API error occurred: missing_scope'), { + code: 'slack_webapi_platform_error', + data: { ok: false, error: 'missing_scope' }, + }) + ) + ).toEqual({ + error: 'missing_scope', + missingScopes: [], + providedScopes: [], + }); + expect(isSlackMissingScopeError(new Error('different error'))).toBe(false); + }); + + it('marks an installation as requiring reinstall for missing scopes', async () => { + mockLimit.mockResolvedValue([ + buildSlackIntegration({ + metadata: { + access_token: 'xoxb-token', + model_slug: 'anthropic/claude-sonnet-4.5', + missing_scopes: ['assistant:write'], + }, + }), + ]); + + await expect( + markSlackInstallationRequiresReinstall('T123', buildSlackMissingScopeError()) + ).resolves.toMatchObject({ + error: 'missing_scope', + integrationId: 'integration-1', + missingScopes: ['assistant:write', 'views:write'], + providedScopes: ['chat:write'], + }); + + expect(mockUpdateSet).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + access_token: 'xoxb-token', + model_slug: 'anthropic/claude-sonnet-4.5', + requires_reinstall: true, + missing_scopes: ['assistant:write', 'views:write'], + last_scope_error_code: 'missing_scope', + }), + }) + ); + }); + + it('does not mark reinstall for non-missing-scope errors', async () => { + await expect( + markSlackInstallationRequiresReinstall('T123', new Error('ratelimited')) + ).resolves.toBe(null); + + expect(mockLimit).not.toHaveBeenCalled(); + expect(mockUpdateSet).not.toHaveBeenCalled(); + }); + + it('preserves the selected model and clears reinstall flags during reinstall', async () => { + const updatedIntegration = buildSlackIntegration({ + metadata: { + access_token: 'new-token', + bot_user_id: 'BNEW', + model_slug: 'anthropic/claude-sonnet-4.5', + }, + }); + mockLimit.mockResolvedValue([ + buildSlackIntegration({ + metadata: { + access_token: 'old-token', + bot_user_id: 'BOLD', + model_slug: 'anthropic/claude-sonnet-4.5', + requires_reinstall: true, + missing_scopes: ['assistant:write'], + last_scope_error_at: '2026-04-28T12:00:00.000Z', + last_scope_error_code: 'missing_scope', + }, + }), + ]); + mockUpdateReturning.mockResolvedValue([updatedIntegration]); + + await expect( + upsertSlackInstallation({ + owner, + teamId: 'T123', + installation: { + botToken: 'new-token', + botUserId: 'BNEW', + teamName: 'Kilo Test', + }, + }) + ).resolves.toBe(updatedIntegration); + + expect(mockUpdateSet).toHaveBeenCalledWith( + expect.objectContaining({ + platform_installation_id: 'T123', + platform_account_id: 'T123', + platform_account_login: 'Kilo Test', + integration_status: 'active', + metadata: { + access_token: 'new-token', + bot_user_id: 'BNEW', + model_slug: 'anthropic/claude-sonnet-4.5', + }, + }) + ); + }); +}); diff --git a/apps/web/src/lib/integrations/slack-service.ts b/apps/web/src/lib/integrations/slack-service.ts index f3f34d7e34..4d4b78019d 100644 --- a/apps/web/src/lib/integrations/slack-service.ts +++ b/apps/web/src/lib/integrations/slack-service.ts @@ -33,23 +33,187 @@ export const SLACK_SCOPES = [ 'groups:read', 'im:history', 'im:read', - 'im:write', 'mpim:history', 'mpim:read', 'reactions:read', 'reactions:write', 'team:read', 'users:read', - 'users:read.email', ]; const SLACK_REDIRECT_URI = `${APP_URL}/api/integrations/slack/callback`; +const SLACK_PLATFORM_ERROR_CODE = 'slack_webapi_platform_error'; + +export type SlackIntegrationMetadata = { + access_token?: string; + bot_user_id?: string; + model_slug?: string; + incoming_webhook?: { channel: string; channelId: string; url: string }; + requires_reinstall?: boolean; + missing_scopes?: string[]; + last_scope_error_at?: string; + last_scope_error_code?: string; +}; + +export type SlackMissingScopeErrorInfo = { + error: 'missing_scope'; + missingScopes: string[]; + providedScopes: string[]; + integrationId: string | null; +}; type SlackUninstallOptions = { deleteChatSdkInstallation?: (teamId: string) => Promise; deleteChatSdkIdentityCache?: (teamId: string) => Promise; }; +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +export function readSlackMetadata(metadata: unknown): SlackIntegrationMetadata { + if (!isRecord(metadata)) return {}; + + const result: SlackIntegrationMetadata = {}; + + if (typeof metadata.access_token === 'string') result.access_token = metadata.access_token; + if (typeof metadata.bot_user_id === 'string') result.bot_user_id = metadata.bot_user_id; + if (typeof metadata.model_slug === 'string') result.model_slug = metadata.model_slug; + if (metadata.requires_reinstall === true) result.requires_reinstall = true; + if (typeof metadata.last_scope_error_at === 'string') { + result.last_scope_error_at = metadata.last_scope_error_at; + } + if (typeof metadata.last_scope_error_code === 'string') { + result.last_scope_error_code = metadata.last_scope_error_code; + } + if (Array.isArray(metadata.missing_scopes)) { + result.missing_scopes = metadata.missing_scopes.filter(scope => typeof scope === 'string'); + } + if (isRecord(metadata.incoming_webhook)) { + const webhook = metadata.incoming_webhook; + if ( + typeof webhook.channel === 'string' && + typeof webhook.channelId === 'string' && + typeof webhook.url === 'string' + ) { + result.incoming_webhook = { + channel: webhook.channel, + channelId: webhook.channelId, + url: webhook.url, + }; + } + } + + return result; +} + +export function parseSlackScopes(value: unknown): string[] { + if (typeof value === 'string') { + return value + .split(',') + .map(scope => scope.trim()) + .filter(Boolean); + } + if (Array.isArray(value)) return value.filter(scope => typeof scope === 'string'); + return []; +} + +function uniqueScopes(scopes: string[]): string[] { + return [...new Set(scopes)]; +} + +export function getSlackPlatformError(error: unknown) { + if (!isRecord(error)) return null; + if (error.code !== SLACK_PLATFORM_ERROR_CODE) return null; + if (!isRecord(error.data)) return null; + if (typeof error.data.error !== 'string') return null; + + return { + error: error.data.error, + needed: error.data.needed, + provided: error.data.provided, + }; +} + +export function getSlackMissingScopeErrorInfo( + error: unknown +): Omit | null { + const platformError = getSlackPlatformError(error); + if (platformError?.error !== 'missing_scope') return null; + + return { + error: 'missing_scope', + missingScopes: parseSlackScopes(platformError.needed), + providedScopes: parseSlackScopes(platformError.provided), + }; +} + +export function isSlackMissingScopeError(error: unknown): boolean { + return getSlackMissingScopeErrorInfo(error) !== null; +} + +function clearSlackReinstallRequiredMetadata(metadata: Record) { + const next = { ...metadata }; + delete next.requires_reinstall; + delete next.missing_scopes; + delete next.last_scope_error_at; + delete next.last_scope_error_code; + return next; +} + +export function getSlackReinstallState(integration: PlatformIntegration) { + const metadata = readSlackMetadata(integration.metadata); + const storedScopes = integration.scopes ?? []; + const missingConfiguredScopes = SLACK_SCOPES.filter(scope => !storedScopes.includes(scope)); + const missingScopes = uniqueScopes([ + ...(metadata.missing_scopes ?? []), + ...missingConfiguredScopes, + ]); + const requiresReinstall = + metadata.requires_reinstall === true || missingConfiguredScopes.length > 0; + + return { + requiresReinstall, + missingScopes, + missingConfiguredScopes, + lastScopeErrorAt: metadata.last_scope_error_at ?? null, + }; +} + +export async function markSlackInstallationRequiresReinstall( + teamId: string, + error: unknown +): Promise { + const scopeInfo = getSlackMissingScopeErrorInfo(error); + if (!scopeInfo) return null; + + const integration = await getInstallationByTeamId(teamId); + if (!integration) return { ...scopeInfo, integrationId: null }; + + const existingMetadata = isRecord(integration.metadata) ? integration.metadata : {}; + const metadata = readSlackMetadata(integration.metadata); + const missingScopes = uniqueScopes([ + ...(metadata.missing_scopes ?? []), + ...scopeInfo.missingScopes, + ]); + + await db + .update(platform_integrations) + .set({ + metadata: { + ...existingMetadata, + requires_reinstall: true, + missing_scopes: missingScopes, + last_scope_error_at: new Date().toISOString(), + last_scope_error_code: scopeInfo.error, + }, + updated_at: new Date().toISOString(), + }) + .where(eq(platform_integrations.id, integration.id)); + + return { ...scopeInfo, missingScopes, integrationId: integration.id }; +} + function getOwnershipConditions(owner: Owner) { return owner.type === 'user' ? [ @@ -170,17 +334,27 @@ export async function upsertSlackInstallation({ access_token: installation.botToken, bot_user_id: installation.botUserId, model_slug: defaultModel, - }; + } satisfies SlackIntegrationMetadata; if (existing) { + const existingMetadata = isRecord(existing.metadata) ? existing.metadata : {}; + const existingSlackMetadata = readSlackMetadata(existing.metadata); + const nextMetadata = clearSlackReinstallRequiredMetadata({ + ...existingMetadata, + access_token: installation.botToken, + bot_user_id: installation.botUserId, + model_slug: existingSlackMetadata.model_slug ?? defaultModel, + }) satisfies SlackIntegrationMetadata; + const [updated] = await db .update(platform_integrations) .set({ + platform_installation_id: teamId, platform_account_id: teamId, platform_account_login: teamName, scopes: SLACK_SCOPES, integration_status: INTEGRATION_STATUS.ACTIVE, - metadata, + metadata: nextMetadata, updated_at: new Date().toISOString(), }) .where(eq(platform_integrations.id, existing.id)) @@ -223,7 +397,7 @@ export async function uninstallApp(owner: Owner, options: SlackUninstallOptions } // Revoke the token if we have one - const metadata = integration.metadata as { access_token?: string } | null; + const metadata = readSlackMetadata(integration.metadata); if (metadata?.access_token) { try { await revokeSlackToken(metadata.access_token); @@ -280,7 +454,7 @@ export async function testConnection(owner: Owner): Promise<{ success: boolean; return { success: false, error: 'No Slack installation found' }; } - const metadata = integration.metadata as { access_token?: string } | null; + const metadata = readSlackMetadata(integration.metadata); if (!metadata?.access_token) { return { success: false, error: 'No access token found' }; @@ -314,11 +488,7 @@ export async function sendTestMessage( return { success: false, error: 'No Slack installation found' }; } - const metadata = integration.metadata as { - access_token?: string; - model_slug?: string; - incoming_webhook?: { channel: string; channelId: string; url: string }; - } | null; + const metadata = readSlackMetadata(integration.metadata); if (!metadata?.access_token) { return { success: false, error: 'No access token found' }; @@ -421,7 +591,7 @@ export async function sendMessage( return { success: false, error: 'No Slack installation found' }; } - const metadata = integration.metadata as { access_token?: string } | null; + const metadata = readSlackMetadata(integration.metadata); if (!metadata?.access_token) { return { success: false, error: 'No access token found' }; @@ -473,7 +643,7 @@ export async function updateModel( } } - const existingMetadata = (integration.metadata || {}) as Record; + const existingMetadata = isRecord(integration.metadata) ? integration.metadata : {}; await db .update(platform_integrations) @@ -499,7 +669,7 @@ export async function getModel(owner: Owner): Promise { return null; } - const metadata = integration.metadata as { model_slug?: string } | null; + const metadata = readSlackMetadata(integration.metadata); return metadata?.model_slug || null; } @@ -528,7 +698,7 @@ export type SlackPostMessageResponse = { export function getAccessTokenFromInstallation( integration: PlatformIntegration ): string | undefined { - const metadata = integration.metadata as { access_token?: string } | null; + const metadata = readSlackMetadata(integration.metadata); return metadata?.access_token; } diff --git a/apps/web/src/routers/slack-router.ts b/apps/web/src/routers/slack-router.ts index 079ae08deb..08e43d2bc0 100644 --- a/apps/web/src/routers/slack-router.ts +++ b/apps/web/src/routers/slack-router.ts @@ -47,16 +47,21 @@ export const slackRouter = createTRPCRouter({ } const isInstalled = integration.integration_status === 'active'; - const metadata = integration.metadata as { model_slug?: string } | null; + const metadata = slackService.readSlackMetadata(integration.metadata); + const reinstallState = slackService.getSlackReinstallState(integration); return { installed: isInstalled, + needsReinstall: reinstallState.requiresReinstall, installation: { teamId: integration.platform_account_id, teamName: integration.platform_account_login, scopes: integration.scopes, installedAt: integration.installed_at, modelSlug: metadata?.model_slug || null, + requiresReinstall: reinstallState.requiresReinstall, + missingScopes: reinstallState.missingScopes, + lastScopeErrorAt: reinstallState.lastScopeErrorAt, }, }; }),