diff --git a/apps/web/src/app/api/chat/link-account/route.ts b/apps/web/src/app/api/chat/link-account/route.ts index 9153b9032..d3cf857c7 100644 --- a/apps/web/src/app/api/chat/link-account/route.ts +++ b/apps/web/src/app/api/chat/link-account/route.ts @@ -1,6 +1,5 @@ import { bot } from '@/lib/bot'; import { APP_URL } from '@/lib/constants'; -import { captureException } from '@sentry/nextjs'; import { after } from 'next/server'; import { consumeLinkAccountContext, @@ -14,9 +13,8 @@ import { getPlatformIntegration, } from '@/lib/bot/platform-helpers'; import { botPlatforms } from '@/lib/bot/platforms'; -import { processLinkedMessage } from '@/lib/bot/run'; -import { Message, ThreadImpl, type Thread } from 'chat'; -import type { User } from '@kilocode/db'; +import { reprocessLinkedMessage } from '@/lib/bot/run'; +import { Message, ThreadImpl } from 'chat'; function errorPage(title: string, message: string, status: number): Response { return new Response( @@ -115,9 +113,17 @@ export async function GET(request: Request) { await linkKiloUser(bot.getState(), identity, user.id); if (await consumeLinkAccountContext(bot.getState(), contextKey)) { - after(() => - reprocessLinkedMessage(identity, ThreadImpl.fromJSON(thread), Message.fromJSON(message), user) - ); + const platformIntegration = await getPlatformIntegration(identity); + if (platformIntegration) { + after(() => + reprocessLinkedMessage({ + platformIntegration, + thread: ThreadImpl.fromJSON(thread), + message: Message.fromJSON(message), + user, + }) + ); + } } return new Response( @@ -133,37 +139,3 @@ export async function GET(request: Request) { { headers: { 'content-type': 'text/html; charset=utf-8' } } ); } - -async function reprocessLinkedMessage( - identity: PlatformIdentity, - thread: Thread, - message: Message, - user: User -): Promise { - try { - const platformIntegration = await getPlatformIntegration(identity); - if (!platformIntegration) return; - - await botPlatforms.require(platformIntegration.platform).withAuthContext({ - platformIntegration, - fn: async () => { - await processLinkedMessage({ - thread, - message, - platformIntegration, - user, - }); - }, - }); - } catch (error) { - console.error('[Bot] Failed to reprocess linked message:', error); - captureException(error, { - tags: { component: 'kilo-bot', op: 'link-account-reprocess-message' }, - extra: { - threadId: thread.id, - messageId: message.id, - userId: user.id, - }, - }); - } -} diff --git a/apps/web/src/app/api/integrations/github/callback/route.test.ts b/apps/web/src/app/api/integrations/github/callback/route.test.ts index 38c0ca7cc..818a3435e 100644 --- a/apps/web/src/app/api/integrations/github/callback/route.test.ts +++ b/apps/web/src/app/api/integrations/github/callback/route.test.ts @@ -3,19 +3,35 @@ import { NextRequest, NextResponse } from 'next/server'; import { getUserFromAuth } from '@/lib/user.server'; import { verifyGitHubBotLinkState } from '@/lib/bot/github-link-state'; import { exchangeGitHubOAuthCode } from '@/lib/integrations/platforms/github/adapter'; -import { linkKiloUser } from '@/lib/bot-identity'; +import { + consumeLinkAccountContext, + linkKiloUser, + readLinkAccountContext, +} from '@/lib/bot-identity'; import { bot } from '@/lib/bot'; +import { reprocessLinkedMessage } from '@/lib/bot/run'; import { failureResult } from '@/lib/maybe-result'; import { findIntegrationByInstallationId } from '@/lib/integrations/db/platform-integrations'; import { isOrganizationMember } from '@/lib/organizations/organizations'; -import type { StateAdapter } from 'chat'; +import type { SerializedMessage, SerializedThread, StateAdapter } from 'chat'; const mockState = { kind: 'state' } as unknown as StateAdapter; const mockIsEnabledForBot = jest.fn(); +const mockedAfter = jest.fn(); +jest.mock('next/server', () => { + const actual = jest.requireActual('next/server'); + return { + ...actual, + after: (fn: () => Promise | void) => mockedAfter(fn), + }; +}); jest.mock('@/lib/user.server'); jest.mock('@/lib/bot/github-link-state'); jest.mock('@/lib/bot-identity'); +jest.mock('@/lib/bot/run', () => ({ + reprocessLinkedMessage: jest.fn(async () => undefined), +})); jest.mock('@/lib/integrations/platforms/github/adapter'); jest.mock('@/lib/bot', () => ({ bot: { @@ -66,11 +82,22 @@ jest.mock('@sentry/nextjs', () => ({ captureException: jest.fn(), captureMessage: jest.fn(), })); +jest.mock( + 'chat', + () => ({ + Message: { fromJSON: jest.fn(value => value) }, + ThreadImpl: { fromJSON: jest.fn(value => value) }, + }), + { virtual: true } +); const mockedGetUserFromAuth = jest.mocked(getUserFromAuth); const mockedVerifyGitHubBotLinkState = jest.mocked(verifyGitHubBotLinkState); const mockedExchangeGitHubOAuthCode = jest.mocked(exchangeGitHubOAuthCode); const mockedLinkKiloUser = jest.mocked(linkKiloUser); +const mockedReadLinkAccountContext = jest.mocked(readLinkAccountContext); +const mockedConsumeLinkAccountContext = jest.mocked(consumeLinkAccountContext); +const mockedReprocessLinkedMessage = jest.mocked(reprocessLinkedMessage); const mockedBot = jest.mocked(bot); const mockedFindIntegrationByInstallationId = jest.mocked(findIntegrationByInstallationId); const mockedIsOrganizationMember = jest.mocked(isOrganizationMember); @@ -103,6 +130,7 @@ describe('GET /api/integrations/github/callback bot link flow', () => { userId: USER_ID, installationId: INSTALLATION_ID, callbackPath: '/github/link', + contextKey: null, }); mockedExchangeGitHubOAuthCode.mockResolvedValue({ id: GITHUB_USER_ID, login: 'octocat' }); mockedFindIntegrationByInstallationId.mockResolvedValue({ @@ -113,6 +141,8 @@ describe('GET /api/integrations/github/callback bot link flow', () => { } as never); mockedIsOrganizationMember.mockResolvedValue(true); mockIsEnabledForBot.mockReturnValue(true); + mockedReadLinkAccountContext.mockResolvedValue(null); + mockedConsumeLinkAccountContext.mockResolvedValue(true); }); test('redirects unauthenticated bot-link callbacks to existing callback auth fallback', async () => { @@ -150,6 +180,7 @@ describe('GET /api/integrations/github/callback bot link flow', () => { userId: OTHER_USER_ID, installationId: INSTALLATION_ID, callbackPath: '/github/link', + contextKey: null, }); const { GET } = await import('./route'); @@ -213,6 +244,122 @@ describe('GET /api/integrations/github/callback bot link flow', () => { expect(mockedExchangeGitHubOAuthCode).toHaveBeenCalledWith('abc', 'lite'); }); + test('replays the original mention when the link state carries a context key', async () => { + const integration = { + owned_by_organization_id: 'org_1', + owned_by_user_id: null, + github_app_type: 'standard', + metadata: { bot_enabled: true }, + }; + const thread: SerializedThread = { + _type: 'chat:Thread', + adapterName: 'github', + channelId: 'github:acme/widgets', + id: 'github:acme/widgets:issue:42', + isDM: false, + }; + const message: SerializedMessage = { + _type: 'chat:Message', + attachments: [], + author: { + fullName: 'octocat', + isBot: false, + isMe: false, + userId: '12345', + userName: 'octocat', + }, + formatted: { type: 'root', children: [] }, + id: 'm_1', + metadata: { dateSent: '2026-05-05T07:32:52.000Z', edited: false }, + raw: {}, + text: '@kilocode fix this', + threadId: 'github:acme/widgets:issue:42', + }; + + mockedFindIntegrationByInstallationId.mockResolvedValue(integration as never); + mockedVerifyGitHubBotLinkState.mockReturnValue({ + userId: USER_ID, + installationId: INSTALLATION_ID, + callbackPath: '/github/link', + contextKey: 'link-account-context:abc', + }); + mockedReadLinkAccountContext.mockResolvedValue({ thread, message }); + mockedConsumeLinkAccountContext.mockResolvedValue(true); + + const { GET } = await import('./route'); + const response = await GET( + makeRequest('/api/integrations/github/callback?code=abc&state=signed') as never + ); + + expect(response.status).toBe(200); + await expect(response.text()).resolves.toContain('Kilo is already processing your message'); + expect(mockedLinkKiloUser).toHaveBeenCalled(); + expect(mockedReadLinkAccountContext).toHaveBeenCalledWith( + mockState, + 'link-account-context:abc' + ); + expect(mockedConsumeLinkAccountContext).toHaveBeenCalledWith( + mockState, + 'link-account-context:abc' + ); + expect(mockedAfter).toHaveBeenCalledTimes(1); + const scheduled = mockedAfter.mock.calls[0][0] as () => Promise; + await scheduled(); + expect(mockedReprocessLinkedMessage).toHaveBeenCalledWith( + expect.objectContaining({ + platformIntegration: integration, + thread, + message, + user: expect.objectContaining({ id: USER_ID }), + }) + ); + }); + + test('skips replay when the context was already consumed (duplicate OAuth callback)', async () => { + mockedVerifyGitHubBotLinkState.mockReturnValue({ + userId: USER_ID, + installationId: INSTALLATION_ID, + callbackPath: '/github/link', + contextKey: 'link-account-context:abc', + }); + mockedReadLinkAccountContext.mockResolvedValue({ + thread: { + _type: 'chat:Thread', + adapterName: 'github', + channelId: 'github:acme/widgets', + id: 'github:acme/widgets:issue:42', + isDM: false, + }, + message: { + _type: 'chat:Message', + attachments: [], + author: { + fullName: 'octocat', + isBot: false, + isMe: false, + userId: '12345', + userName: 'octocat', + }, + formatted: { type: 'root', children: [] }, + id: 'm_1', + metadata: { dateSent: '2026-05-05T07:32:52.000Z', edited: false }, + raw: {}, + text: '@kilocode fix this', + threadId: 'github:acme/widgets:issue:42', + }, + }); + mockedConsumeLinkAccountContext.mockResolvedValue(false); + + const { GET } = await import('./route'); + const response = await GET( + makeRequest('/api/integrations/github/callback?code=abc&state=signed') as never + ); + + expect(response.status).toBe(200); + await expect(response.text()).resolves.toContain('mention Kilo again'); + expect(mockedAfter).not.toHaveBeenCalled(); + }); + test('rejects bot-link callbacks for integrations without bot_enabled metadata', async () => { mockedFindIntegrationByInstallationId.mockResolvedValue({ owned_by_organization_id: 'org_1', diff --git a/apps/web/src/app/api/integrations/github/callback/route.ts b/apps/web/src/app/api/integrations/github/callback/route.ts index 75d9f37fc..73a303c33 100644 --- a/apps/web/src/app/api/integrations/github/callback/route.ts +++ b/apps/web/src/app/api/integrations/github/callback/route.ts @@ -1,5 +1,5 @@ import type { NextRequest } from 'next/server'; -import { NextResponse } from 'next/server'; +import { NextResponse, after } from 'next/server'; import { getUserFromAuth } from '@/lib/user.server'; import { Octokit } from '@octokit/rest'; import { createAppAuth } from '@octokit/auth-app'; @@ -22,11 +22,18 @@ import type { } from '@/lib/integrations/core/types'; import { captureException, captureMessage } from '@sentry/nextjs'; import { verifyGitHubBotLinkState } from '@/lib/bot/github-link-state'; -import { linkKiloUser } from '@/lib/bot-identity'; +import { + consumeLinkAccountContext, + linkKiloUser, + readLinkAccountContext, +} from '@/lib/bot-identity'; import { bot } from '@/lib/bot'; +import { reprocessLinkedMessage } from '@/lib/bot/run'; import { isOrganizationMember } from '@/lib/organizations/organizations'; import { PLATFORM } from '@/lib/integrations/core/constants'; import { botPlatforms } from '@/lib/bot/platforms'; +import { Message, ThreadImpl } from 'chat'; +import type { PlatformIntegration, User } from '@kilocode/db'; function htmlPage(title: string, message: string, status = 200): Response { return new Response( @@ -42,7 +49,7 @@ function htmlPage(title: string, message: string, status = 200): Response { ); } -async function handleGitHubBotLinkCallback(request: NextRequest, user: { id: string }) { +async function handleGitHubBotLinkCallback(request: NextRequest, user: User) { const searchParams = request.nextUrl.searchParams; const code = searchParams.get('code'); const state = verifyGitHubBotLinkState(searchParams.get('state')); @@ -100,10 +107,44 @@ async function handleGitHubBotLinkCallback(request: NextRequest, user: { id: str user.id ); + const replayed = await scheduleMessageReplay(state.contextKey, integration, user); + return htmlPage( 'GitHub account linked', - `GitHub account ${githubUser.login} has been linked to your Kilo account.
You can return to GitHub and mention Kilo again.` + `GitHub account ${githubUser.login} has been linked to your Kilo account.
${ + replayed + ? 'You can close this tab — Kilo is already processing your message.' + : 'You can return to GitHub and mention Kilo again.' + }` + ); +} + +/** + * If the link flow carried a message context key, replay the original + * mention so the user does not need to ping Kilo a second time. Matches + * the Slack link-account UX where replay happens automatically. + */ +async function scheduleMessageReplay( + contextKey: string | null, + platformIntegration: PlatformIntegration, + user: User +): Promise { + if (!contextKey) return false; + + const context = await readLinkAccountContext(bot.getState(), contextKey); + if (!context) return false; + + if (!(await consumeLinkAccountContext(bot.getState(), contextKey))) return false; + + after(() => + reprocessLinkedMessage({ + platformIntegration, + thread: ThreadImpl.fromJSON(context.thread), + message: Message.fromJSON(context.message), + user, + }) ); + return true; } /** diff --git a/apps/web/src/app/github/link/route.test.ts b/apps/web/src/app/github/link/route.test.ts index 4c2eaccdc..e5924fde5 100644 --- a/apps/web/src/app/github/link/route.test.ts +++ b/apps/web/src/app/github/link/route.test.ts @@ -57,6 +57,7 @@ describe('GET /github/link', () => { mockedVerifyGitHubLinkToken.mockReturnValue({ platformIntegrationId: PLATFORM_INTEGRATION_ID, installationId: INSTALLATION_ID, + contextKey: null, }); mockedVerifyGitHubBotLinkState.mockReturnValue(null); mockedCreateGitHubBotLinkState.mockReturnValue('signed-state'); @@ -136,10 +137,27 @@ describe('GET /github/link', () => { expect(redirectUrl.searchParams.get('state')).toBe('signed-state'); expect(redirectUrl.searchParams.get('scope')).toBe('read:user'); expect(mockedGetPlatformIntegrationById).toHaveBeenCalledWith(PLATFORM_INTEGRATION_ID); - expect(mockedCreateGitHubBotLinkState).toHaveBeenCalledWith(USER_ID, INSTALLATION_ID); + expect(mockedCreateGitHubBotLinkState).toHaveBeenCalledWith(USER_ID, INSTALLATION_ID, { + contextKey: undefined, + }); expect(mockedGetGitHubAppCredentials).toHaveBeenCalledWith('standard'); }); + test('propagates the context key from the link token into the OAuth state', async () => { + mockedVerifyGitHubLinkToken.mockReturnValue({ + platformIntegrationId: PLATFORM_INTEGRATION_ID, + installationId: INSTALLATION_ID, + contextKey: 'link-account-context:abc', + }); + + const { GET } = await import('./route'); + await GET(makeRequest('/github/link?token=signed-token') as never); + + expect(mockedCreateGitHubBotLinkState).toHaveBeenCalledWith(USER_ID, INSTALLATION_ID, { + contextKey: 'link-account-context:abc', + }); + }); + test("picks credentials matching the integration's github_app_type", async () => { mockedGetPlatformIntegrationById.mockResolvedValue({ id: PLATFORM_INTEGRATION_ID, diff --git a/apps/web/src/app/github/link/route.ts b/apps/web/src/app/github/link/route.ts index ba68da738..8626184b4 100644 --- a/apps/web/src/app/github/link/route.ts +++ b/apps/web/src/app/github/link/route.ts @@ -92,7 +92,9 @@ export async function GET(request: NextRequest) { ); authorizeUrl.searchParams.set( 'state', - createGitHubBotLinkState(user.id, verifiedToken.installationId) + createGitHubBotLinkState(user.id, verifiedToken.installationId, { + contextKey: verifiedToken.contextKey ?? undefined, + }) ); authorizeUrl.searchParams.set('scope', 'read:user'); diff --git a/apps/web/src/lib/bot-identity.ts b/apps/web/src/lib/bot-identity.ts index 25f749723..a911eb33b 100644 --- a/apps/web/src/lib/bot-identity.ts +++ b/apps/web/src/lib/bot-identity.ts @@ -216,6 +216,30 @@ function createLinkToken(payload: LinkTokenPayload): string { return `${encodedPayload}.${hmacSign(encodedPayload)}`; } +/** + * Store thread + message context under a fresh Redis key so a link flow + * (Slack's single-hop route or GitHub's OAuth round-trip) can replay the + * user's original message after account linking succeeds. + */ +export async function storeLinkAccountContext( + state: StateAdapter, + context: LinkAccountContext +): Promise { + const contextKey = `${LINK_ACCOUNT_CONTEXT_KEY_PREFIX}${crypto.randomBytes(NONCE_BYTES).toString('base64url')}`; + await state.set(contextKey, context, LINK_ACCOUNT_CONTEXT_TTL_MS); + return contextKey; +} + +export async function readLinkAccountContext( + state: StateAdapter, + contextKey: string +): Promise { + const raw = await state.get(contextKey); + if (raw === null || raw === undefined) return null; + const parsed = linkAccountContextSchema.safeParse(raw); + return parsed.success ? parsed.data : null; +} + export async function createLinkAccountToken({ identity, thread, @@ -225,8 +249,7 @@ export async function createLinkAccountToken({ identity: PlatformIdentity; state: StateAdapter; }): Promise { - const contextKey = `${LINK_ACCOUNT_CONTEXT_KEY_PREFIX}${crypto.randomBytes(NONCE_BYTES).toString('base64url')}`; - await state.set(contextKey, { thread, message }, LINK_ACCOUNT_CONTEXT_TTL_MS); + const contextKey = await storeLinkAccountContext(state, { thread, message }); return createLinkToken({ identity, contextKey }); } diff --git a/apps/web/src/lib/bot/github-link-state.ts b/apps/web/src/lib/bot/github-link-state.ts index f9ac7496d..4776be252 100644 --- a/apps/web/src/lib/bot/github-link-state.ts +++ b/apps/web/src/lib/bot/github-link-state.ts @@ -10,6 +10,12 @@ type GitHubBotLinkStatePayload = { userId: string; installationId: string; callbackPath: string; + /** + * Redis key referencing the thread + message to replay after linking. + * Preserved across the GitHub OAuth round-trip so the callback can + * resume processing the original mention once the account is linked. + */ + contextKey?: string; iat: number; nonce: string; }; @@ -18,6 +24,7 @@ export type VerifiedGitHubBotLinkState = { userId: string; installationId: string; callbackPath: string; + contextKey: string | null; }; function sign(data: string): string { @@ -27,12 +34,13 @@ function sign(data: string): string { export function createGitHubBotLinkState( userId: string, installationId: string, - callbackPath = '/github/link' + options: { callbackPath?: string; contextKey?: string } = {} ): string { const payload: GitHubBotLinkStatePayload = { userId, installationId, - callbackPath, + callbackPath: options.callbackPath ?? '/github/link', + ...(options.contextKey ? { contextKey: options.contextKey } : {}), iat: Math.floor(Date.now() / 1000), nonce: crypto.randomBytes(NONCE_BYTES).toString('base64url'), }; @@ -67,6 +75,7 @@ export function verifyGitHubBotLinkState(state: string | null): VerifiedGitHubBo if (typeof data.callbackPath !== 'string' || !data.callbackPath.startsWith('/')) return null; if (typeof data.iat !== 'number') return null; if (typeof data.nonce !== 'string' || data.nonce.length === 0) return null; + if (data.contextKey !== undefined && typeof data.contextKey !== 'string') return null; const ageSeconds = Math.floor(Date.now() / 1000) - data.iat; if (ageSeconds < 0 || ageSeconds > STATE_TTL_SECONDS) return null; @@ -75,6 +84,7 @@ export function verifyGitHubBotLinkState(state: string | null): VerifiedGitHubBo userId: data.userId, installationId: data.installationId, callbackPath: data.callbackPath, + contextKey: data.contextKey ?? null, }; } catch { return null; diff --git a/apps/web/src/lib/bot/github-link-token.ts b/apps/web/src/lib/bot/github-link-token.ts index bbd58e19f..5ec284ef6 100644 --- a/apps/web/src/lib/bot/github-link-token.ts +++ b/apps/web/src/lib/bot/github-link-token.ts @@ -15,6 +15,11 @@ const NONCE_BYTES = 16; type GitHubLinkTokenPayload = { platformIntegrationId: string; installationId: string; + /** + * Redis key referencing the thread + message to replay after linking. + * Optional because older tokens and non-mention flows may not carry one. + */ + contextKey?: string; iat: number; nonce: string; }; @@ -22,6 +27,7 @@ type GitHubLinkTokenPayload = { export type VerifiedGitHubLinkToken = { platformIntegrationId: string; installationId: string; + contextKey: string | null; }; function sign(data: string): string { @@ -31,10 +37,12 @@ function sign(data: string): string { export function createGitHubLinkToken(params: { platformIntegrationId: string; installationId: string; + contextKey?: string; }): string { const payload: GitHubLinkTokenPayload = { platformIntegrationId: params.platformIntegrationId, installationId: params.installationId, + ...(params.contextKey ? { contextKey: params.contextKey } : {}), iat: Math.floor(Date.now() / 1000), nonce: crypto.randomBytes(NONCE_BYTES).toString('base64url'), }; @@ -70,6 +78,7 @@ export function verifyGitHubLinkToken(token: string | null): VerifiedGitHubLinkT if (typeof data.installationId !== 'string' || data.installationId.length === 0) return null; if (typeof data.iat !== 'number') return null; if (typeof data.nonce !== 'string' || data.nonce.length === 0) return null; + if (data.contextKey !== undefined && typeof data.contextKey !== 'string') return null; const ageSeconds = Math.floor(Date.now() / 1000) - data.iat; if (ageSeconds < 0 || ageSeconds > TOKEN_TTL_SECONDS) return null; @@ -77,6 +86,7 @@ export function verifyGitHubLinkToken(token: string | null): VerifiedGitHubLinkT return { platformIntegrationId: data.platformIntegrationId, installationId: data.installationId, + contextKey: data.contextKey ?? null, }; } catch { return null; diff --git a/apps/web/src/lib/bot/platforms/github.ts b/apps/web/src/lib/bot/platforms/github.ts index 83ab641c7..c88b66a5c 100644 --- a/apps/web/src/lib/bot/platforms/github.ts +++ b/apps/web/src/lib/bot/platforms/github.ts @@ -7,6 +7,7 @@ import { truncate, } from '@/lib/bot/platforms/shared'; import type { BotPlatform } from '@/lib/bot/platforms/types'; +import { storeLinkAccountContext } from '@/lib/bot-identity'; import { APP_URL } from '@/lib/constants'; import { generateGitHubInstallationToken } from '@/lib/integrations/platforms/github/adapter'; import { PLATFORM } from '@/lib/integrations/core/constants'; @@ -298,13 +299,22 @@ export function createGitHubBotPlatform(githubAdapter: GitHubInstallationLookup) getGitHubRepositoryReference(thread, message) ); }, - async promptLinkAccount({ thread, identity, platformIntegration }) { + async promptLinkAccount({ thread, message, identity, platformIntegration, state }) { + // Stash the thread + message so the OAuth callback can replay the + // mention automatically once the account is linked — matching the + // Slack link-account flow where replay happens after a single hop. + const contextKey = await storeLinkAccountContext(state, { + thread: thread.toJSON(), + message: message.toJSON(), + }); + const url = new URL(GITHUB_LINK_PATH, APP_URL); url.searchParams.set( 'token', createGitHubLinkToken({ platformIntegrationId: platformIntegration.id, installationId: identity.teamId, + contextKey, }) ); @@ -312,7 +322,7 @@ export function createGitHubBotPlatform(githubAdapter: GitHubInstallationLookup) markdown: 'To use Kilo from GitHub you first need to link your GitHub account to Kilo. ' + `[Link your Kilo account](${url.toString()}) to continue. ` + - 'After linking, mention me again in this issue or pull request.', + 'Once linked, I will pick up your message automatically.', }); }, async withAuthContext({ fn }) { diff --git a/apps/web/src/lib/bot/run.ts b/apps/web/src/lib/bot/run.ts index 8451ce32a..b2fba5523 100644 --- a/apps/web/src/lib/bot/run.ts +++ b/apps/web/src/lib/bot/run.ts @@ -1,10 +1,48 @@ import { createBotRequest, updateBotRequest } from '@/lib/bot/request-logging'; import { runBotAgent } from '@/lib/bot/agent-runner'; import { extractAndUploadImages } from '@/lib/bot/images'; +import { botPlatforms } from '@/lib/bot/platforms'; import type { PlatformIntegration, User } from '@kilocode/db'; import type { Message, Thread } from 'chat'; import { captureException } from '@sentry/nextjs'; +/** + * Replay a user's original mention after their account is linked. Shared + * between the Slack single-hop link route and the GitHub OAuth callback so + * the experience is identical: link, then immediately pick up the message. + */ +export async function reprocessLinkedMessage({ + platformIntegration, + thread, + message, + user, +}: { + platformIntegration: PlatformIntegration; + thread: Thread; + message: Message; + user: User; +}): Promise { + try { + await botPlatforms.require(platformIntegration.platform).withAuthContext({ + platformIntegration, + fn: async () => { + await processLinkedMessage({ thread, message, platformIntegration, user }); + }, + }); + } catch (error) { + console.error('[Bot] Failed to reprocess linked message:', error); + captureException(error, { + tags: { component: 'kilo-bot', op: 'link-account-reprocess-message' }, + extra: { + platform: platformIntegration.platform, + threadId: thread.id, + messageId: message.id, + userId: user.id, + }, + }); + } +} + export async function processLinkedMessage({ thread, message,