Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 38 additions & 7 deletions apps/web/src/components/integrations/SlackIntegrationDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -12,6 +12,7 @@ import {
ExternalLink,
Send,
Trash2,
TriangleAlert,
} from 'lucide-react';
import { toast } from 'sonner';
import { useEffect, useMemo, useState } from 'react';
Expand Down Expand Up @@ -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 (
<div className="space-y-6">
Expand All @@ -255,10 +259,17 @@ export function SlackIntegrationDetails({
</CardDescription>
</div>
{isInstalled ? (
<Badge variant="default" className="flex items-center gap-1">
<CheckCircle2 className="h-3 w-3" />
Connected
</Badge>
installation?.requiresReinstall ? (
<Badge variant="secondary" className="flex items-center gap-1">
<TriangleAlert className="h-3 w-3" />
Needs Reinstall
</Badge>
) : (
<Badge variant="default" className="flex items-center gap-1">
<CheckCircle2 className="h-3 w-3" />
Connected
</Badge>
)
) : (
<Badge variant="secondary" className="flex items-center gap-1">
<XCircle className="h-3 w-3" />
Expand All @@ -270,6 +281,22 @@ export function SlackIntegrationDetails({
<CardContent className="space-y-4">
{isInstalled && installation ? (
<>
{installation.requiresReinstall && (
<Alert variant="warning">
<TriangleAlert />
<AlertTitle>Reinstall Slack to restore all bot features</AlertTitle>
<AlertDescription>
Kilo tried to use Slack permissions this workspace has not granted yet.
Reinstall the Slack app to refresh its scopes.
{installation.missingScopes.length > 0 && (
<span className="mt-2 block">
Missing scopes: {installation.missingScopes.join(', ')}
</span>
)}
</AlertDescription>
</Alert>
)}

{/* Installation Details */}
<div className="space-y-3 rounded-lg border p-4">
<div className="flex items-center justify-between">
Expand Down Expand Up @@ -313,6 +340,10 @@ export function SlackIntegrationDetails({

{/* Actions */}
<div className="flex flex-wrap gap-3">
<Button onClick={handleInstall} disabled={isStartingOAuth}>
<MessageSquare className="mr-2 h-4 w-4" />
{reinstallButtonText}
</Button>
<Button
variant="outline"
onClick={handleTestConnection}
Expand Down Expand Up @@ -379,10 +410,10 @@ export function SlackIntegrationDetails({
onClick={handleInstall}
size="lg"
className="w-full"
disabled={isStartingSlackConnection || isFetchingOAuthUrl}
disabled={isStartingOAuth}
>
<MessageSquare className="mr-2 h-4 w-4" />
{isStartingSlackConnection || isFetchingOAuthUrl ? 'Loading...' : 'Connect Slack'}
{connectButtonText}
</Button>
</>
)}
Expand Down
71 changes: 67 additions & 4 deletions apps/web/src/lib/bot/webhook-handler.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -12,13 +13,75 @@ export function cloneRequestWithBody(request: Request, body: BodyInit): Request
});
}

export function handleWebhook(platform: string, request: Request): Response | Promise<Response> {
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<void> {
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<Response> {
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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Missing-scope errors can be swallowed before this handler sees them

The new marker only runs when the webhook handler or a waitUntil task rejects. The Slack assistant/app-home callbacks catch Slack API errors locally and only call captureException, so missing_scope from setSuggestedPrompts/publishHomeView resolves successfully and never reaches this catch. That means the Sentry flow this PR targets can still fail without setting requires_reinstall. Consider rethrowing those Slack API errors after capture or marking the installation from those catches.

throw error;
}
}),
});
} catch (error) {
await handleSlackWebhookError(error, slackTeamId);
throw error;
}
}
160 changes: 159 additions & 1 deletion apps/web/src/lib/integrations/slack-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand All @@ -17,6 +20,9 @@ jest.mock('@/lib/drizzle', () => ({
delete: jest.fn(() => ({
where: mockDeleteWhere,
})),
update: jest.fn(() => ({
set: mockUpdateSet,
})),
},
}));

Expand All @@ -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;

Expand All @@ -44,13 +56,30 @@ function buildSlackIntegration(overrides: Record<string, unknown> = {}) {
};
}

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 () => {
Expand Down Expand Up @@ -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',
},
})
);
});
});
Loading
Loading