From ff18621087936caff06ac6bb77abdc82ef41608c Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Tue, 28 Apr 2026 15:05:35 +0200 Subject: [PATCH 1/5] feat(slack): add reinstall flow for missing scopes --- .../integrations/slack/reinstall/page.tsx | 54 ++++ .../integrations/slack/reinstall/page.tsx | 63 +++++ .../integrations/SlackIntegrationDetails.tsx | 80 +++++- apps/web/src/lib/bot.ts | 76 +++++- apps/web/src/lib/bot/webhook-context.ts | 16 ++ apps/web/src/lib/bot/webhook-handler.ts | 49 +++- .../lib/integrations/slack-service.test.ts | 160 +++++++++++- .../web/src/lib/integrations/slack-service.ts | 232 +++++++++++++++++- apps/web/src/routers/slack-router.ts | 7 +- 9 files changed, 705 insertions(+), 32 deletions(-) create mode 100644 apps/web/src/app/(app)/integrations/slack/reinstall/page.tsx create mode 100644 apps/web/src/app/(app)/organizations/[id]/integrations/slack/reinstall/page.tsx create mode 100644 apps/web/src/lib/bot/webhook-context.ts diff --git a/apps/web/src/app/(app)/integrations/slack/reinstall/page.tsx b/apps/web/src/app/(app)/integrations/slack/reinstall/page.tsx new file mode 100644 index 0000000000..4559d515f8 --- /dev/null +++ b/apps/web/src/app/(app)/integrations/slack/reinstall/page.tsx @@ -0,0 +1,54 @@ +import { Suspense } from 'react'; +import { getUserFromAuthOrRedirect } from '@/lib/user.server'; +import { SlackIntegrationDetails } from '@/components/integrations/SlackIntegrationDetails'; +import { Button } from '@/components/ui/button'; +import { ArrowLeft } from 'lucide-react'; +import Link from 'next/link'; +import { Card, CardContent } from '@/components/ui/card'; +import { PageLayout } from '@/components/PageLayout'; + +export default async function UserSlackReinstallPage({ + searchParams, +}: { + searchParams: Promise<{ + success?: string; + error?: string; + }>; +}) { + await getUserFromAuthOrRedirect('/users/sign_in'); + const search = await searchParams; + + return ( + + + + } + > + + +
+
+
+
+ + + } + > + + + + ); +} diff --git a/apps/web/src/app/(app)/organizations/[id]/integrations/slack/reinstall/page.tsx b/apps/web/src/app/(app)/organizations/[id]/integrations/slack/reinstall/page.tsx new file mode 100644 index 0000000000..da6c98a070 --- /dev/null +++ b/apps/web/src/app/(app)/organizations/[id]/integrations/slack/reinstall/page.tsx @@ -0,0 +1,63 @@ +import { Suspense } from 'react'; +import { SlackIntegrationDetails } from '@/components/integrations/SlackIntegrationDetails'; +import { Button } from '@/components/ui/button'; +import { SetPageTitle } from '@/components/SetPageTitle'; +import { ArrowLeft } from 'lucide-react'; +import Link from 'next/link'; +import { Card, CardContent } from '@/components/ui/card'; +import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout'; + +export default async function OrgSlackReinstallPage({ + params, + searchParams, +}: { + params: Promise<{ id: string }>; + searchParams: Promise<{ + success?: string; + error?: string; + }>; +}) { + const search = await searchParams; + + return ( + ( + <> +
+ + + + +

+ Refresh Slack permissions for {organization.name} +

+
+ + + +
+
+
+
+ + + } + > + + + + )} + /> + ); +} diff --git a/apps/web/src/components/integrations/SlackIntegrationDetails.tsx b/apps/web/src/components/integrations/SlackIntegrationDetails.tsx index aabe8c6e2d..67d68a672a 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'; @@ -20,17 +21,20 @@ import { useTRPC } from '@/lib/trpc/utils'; import { IS_DEVELOPMENT } from '@/lib/constants'; import { ModelCombobox, type ModelOption } from '@/components/shared/ModelCombobox'; import { useModelSelectorList } from '@/app/api/openrouter/hooks'; +import Link from 'next/link'; type SlackIntegrationDetailsProps = { organizationId?: string; success?: boolean; error?: string; + mode?: 'manage' | 'reinstall'; }; export function SlackIntegrationDetails({ organizationId, success, error, + mode = 'manage', }: SlackIntegrationDetailsProps) { const trpc = useTRPC(); const queryClient = useQueryClient(); @@ -238,9 +242,28 @@ export function SlackIntegrationDetails({ const isInstalled = installationData?.installed; const installation = installationData?.installation; + const isReinstallMode = mode === 'reinstall'; + const isStartingOAuth = isStartingSlackConnection || isFetchingOAuthUrl; + const slackIntegrationPath = organizationId + ? `/organizations/${organizationId}/integrations/slack` + : '/integrations/slack'; + const reinstallButtonText = isStartingOAuth ? 'Loading...' : 'Reinstall Slack'; + const connectButtonText = isStartingOAuth ? 'Loading...' : 'Connect Slack'; return (
+ {isReinstallMode && ( + + + Slack permissions need to be refreshed + + Reinstall Kilo's Slack app to grant the latest permissions. Existing Kilo settings + for this workspace, including the selected model, will be preserved. A Slack workspace + admin may need to approve the reinstall. + + + )} + {/* Installation Status Card */} @@ -255,10 +278,17 @@ export function SlackIntegrationDetails({
{isInstalled ? ( - - - Connected - + installation?.requiresReinstall ? ( + + + Needs Reinstall + + ) : ( + + + Connected + + ) ) : ( @@ -270,6 +300,22 @@ export function SlackIntegrationDetails({ {isInstalled && installation ? ( <> + {installation.requiresReinstall && !isReinstallMode && ( + + + 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 +359,10 @@ export function SlackIntegrationDetails({ {/* Actions */}
+
+ + {isReinstallMode && ( + + )} ) : ( <> {/* Not Connected State */} - Connect Slack to talk with Kilo directly from your workspace. + {isReinstallMode + ? 'Slack is not connected yet. Connect Slack to grant Kilo the latest permissions.' + : 'Connect Slack to talk with Kilo directly from your workspace.'} @@ -379,11 +437,17 @@ export function SlackIntegrationDetails({ onClick={handleInstall} size="lg" className="w-full" - disabled={isStartingSlackConnection || isFetchingOAuthUrl} + disabled={isStartingOAuth} > - {isStartingSlackConnection || isFetchingOAuthUrl ? 'Loading...' : 'Connect Slack'} + {isReinstallMode ? reinstallButtonText : connectButtonText} + + {isReinstallMode && ( + + )} )} diff --git a/apps/web/src/lib/bot.ts b/apps/web/src/lib/bot.ts index 58c174336b..1875d18a07 100644 --- a/apps/web/src/lib/bot.ts +++ b/apps/web/src/lib/bot.ts @@ -10,6 +10,12 @@ import { findUserById } from '@/lib/user'; import { processMessage } from '@/lib/bot/run'; import { createChatState } from '@/lib/bot/state'; import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_SIGNING_SECRET } from '@/lib/config.server'; +import { + getSlackMissingScopeErrorInfo, + markSlackInstallationRequiresReinstall, + postSlackReinstallNoticeByTeamId, +} from '@/lib/integrations/slack-service'; +import { getBotWebhookContext } from '@/lib/bot/webhook-context'; const SLACK_ASSISTANT_SUGGESTED_PROMPTS = [ { @@ -32,6 +38,58 @@ const SLACK_ASSISTANT_SUGGESTED_PROMPTS = [ const ASSISTANT_PROMPTS_TITLE = 'Try asking Kilo Bot'; +async function captureSlackBotError({ + error, + op, + userId, + channelId, + teamId, + threadTs, +}: { + error: unknown; + op: 'assistant-thread-started' | 'app-home-opened'; + userId: string; + channelId: string; + teamId?: string; + threadTs?: string; +}) { + const resolvedTeamId = teamId ?? getBotWebhookContext().slackTeamId; + const missingScopeInfo = getSlackMissingScopeErrorInfo(error); + const markedScopeInfo = resolvedTeamId + ? await markSlackInstallationRequiresReinstall(resolvedTeamId, error) + : null; + + captureException(error, { + tags: { + component: 'kilo-bot', + op, + ...(missingScopeInfo ? { slack_error: missingScopeInfo.error } : {}), + }, + extra: { + userId, + channelId, + teamId: resolvedTeamId, + missingScopes: markedScopeInfo?.missingScopes ?? missingScopeInfo?.missingScopes, + providedScopes: markedScopeInfo?.providedScopes ?? missingScopeInfo?.providedScopes, + platformIntegrationId: markedScopeInfo?.integrationId, + }, + }); + + const missingScopes = markedScopeInfo?.missingScopes ?? missingScopeInfo?.missingScopes ?? []; + if (!resolvedTeamId || missingScopes.length === 0 || missingScopes.includes('chat:write')) return; + + const noticeResult = await postSlackReinstallNoticeByTeamId({ + teamId: resolvedTeamId, + channelId, + threadTs, + missingScopes, + }); + + if (!noticeResult.ok) { + console.warn('[Bot] Failed to post Slack reinstall notice:', noticeResult.error); + } +} + export function buildSlackAppHomeView() { return { type: 'home', @@ -215,9 +273,13 @@ function createKiloBot(slackAdapter: ReturnType) { ); } catch (error) { console.error('[Bot] Failed to set suggested prompts:', error); - captureException(error, { - tags: { component: 'kilo-bot', op: 'assistant-thread-started' }, - extra: { userId: event.userId, channelId: event.channelId }, + await captureSlackBotError({ + error, + op: 'assistant-thread-started', + userId: event.userId, + channelId: event.channelId, + teamId: event.context.teamId, + threadTs: event.threadTs, }); } }); @@ -229,9 +291,11 @@ function createKiloBot(slackAdapter: ReturnType) { await event.adapter.publishHomeView(event.userId, buildSlackAppHomeView()); } catch (error) { console.error('[Bot] Failed to publish Slack App Home:', error); - captureException(error, { - tags: { component: 'kilo-bot', op: 'app-home-opened' }, - extra: { userId: event.userId, channelId: event.channelId }, + await captureSlackBotError({ + error, + op: 'app-home-opened', + userId: event.userId, + channelId: event.channelId, }); } }); diff --git a/apps/web/src/lib/bot/webhook-context.ts b/apps/web/src/lib/bot/webhook-context.ts new file mode 100644 index 0000000000..b04ca85b8a --- /dev/null +++ b/apps/web/src/lib/bot/webhook-context.ts @@ -0,0 +1,16 @@ +import 'server-only'; +import { AsyncLocalStorage } from 'node:async_hooks'; + +type BotWebhookContext = { + slackTeamId?: string; +}; + +const botWebhookContext = new AsyncLocalStorage(); + +export function runWithBotWebhookContext(context: BotWebhookContext, callback: () => T): T { + return botWebhookContext.run(context, callback); +} + +export function getBotWebhookContext(): BotWebhookContext { + return botWebhookContext.getStore() ?? {}; +} diff --git a/apps/web/src/lib/bot/webhook-handler.ts b/apps/web/src/lib/bot/webhook-handler.ts index b1533f0245..f9459d5963 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 { runWithBotWebhookContext } from '@/lib/bot/webhook-context'; type Platform = keyof typeof bot.webhooks; @@ -12,13 +13,55 @@ 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; + } +} + +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; + + return runWithBotWebhookContext({ slackTeamId }, () => { + return handler(clonedRequest, { + waitUntil: task => after(() => task), + }); }); } 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..2e20d71d99 100644 --- a/apps/web/src/lib/integrations/slack-service.ts +++ b/apps/web/src/lib/integrations/slack-service.ts @@ -44,12 +44,212 @@ export const SLACK_SCOPES = [ ]; 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 }; +} + +export async function postSlackReinstallNoticeByTeamId({ + teamId, + channelId, + threadTs, + missingScopes, +}: { + teamId: string; + channelId: string; + threadTs?: string; + missingScopes: string[]; +}): Promise { + if (missingScopes.includes('chat:write')) { + return { ok: false, error: 'missing_chat_write_scope' }; + } + + const integration = await getInstallationByTeamId(teamId); + if (!integration) return { ok: false, error: 'No Slack installation found' }; + + const metadata = readSlackMetadata(integration.metadata); + if (!metadata.access_token) return { ok: false, error: 'No access token found' }; + + const reinstallPath = integration.owned_by_organization_id + ? `/organizations/${integration.owned_by_organization_id}/integrations/slack/reinstall` + : '/integrations/slack/reinstall'; + const reinstallUrl = new URL(reinstallPath, APP_URL).toString(); + const scopeText = missingScopes.length > 0 ? ` Missing scopes: ${missingScopes.join(', ')}.` : ''; + + return postSlackMessageByAccessToken(metadata.access_token, { + channel: channelId, + thread_ts: threadTs, + text: `Kilo needs updated Slack permissions (${scopeText}) for some features. Please reinstall the Kilo Slack app: ${reinstallUrl}. A Slack workspace admin may need to approve the reinstall.`, + }); +} + function getOwnershipConditions(owner: Owner) { return owner.type === 'user' ? [ @@ -170,17 +370,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 +433,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 +490,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 +524,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 +627,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 +679,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 +705,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 +734,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, }, }; }), From 1d0358475449b78f97e2997e017e0d6b08ae3c98 Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Tue, 28 Apr 2026 15:07:28 +0200 Subject: [PATCH 2/5] fix(slack): remove extra OAuth scopes --- apps/web/src/lib/integrations/slack-service.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/web/src/lib/integrations/slack-service.ts b/apps/web/src/lib/integrations/slack-service.ts index 2e20d71d99..4a9172b3fd 100644 --- a/apps/web/src/lib/integrations/slack-service.ts +++ b/apps/web/src/lib/integrations/slack-service.ts @@ -33,14 +33,12 @@ 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`; From eeee3cc24065a098dcce0c07338ad4f2031c8e44 Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Tue, 28 Apr 2026 15:20:31 +0200 Subject: [PATCH 3/5] Fix formatting --- apps/web/src/lib/integrations/slack-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/lib/integrations/slack-service.ts b/apps/web/src/lib/integrations/slack-service.ts index 4a9172b3fd..64e0e53dcf 100644 --- a/apps/web/src/lib/integrations/slack-service.ts +++ b/apps/web/src/lib/integrations/slack-service.ts @@ -239,7 +239,7 @@ export async function postSlackReinstallNoticeByTeamId({ ? `/organizations/${integration.owned_by_organization_id}/integrations/slack/reinstall` : '/integrations/slack/reinstall'; const reinstallUrl = new URL(reinstallPath, APP_URL).toString(); - const scopeText = missingScopes.length > 0 ? ` Missing scopes: ${missingScopes.join(', ')}.` : ''; + const scopeText = missingScopes.length > 0 ? ` Missing scopes: ${missingScopes.join(', ')}` : ''; return postSlackMessageByAccessToken(metadata.access_token, { channel: channelId, From d3c71e410a6ad1528b2c55a0051fa32c7cffa581 Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Tue, 28 Apr 2026 15:36:20 +0200 Subject: [PATCH 4/5] fix(slack): remove reinstall pages --- .../integrations/slack/reinstall/page.tsx | 54 ---------------- .../integrations/slack/reinstall/page.tsx | 63 ------------------- .../integrations/SlackIntegrationDetails.tsx | 39 +----------- .../web/src/lib/integrations/slack-service.ts | 4 +- 4 files changed, 5 insertions(+), 155 deletions(-) delete mode 100644 apps/web/src/app/(app)/integrations/slack/reinstall/page.tsx delete mode 100644 apps/web/src/app/(app)/organizations/[id]/integrations/slack/reinstall/page.tsx diff --git a/apps/web/src/app/(app)/integrations/slack/reinstall/page.tsx b/apps/web/src/app/(app)/integrations/slack/reinstall/page.tsx deleted file mode 100644 index 4559d515f8..0000000000 --- a/apps/web/src/app/(app)/integrations/slack/reinstall/page.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { Suspense } from 'react'; -import { getUserFromAuthOrRedirect } from '@/lib/user.server'; -import { SlackIntegrationDetails } from '@/components/integrations/SlackIntegrationDetails'; -import { Button } from '@/components/ui/button'; -import { ArrowLeft } from 'lucide-react'; -import Link from 'next/link'; -import { Card, CardContent } from '@/components/ui/card'; -import { PageLayout } from '@/components/PageLayout'; - -export default async function UserSlackReinstallPage({ - searchParams, -}: { - searchParams: Promise<{ - success?: string; - error?: string; - }>; -}) { - await getUserFromAuthOrRedirect('/users/sign_in'); - const search = await searchParams; - - return ( - - - - } - > - - -
-
-
-
- - - } - > - - - - ); -} diff --git a/apps/web/src/app/(app)/organizations/[id]/integrations/slack/reinstall/page.tsx b/apps/web/src/app/(app)/organizations/[id]/integrations/slack/reinstall/page.tsx deleted file mode 100644 index da6c98a070..0000000000 --- a/apps/web/src/app/(app)/organizations/[id]/integrations/slack/reinstall/page.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { Suspense } from 'react'; -import { SlackIntegrationDetails } from '@/components/integrations/SlackIntegrationDetails'; -import { Button } from '@/components/ui/button'; -import { SetPageTitle } from '@/components/SetPageTitle'; -import { ArrowLeft } from 'lucide-react'; -import Link from 'next/link'; -import { Card, CardContent } from '@/components/ui/card'; -import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout'; - -export default async function OrgSlackReinstallPage({ - params, - searchParams, -}: { - params: Promise<{ id: string }>; - searchParams: Promise<{ - success?: string; - error?: string; - }>; -}) { - const search = await searchParams; - - return ( - ( - <> -
- - - - -

- Refresh Slack permissions for {organization.name} -

-
- - - -
-
-
-
- - - } - > - - - - )} - /> - ); -} diff --git a/apps/web/src/components/integrations/SlackIntegrationDetails.tsx b/apps/web/src/components/integrations/SlackIntegrationDetails.tsx index 67d68a672a..5ae303c329 100644 --- a/apps/web/src/components/integrations/SlackIntegrationDetails.tsx +++ b/apps/web/src/components/integrations/SlackIntegrationDetails.tsx @@ -21,20 +21,17 @@ import { useTRPC } from '@/lib/trpc/utils'; import { IS_DEVELOPMENT } from '@/lib/constants'; import { ModelCombobox, type ModelOption } from '@/components/shared/ModelCombobox'; import { useModelSelectorList } from '@/app/api/openrouter/hooks'; -import Link from 'next/link'; type SlackIntegrationDetailsProps = { organizationId?: string; success?: boolean; error?: string; - mode?: 'manage' | 'reinstall'; }; export function SlackIntegrationDetails({ organizationId, success, error, - mode = 'manage', }: SlackIntegrationDetailsProps) { const trpc = useTRPC(); const queryClient = useQueryClient(); @@ -242,28 +239,12 @@ export function SlackIntegrationDetails({ const isInstalled = installationData?.installed; const installation = installationData?.installation; - const isReinstallMode = mode === 'reinstall'; const isStartingOAuth = isStartingSlackConnection || isFetchingOAuthUrl; - const slackIntegrationPath = organizationId - ? `/organizations/${organizationId}/integrations/slack` - : '/integrations/slack'; const reinstallButtonText = isStartingOAuth ? 'Loading...' : 'Reinstall Slack'; const connectButtonText = isStartingOAuth ? 'Loading...' : 'Connect Slack'; return (
- {isReinstallMode && ( - - - Slack permissions need to be refreshed - - Reinstall Kilo's Slack app to grant the latest permissions. Existing Kilo settings - for this workspace, including the selected model, will be preserved. A Slack workspace - admin may need to approve the reinstall. - - - )} - {/* Installation Status Card */} @@ -300,7 +281,7 @@ export function SlackIntegrationDetails({ {isInstalled && installation ? ( <> - {installation.requiresReinstall && !isReinstallMode && ( + {installation.requiresReinstall && ( Reinstall Slack to restore all bot features @@ -408,21 +389,13 @@ export function SlackIntegrationDetails({ )}
- - {isReinstallMode && ( - - )} ) : ( <> {/* Not Connected State */} - {isReinstallMode - ? 'Slack is not connected yet. Connect Slack to grant Kilo the latest permissions.' - : 'Connect Slack to talk with Kilo directly from your workspace.'} + Connect Slack to talk with Kilo directly from your workspace. @@ -440,14 +413,8 @@ export function SlackIntegrationDetails({ disabled={isStartingOAuth} > - {isReinstallMode ? reinstallButtonText : connectButtonText} + {connectButtonText} - - {isReinstallMode && ( - - )} )} diff --git a/apps/web/src/lib/integrations/slack-service.ts b/apps/web/src/lib/integrations/slack-service.ts index 64e0e53dcf..9636717025 100644 --- a/apps/web/src/lib/integrations/slack-service.ts +++ b/apps/web/src/lib/integrations/slack-service.ts @@ -236,8 +236,8 @@ export async function postSlackReinstallNoticeByTeamId({ if (!metadata.access_token) return { ok: false, error: 'No access token found' }; const reinstallPath = integration.owned_by_organization_id - ? `/organizations/${integration.owned_by_organization_id}/integrations/slack/reinstall` - : '/integrations/slack/reinstall'; + ? `/organizations/${integration.owned_by_organization_id}/integrations/slack` + : '/integrations/slack'; const reinstallUrl = new URL(reinstallPath, APP_URL).toString(); const scopeText = missingScopes.length > 0 ? ` Missing scopes: ${missingScopes.join(', ')}` : ''; From 400cbf47073b2eb951a427ce8d51d4667234b510 Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Tue, 28 Apr 2026 15:57:29 +0200 Subject: [PATCH 5/5] refactor(slack): centralize missing-scope handling in webhook handler Move Slack missing_scope detection to a single try/catch in the webhook handler (wrapping both the direct handler call and waitUntil tasks), drop the AsyncLocalStorage webhook context, the per-event captureSlackBotError helper, and the in-thread reinstall notice. --- apps/web/src/lib/bot.ts | 76 ++----------------- apps/web/src/lib/bot/webhook-context.ts | 16 ---- apps/web/src/lib/bot/webhook-handler.ts | 30 ++++++-- .../web/src/lib/integrations/slack-service.ts | 34 --------- 4 files changed, 31 insertions(+), 125 deletions(-) delete mode 100644 apps/web/src/lib/bot/webhook-context.ts diff --git a/apps/web/src/lib/bot.ts b/apps/web/src/lib/bot.ts index 1875d18a07..58c174336b 100644 --- a/apps/web/src/lib/bot.ts +++ b/apps/web/src/lib/bot.ts @@ -10,12 +10,6 @@ import { findUserById } from '@/lib/user'; import { processMessage } from '@/lib/bot/run'; import { createChatState } from '@/lib/bot/state'; import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_SIGNING_SECRET } from '@/lib/config.server'; -import { - getSlackMissingScopeErrorInfo, - markSlackInstallationRequiresReinstall, - postSlackReinstallNoticeByTeamId, -} from '@/lib/integrations/slack-service'; -import { getBotWebhookContext } from '@/lib/bot/webhook-context'; const SLACK_ASSISTANT_SUGGESTED_PROMPTS = [ { @@ -38,58 +32,6 @@ const SLACK_ASSISTANT_SUGGESTED_PROMPTS = [ const ASSISTANT_PROMPTS_TITLE = 'Try asking Kilo Bot'; -async function captureSlackBotError({ - error, - op, - userId, - channelId, - teamId, - threadTs, -}: { - error: unknown; - op: 'assistant-thread-started' | 'app-home-opened'; - userId: string; - channelId: string; - teamId?: string; - threadTs?: string; -}) { - const resolvedTeamId = teamId ?? getBotWebhookContext().slackTeamId; - const missingScopeInfo = getSlackMissingScopeErrorInfo(error); - const markedScopeInfo = resolvedTeamId - ? await markSlackInstallationRequiresReinstall(resolvedTeamId, error) - : null; - - captureException(error, { - tags: { - component: 'kilo-bot', - op, - ...(missingScopeInfo ? { slack_error: missingScopeInfo.error } : {}), - }, - extra: { - userId, - channelId, - teamId: resolvedTeamId, - missingScopes: markedScopeInfo?.missingScopes ?? missingScopeInfo?.missingScopes, - providedScopes: markedScopeInfo?.providedScopes ?? missingScopeInfo?.providedScopes, - platformIntegrationId: markedScopeInfo?.integrationId, - }, - }); - - const missingScopes = markedScopeInfo?.missingScopes ?? missingScopeInfo?.missingScopes ?? []; - if (!resolvedTeamId || missingScopes.length === 0 || missingScopes.includes('chat:write')) return; - - const noticeResult = await postSlackReinstallNoticeByTeamId({ - teamId: resolvedTeamId, - channelId, - threadTs, - missingScopes, - }); - - if (!noticeResult.ok) { - console.warn('[Bot] Failed to post Slack reinstall notice:', noticeResult.error); - } -} - export function buildSlackAppHomeView() { return { type: 'home', @@ -273,13 +215,9 @@ function createKiloBot(slackAdapter: ReturnType) { ); } catch (error) { console.error('[Bot] Failed to set suggested prompts:', error); - await captureSlackBotError({ - error, - op: 'assistant-thread-started', - userId: event.userId, - channelId: event.channelId, - teamId: event.context.teamId, - threadTs: event.threadTs, + captureException(error, { + tags: { component: 'kilo-bot', op: 'assistant-thread-started' }, + extra: { userId: event.userId, channelId: event.channelId }, }); } }); @@ -291,11 +229,9 @@ function createKiloBot(slackAdapter: ReturnType) { await event.adapter.publishHomeView(event.userId, buildSlackAppHomeView()); } catch (error) { console.error('[Bot] Failed to publish Slack App Home:', error); - await captureSlackBotError({ - error, - op: 'app-home-opened', - userId: event.userId, - channelId: event.channelId, + captureException(error, { + tags: { component: 'kilo-bot', op: 'app-home-opened' }, + extra: { userId: event.userId, channelId: event.channelId }, }); } }); diff --git a/apps/web/src/lib/bot/webhook-context.ts b/apps/web/src/lib/bot/webhook-context.ts deleted file mode 100644 index b04ca85b8a..0000000000 --- a/apps/web/src/lib/bot/webhook-context.ts +++ /dev/null @@ -1,16 +0,0 @@ -import 'server-only'; -import { AsyncLocalStorage } from 'node:async_hooks'; - -type BotWebhookContext = { - slackTeamId?: string; -}; - -const botWebhookContext = new AsyncLocalStorage(); - -export function runWithBotWebhookContext(context: BotWebhookContext, callback: () => T): T { - return botWebhookContext.run(context, callback); -} - -export function getBotWebhookContext(): BotWebhookContext { - return botWebhookContext.getStore() ?? {}; -} diff --git a/apps/web/src/lib/bot/webhook-handler.ts b/apps/web/src/lib/bot/webhook-handler.ts index f9459d5963..f999ac28d3 100644 --- a/apps/web/src/lib/bot/webhook-handler.ts +++ b/apps/web/src/lib/bot/webhook-handler.ts @@ -1,7 +1,7 @@ import 'server-only'; import { after } from 'next/server'; import { bot } from '@/lib/bot'; -import { runWithBotWebhookContext } from '@/lib/bot/webhook-context'; +import { markSlackInstallationRequiresReinstall } from '@/lib/integrations/slack-service'; type Platform = keyof typeof bot.webhooks; @@ -46,6 +46,15 @@ function getSlackTeamIdFromBody(body: string, contentType: string): string | und } } +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) { @@ -59,9 +68,20 @@ export async function handleWebhook(platform: string, request: Request): Promise ? getSlackTeamIdFromBody(body, request.headers.get('content-type') ?? '') : undefined; - return runWithBotWebhookContext({ slackTeamId }, () => { - return handler(clonedRequest, { - waitUntil: task => after(() => task), + 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.ts b/apps/web/src/lib/integrations/slack-service.ts index 9636717025..4d4b78019d 100644 --- a/apps/web/src/lib/integrations/slack-service.ts +++ b/apps/web/src/lib/integrations/slack-service.ts @@ -214,40 +214,6 @@ export async function markSlackInstallationRequiresReinstall( return { ...scopeInfo, missingScopes, integrationId: integration.id }; } -export async function postSlackReinstallNoticeByTeamId({ - teamId, - channelId, - threadTs, - missingScopes, -}: { - teamId: string; - channelId: string; - threadTs?: string; - missingScopes: string[]; -}): Promise { - if (missingScopes.includes('chat:write')) { - return { ok: false, error: 'missing_chat_write_scope' }; - } - - const integration = await getInstallationByTeamId(teamId); - if (!integration) return { ok: false, error: 'No Slack installation found' }; - - const metadata = readSlackMetadata(integration.metadata); - if (!metadata.access_token) return { ok: false, error: 'No access token found' }; - - const reinstallPath = integration.owned_by_organization_id - ? `/organizations/${integration.owned_by_organization_id}/integrations/slack` - : '/integrations/slack'; - const reinstallUrl = new URL(reinstallPath, APP_URL).toString(); - const scopeText = missingScopes.length > 0 ? ` Missing scopes: ${missingScopes.join(', ')}` : ''; - - return postSlackMessageByAccessToken(metadata.access_token, { - channel: channelId, - thread_ts: threadTs, - text: `Kilo needs updated Slack permissions (${scopeText}) for some features. Please reinstall the Kilo Slack app: ${reinstallUrl}. A Slack workspace admin may need to approve the reinstall.`, - }); -} - function getOwnershipConditions(owner: Owner) { return owner.type === 'user' ? [