Skip to content

Commit da0a1ca

Browse files
authored
refactor(bot): make callback continuation platform agnostic (#2949)
* refactor(bot): make callback continuation platform agnostic * refactor(bot): require platform integration lookup * Default to slack docs for now * Clean up withBotPlatformAuthContext * Cleaner logic * Fix failing test expectations
1 parent 3dfe5d4 commit da0a1ca

9 files changed

Lines changed: 340 additions & 180 deletions

File tree

apps/web/src/app/api/internal/bot-session-callback/[botRequestId]/route.ts

Lines changed: 137 additions & 157 deletions
Large diffs are not rendered by default.

apps/web/src/lib/bot/agent-runner.ts

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
formatConversationContextForPrompt,
1111
} from '@/lib/bot/conversation-context';
1212
import { buildPrSignature, getRequesterInfo } from '@/lib/bot/pr-signature';
13+
import { getBotDocumentationUrl } from '@/lib/bot/platform-helpers';
1314
import {
1415
linkBotRequestToSession,
1516
recordBotRequestCloudAgentSession,
@@ -41,7 +42,6 @@ import type { BotRequestStep } from '@kilocode/db/schema';
4142
import { ToolLoopAgent, generateText, stepCountIs, tool } from 'ai';
4243
import type { StepResult, ToolSet } from 'ai';
4344
import { Actions, Card, CardText, LinkButton, Section } from 'chat';
44-
import { ThreadImpl } from 'chat';
4545
import type { Author, Message, Thread } from 'chat';
4646
import { randomUUID } from 'crypto';
4747

@@ -98,6 +98,7 @@ async function buildSystemPrompt(
9898
triggerMessage: { id: string }
9999
) {
100100
const owner = ownerFromIntegration(platformIntegration);
101+
const botDocumentationUrl = getBotDocumentationUrl(platformIntegration.platform);
101102

102103
const [githubContext, gitlabContext, conversationContext] = await Promise.all([
103104
getGitHubRepositoryContext(owner),
@@ -113,7 +114,7 @@ async function buildSystemPrompt(
113114
- If the user's request is ambiguous, ask 1-2 clarifying questions instead of guessing.
114115
115116
## Answering questions about Kilo Bot
116-
- When users ask what you can do, how you work, or for general help, include a link to the Bot documentation: https://kilo.ai/docs/code-with-ai/platforms/slack
117+
- When users ask what you can do, how you work, or for general help, include a link to the Bot documentation: ${botDocumentationUrl}
117118
- Provide the docs link along with your answer so users can learn more.
118119
119120
## Context you may receive
@@ -359,17 +360,3 @@ This tool returns an acknowledgement immediately. The final Cloud Agent result w
359360
responseTimeMs: Date.now() - startedAt,
360361
};
361362
}
362-
363-
export function createSyntheticThread(params: {
364-
threadId: string;
365-
adapterName: string;
366-
channelId: string;
367-
isDM: boolean;
368-
}): Thread {
369-
return new ThreadImpl({
370-
adapterName: params.adapterName,
371-
id: params.threadId,
372-
channelId: params.channelId,
373-
isDM: params.isDM,
374-
});
375-
}

apps/web/src/lib/bot/images.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ export async function extractAndUploadImages(
8989
} catch (error) {
9090
console.error('[KiloBot] Failed to upload image attachment:', error);
9191
captureException(error, {
92-
tags: { component: 'kilo-bot', op: 'upload-slack-image' },
92+
tags: { component: 'kilo-bot', op: 'upload-bot-image' },
9393
});
9494
}
9595
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
jest.mock('@/lib/bot', () => ({
2+
bot: {
3+
getAdapter: jest.fn(),
4+
initialize: jest.fn(),
5+
registerSingleton: jest.fn(),
6+
},
7+
}));
8+
9+
import { PLATFORM } from '@/lib/integrations/core/constants';
10+
import type { PlatformIntegration } from '@kilocode/db';
11+
import { withBotPlatformAuthContext } from './platform-auth-context';
12+
13+
type MockBotModule = {
14+
bot: {
15+
getAdapter: jest.Mock;
16+
initialize: jest.Mock;
17+
registerSingleton: jest.Mock;
18+
};
19+
};
20+
21+
const mockBotModule: MockBotModule = jest.requireMock('@/lib/bot');
22+
let mockWithBotToken: jest.Mock;
23+
let mockGetInstallation: jest.Mock;
24+
25+
function createPlatformIntegration(
26+
overrides: Partial<PlatformIntegration> = {}
27+
): PlatformIntegration {
28+
return {
29+
id: 'pi_slack',
30+
owned_by_user_id: 'user_1',
31+
owned_by_organization_id: null,
32+
created_by_user_id: null,
33+
platform: PLATFORM.SLACK,
34+
integration_type: 'oauth',
35+
platform_installation_id: 'T123',
36+
platform_account_id: 'T123',
37+
platform_account_login: 'Test Workspace',
38+
permissions: null,
39+
integration_status: 'active',
40+
scopes: null,
41+
repository_access: null,
42+
repositories: null,
43+
repositories_synced_at: null,
44+
metadata: { access_token: 'xoxb-current' },
45+
kilo_requester_user_id: null,
46+
platform_requester_account_id: null,
47+
suspended_at: null,
48+
suspended_by: null,
49+
github_app_type: 'standard',
50+
installed_at: '2026-01-01T00:00:00.000Z',
51+
created_at: '2026-01-01T00:00:00.000Z',
52+
updated_at: '2026-01-01T00:00:00.000Z',
53+
...overrides,
54+
};
55+
}
56+
57+
describe('withBotPlatformAuthContext', () => {
58+
beforeEach(() => {
59+
mockWithBotToken = jest.fn(async (_token, fn) => await fn());
60+
mockGetInstallation = jest.fn();
61+
62+
mockBotModule.bot.getAdapter.mockReset();
63+
mockBotModule.bot.initialize.mockReset();
64+
mockBotModule.bot.registerSingleton.mockReset();
65+
66+
mockBotModule.bot.getAdapter.mockReturnValue({
67+
getInstallation: mockGetInstallation,
68+
withBotToken: mockWithBotToken,
69+
});
70+
});
71+
72+
it('wraps Slack callbacks with the installation bot token', async () => {
73+
const fn = jest.fn(async () => 'result');
74+
mockGetInstallation.mockResolvedValue({ botToken: 'xoxb-stored' });
75+
76+
const result = await withBotPlatformAuthContext(createPlatformIntegration(), fn);
77+
78+
expect(result).toBe('result');
79+
expect(mockBotModule.bot.initialize).toHaveBeenCalledTimes(1);
80+
expect(mockBotModule.bot.registerSingleton).toHaveBeenCalledTimes(1);
81+
expect(mockBotModule.bot.getAdapter).toHaveBeenCalledWith(PLATFORM.SLACK);
82+
expect(mockGetInstallation).toHaveBeenCalledWith('T123');
83+
expect(mockWithBotToken).toHaveBeenCalledWith('xoxb-stored', expect.any(Function));
84+
expect(fn).toHaveBeenCalledTimes(1);
85+
});
86+
87+
it('throws when Slack installation is missing', async () => {
88+
mockGetInstallation.mockResolvedValue(null);
89+
90+
await expect(
91+
withBotPlatformAuthContext(createPlatformIntegration({ metadata: {} }), async () => 'result')
92+
).rejects.toThrow('No Slack installation for platform integration pi_slack');
93+
});
94+
95+
it('runs non-Slack callbacks directly', async () => {
96+
const fn = jest.fn(async () => 'result');
97+
98+
const result = await withBotPlatformAuthContext(
99+
createPlatformIntegration({
100+
id: 'pi_discord',
101+
platform: PLATFORM.DISCORD,
102+
metadata: {},
103+
}),
104+
fn
105+
);
106+
107+
expect(result).toBe('result');
108+
expect(fn).toHaveBeenCalledTimes(1);
109+
expect(mockBotModule.bot.getAdapter).not.toHaveBeenCalled();
110+
expect(mockWithBotToken).not.toHaveBeenCalled();
111+
});
112+
});
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { bot } from '@/lib/bot';
2+
import { PLATFORM } from '@/lib/integrations/core/constants';
3+
import type { PlatformIntegration } from '@kilocode/db';
4+
5+
export async function withBotPlatformAuthContext<T>(
6+
platformIntegration: PlatformIntegration,
7+
fn: () => Promise<T>
8+
): Promise<T> {
9+
await bot.initialize();
10+
bot.registerSingleton();
11+
12+
if (platformIntegration.platform === PLATFORM.SLACK) {
13+
const slackAdapter = bot.getAdapter(PLATFORM.SLACK);
14+
const installation = await slackAdapter.getInstallation(
15+
platformIntegration.platform_account_id as string
16+
);
17+
if (!installation) {
18+
throw new Error(`No Slack installation for platform integration ${platformIntegration.id}`);
19+
}
20+
21+
return await slackAdapter.withBotToken(installation.botToken, fn);
22+
}
23+
24+
return await fn();
25+
}

apps/web/src/lib/bot/platform-helpers.test.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@ jest.mock('@/lib/drizzle', () => ({
1313
}));
1414

1515
import { PLATFORM } from '@/lib/integrations/core/constants';
16-
import { getPlatformIntegration, getPlatformIntegrationByBotUserId } from './platform-helpers';
16+
import {
17+
getBotDocumentationUrl,
18+
getPlatformIntegration,
19+
getPlatformIntegrationByBotUserId,
20+
getPlatformIntegrationById,
21+
} from './platform-helpers';
1722

1823
describe('platform helpers', () => {
1924
beforeEach(() => {
@@ -49,6 +54,27 @@ describe('platform helpers', () => {
4954
expect(result).toBeNull();
5055
});
5156

57+
it('returns the platform integration for a given id', async () => {
58+
const integration = {
59+
id: 'pi_slack',
60+
platform: PLATFORM.SLACK,
61+
platform_installation_id: 'T123',
62+
};
63+
mockLimit.mockResolvedValue([integration]);
64+
65+
const result = await getPlatformIntegrationById('pi_slack');
66+
67+
expect(result).toBe(integration);
68+
});
69+
70+
it('throws when no platform integration exists for an id', async () => {
71+
mockLimit.mockResolvedValue([]);
72+
73+
await expect(getPlatformIntegrationById('pi_missing')).rejects.toThrow(
74+
'Could not find platform integration pi_missing'
75+
);
76+
});
77+
5278
it('returns the platform integration for a bot user id', async () => {
5379
const integration = {
5480
id: 'pi_slack',
@@ -68,4 +94,13 @@ describe('platform helpers', () => {
6894
expect(result).toBeNull();
6995
expect(mockLimit).not.toHaveBeenCalled();
7096
});
97+
98+
it('returns platform-specific bot documentation URLs', () => {
99+
expect(getBotDocumentationUrl(PLATFORM.SLACK)).toBe(
100+
'https://kilo.ai/docs/code-with-ai/platforms/slack'
101+
);
102+
expect(getBotDocumentationUrl(PLATFORM.DISCORD)).toBe(
103+
'https://kilo.ai/docs/code-with-ai/platforms/slack'
104+
);
105+
});
71106
});

apps/web/src/lib/bot/platform-helpers.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,20 @@ export async function getPlatformIntegration(identity: PlatformIdentity) {
4848
return integration ?? null;
4949
}
5050

51+
export async function getPlatformIntegrationById(platformIntegrationId: string) {
52+
const [integration] = await db
53+
.select()
54+
.from(platform_integrations)
55+
.where(eq(platform_integrations.id, platformIntegrationId))
56+
.limit(1);
57+
58+
if (!integration) {
59+
throw new Error(`Could not find platform integration ${platformIntegrationId}`);
60+
}
61+
62+
return integration;
63+
}
64+
5165
export async function getPlatformIntegrationByBotUserId(
5266
platform: string,
5367
botUserId: string | undefined
@@ -67,3 +81,11 @@ export async function getPlatformIntegrationByBotUserId(
6781

6882
return integration ?? null;
6983
}
84+
85+
export function getBotDocumentationUrl(platform: string): string {
86+
switch (platform) {
87+
//TODO(remon): Update when we have specific docs pages for other platforms
88+
default:
89+
return 'https://kilo.ai/docs/code-with-ai/platforms/slack';
90+
}
91+
}

apps/web/src/lib/bot/run.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export async function processMessage({
2020
}) {
2121
const startedAt = Date.now();
2222

23-
// Extract and upload any image attachments from the Slack message to R2.
23+
// Extract and upload any image attachments from the chat message to R2.
2424
// This runs before the agent loop so the images are ready when a Cloud Agent
2525
// session is spawned. Failures are non-fatal — we log and continue without images.
2626
let images: Awaited<ReturnType<typeof extractAndUploadImages>>;

apps/web/src/routers/kiloclaw-billing-router.test.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { insertTestUser } from '@/tests/helpers/user.helper';
3232
import type { User } from '@kilocode/db/schema';
3333
import type Stripe from 'stripe';
3434
import { KiloPassTier, KiloPassCadence } from '@/lib/kilo-pass/enums';
35+
import { differenceInCalendarMonths } from 'date-fns';
3536

3637
// eslint-disable-next-line @typescript-eslint/no-explicit-any
3738
type AnyMock = jest.Mock<(...args: any[]) => any>;
@@ -5062,11 +5063,9 @@ describe('enrollWithCredits', () => {
50625063
expect(sub.payment_source).toBe('credits');
50635064
expect(sub.commit_ends_at).not.toBeNull();
50645065

5065-
// commit_ends_at should be ~6 months from now
5066+
// commit_ends_at should be 6 calendar months from now.
50665067
const commitEnd = new Date(sub.commit_ends_at!);
5067-
const diffDays = (commitEnd.getTime() - Date.now()) / 86_400_000;
5068-
expect(diffDays).toBeGreaterThanOrEqual(178);
5069-
expect(diffDays).toBeLessThanOrEqual(184);
5068+
expect(differenceInCalendarMonths(commitEnd, new Date())).toBe(6);
50705069
});
50715070

50725071
it('rejects enrollment when balance is insufficient', async () => {

0 commit comments

Comments
 (0)