Skip to content
Draft
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
47 changes: 44 additions & 3 deletions src/app/api/chat/link-account/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { after } from 'next/server';
import { captureException } from '@sentry/nextjs';
import { bot } from '@/lib/bot';
import { replayLinkedBotMessage } from '@/lib/bot/handle-linked-message';
import { APP_URL } from '@/lib/constants';
import { linkKiloUser, verifyLinkToken } from '@/lib/bot-identity';
import {
consumePendingLinkReplay,
deserializePendingLinkReplay,
} from '@/lib/bot/pending-link-replay';
import { db } from '@/lib/drizzle';
import { isOrganizationMember } from '@/lib/organizations/organizations';
import { getUserFromAuth } from '@/lib/user.server';
Expand Down Expand Up @@ -77,7 +84,8 @@ async function verifyIntegrationAccess(
* 2. Authenticate the user via NextAuth session (redirect to sign-in if needed).
* 3. Verify the user belongs to the org that owns the integration.
* 4. Write the platform identity → Kilo user mapping into Redis.
* 5. Show a success page.
* 5. Replay the original chat message when pending context exists.
* 6. Show a success page.
*/
export async function GET(request: Request) {
const url = new URL(request.url);
Expand Down Expand Up @@ -119,14 +127,47 @@ export async function GET(request: Request) {

await linkKiloUser(bot.getState(), identity, user.id);

const pendingReplayPayload = await consumePendingLinkReplay(bot.getState(), token);
let replayQueued = false;

if (pendingReplayPayload) {
try {
const replayContext = deserializePendingLinkReplay(pendingReplayPayload, bot.reviver());
replayQueued = true;

after(async () => {
await replayLinkedBotMessage({
thread: replayContext.thread,
message: replayContext.message,
user,
});
});
} catch (error) {
captureException(error, {
tags: { component: 'kilo-bot', op: 'deserialize-pending-link-replay' },
extra: {
platform: identity.platform,
teamId: identity.teamId,
userId: identity.userId,
},
});
}
}

const successMessage = replayQueued
? `Your ${identity.platform} account has been linked to your Kilo account.<br>
Kilo is now handling your original chat message. You can close this tab.<br>
If you do not see a reply in chat in a few moments, @mention Kilo again.`
: `Your ${identity.platform} account has been linked to your Kilo account.<br>
You can close this tab and @mention Kilo again in your chat.`;

return new Response(
`<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Account Linked</title></head>
<body style="font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0">
<div style="text-align:center">
<h1>Account linked</h1>
<p>Your ${identity.platform} account has been linked to your Kilo account.<br>
You can close this tab and @mention Kilo again in your chat.</p>
<p>${successMessage}</p>
</div>
</body></html>`,
{ headers: { 'content-type': 'text/html; charset=utf-8' } }
Expand Down
4 changes: 2 additions & 2 deletions src/lib/bot-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export async function unlinkKiloUser(

const HMAC_ALGORITHM = 'sha256';

const TOKEN_TTL_SECONDS = 30 * 60;
export const LINK_TOKEN_TTL_SECONDS = 30 * 60;

const NONCE_BYTES = 16;

Expand Down Expand Up @@ -117,7 +117,7 @@ export function verifyLinkToken(token: string): PlatformIdentity | null {

if (typeof data.iat !== 'number') return null;
const age = Math.floor(Date.now() / 1000) - data.iat;
if (age < 0 || age > TOKEN_TTL_SECONDS) return null;
if (age < 0 || age > LINK_TOKEN_TTL_SECONDS) return null;

if (typeof data.nonce !== 'string' || data.nonce.length === 0) return null;

Expand Down
74 changes: 37 additions & 37 deletions src/lib/bot.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { Chat, emoji, type ActionEvent, type Message, type Thread } from 'chat';
import { Chat, type ActionEvent, type Message, type Thread } from 'chat';
import { createSlackAdapter } from '@chat-adapter/slack';
import { createRedisState } from '@chat-adapter/state-redis';
import { createMemoryState } from '@chat-adapter/state-memory';
import { captureException } from '@sentry/nextjs';
import { resolveKiloUserId, unlinkKiloUser } from '@/lib/bot-identity';
import { resolveKiloUserId, type PlatformIdentity, unlinkKiloUser } from '@/lib/bot-identity';
import { handleLinkedBotMessage } from '@/lib/bot/handle-linked-message';
import { getPlatformIdentity, getPlatformIntegration } from '@/lib/bot/platform-helpers';
import { LINK_ACCOUNT_ACTION_PREFIX, promptLinkAccount } from '@/lib/bot/link-account';
import { createBotRequest, updateBotRequest } from '@/lib/bot/request-logging';
import {
createLinkAccountTarget,
LINK_ACCOUNT_ACTION_PREFIX,
promptLinkAccount,
} from '@/lib/bot/link-account';
import { storePendingLinkReplay } from '@/lib/bot/pending-link-replay';
import { findUserById } from '@/lib/user';
import { processMessage } from '@/lib/bot/run';

const slackAdapter = createSlackAdapter({
clientId: process.env.SLACK_NEXT_CLIENT_ID,
Expand All @@ -25,6 +29,31 @@ export const bot = new Chat({
state: process.env.REDIS_URL ? createRedisState() : createMemoryState(),
});

async function promptLinkAccountForMessage(
thread: Thread,
message: Message,
identity: PlatformIdentity
): Promise<void> {
const linkAccountTarget = createLinkAccountTarget(identity);

try {
await storePendingLinkReplay(bot.getState(), linkAccountTarget.token, thread, message);
} catch (error) {
captureException(error, {
tags: { component: 'kilo-bot', op: 'store-pending-link-replay' },
extra: {
messageId: message.id,
platform: identity.platform,
teamId: identity.teamId,
threadId: thread.id,
userId: identity.userId,
},
});
}

await promptLinkAccount(thread, message, linkAccountTarget);
}

bot.onNewMention(async function handleIncomingMessage(
thread: Thread,
message: Message
Expand All @@ -43,48 +72,19 @@ bot.onNewMention(async function handleIncomingMessage(
}

if (!kiloUserId) {
await promptLinkAccount(thread, message, identity);
await promptLinkAccountForMessage(thread, message, identity);
return;
}

const user = await findUserById(kiloUserId);

if (!user) {
await unlinkKiloUser(bot.getState(), identity);
await promptLinkAccount(thread, message, identity);
await promptLinkAccountForMessage(thread, message, identity);
return;
}

const platform = thread.id.split(':')[0];
const botRequestId = await createBotRequest({
createdBy: user.id,
organizationId: platformIntegration.owned_by_organization_id ?? null,
platformIntegrationId: platformIntegration.id,
platform,
platformThreadId: thread.id,
platformMessageId: message.id,
userMessage: message.text,
modelUsed: undefined,
});

const received = thread.createSentMessageFromMessage(message);
await received.addReaction(emoji.eyes);

try {
await processMessage({ thread, message, platformIntegration, user, botRequestId });
} catch (error) {
console.error('[Bot] Unhandled error in message handler:', error);
if (botRequestId) {
const errMsg = error instanceof Error ? error.message : String(error);
updateBotRequest(botRequestId, {
status: 'error',
errorMessage: errMsg.slice(0, 2000),
});
}
await thread.post({ markdown: 'Sorry, something went wrong while processing your message.' });
} finally {
await Promise.all([received.removeReaction(emoji.eyes), received.addReaction(emoji.check)]);
}
await handleLinkedBotMessage({ thread, message, platformIntegration, user });
});

// When the user clicks the "Link Account" LinkButton, Slack fires a
Expand Down
77 changes: 77 additions & 0 deletions src/lib/bot/handle-linked-message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { emoji, type Message, type Thread } from 'chat';
import type { PlatformIntegration, User } from '@kilocode/db';
import { captureException } from '@sentry/nextjs';
import { getPlatformIntegration } from '@/lib/bot/platform-helpers';
import { createBotRequest, updateBotRequest } from '@/lib/bot/request-logging';
import { processMessage } from '@/lib/bot/run';

type LinkedBotMessageContext = {
message: Message;
platformIntegration: PlatformIntegration;
thread: Thread;
user: User;
};

export async function handleLinkedBotMessage({
thread,
message,
platformIntegration,
user,
}: LinkedBotMessageContext): Promise<void> {
const platform = thread.id.split(':')[0];
const botRequestId = await createBotRequest({
createdBy: user.id,
organizationId: platformIntegration.owned_by_organization_id ?? null,
platformIntegrationId: platformIntegration.id,
platform,
platformThreadId: thread.id,
platformMessageId: message.id,
userMessage: message.text,
modelUsed: undefined,
});

const received = thread.createSentMessageFromMessage(message);
await received.addReaction(emoji.eyes);

try {
await processMessage({ thread, message, platformIntegration, user, botRequestId });
} catch (error) {
console.error('[Bot] Unhandled error in message handler:', error);
if (botRequestId) {
const errMsg = error instanceof Error ? error.message : String(error);
updateBotRequest(botRequestId, {
status: 'error',
errorMessage: errMsg.slice(0, 2000),
});
}
await thread.post({ markdown: 'Sorry, something went wrong while processing your message.' });
} finally {
await Promise.all([received.removeReaction(emoji.eyes), received.addReaction(emoji.check)]);
}
}

export async function replayLinkedBotMessage({
thread,
message,
user,
}: {
thread: Thread;
message: Message;
user: User;
}): Promise<void> {
const platformIntegration = await getPlatformIntegration(thread, message);

if (!platformIntegration) {
captureException(new Error('No active platform integration found during link replay'), {
extra: {
messageId: message.id,
platform: thread.id.split(':')[0],
threadId: thread.id,
userId: user.id,
},
});
return;
}

await handleLinkedBotMessage({ thread, message, platformIntegration, user });
}
28 changes: 21 additions & 7 deletions src/lib/bot/link-account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,36 +7,50 @@ const LINK_ACCOUNT_PATH = '/api/chat/link-account';

export const LINK_ACCOUNT_ACTION_PREFIX = `link-${APP_URL}${LINK_ACCOUNT_PATH}`;

function buildLinkAccountUrl(identity: PlatformIdentity): string {
export type LinkAccountTarget = {
token: string;
url: string;
};

function buildLinkAccountUrl(token: string): string {
const url = new URL(LINK_ACCOUNT_PATH, APP_URL);
url.searchParams.set('token', createLinkToken(identity));
url.searchParams.set('token', token);
return url.toString();
}

function linkAccountCard(linkUrl: string) {
export function createLinkAccountTarget(identity: PlatformIdentity): LinkAccountTarget {
const token = createLinkToken(identity);
return {
token,
url: buildLinkAccountUrl(token),
};
}

function linkAccountCard(linkTarget: LinkAccountTarget) {
return Card({
title: 'Link your Kilo account',
children: [
Section([
CardText(
'To use Kilo from this workspace you first need to link your chat account. ' +
'Click the button below to sign in and link your account.'
'Click the button below to sign in and link your account. ' +
'After linking, Kilo will continue with your original message automatically.'
),
]),
Actions([LinkButton({ label: 'Link Account', url: linkUrl, style: 'primary' })]),
Actions([LinkButton({ label: 'Link Account', url: linkTarget.url, style: 'primary' })]),
],
});
}

export async function promptLinkAccount(
thread: Thread,
message: Message,
identity: PlatformIdentity
linkTarget: LinkAccountTarget
): Promise<void> {
// Post to the channel when the @mention is top-level, otherwise into the thread.
const target = isChannelLevelMessage(thread, message) ? thread.channel : thread;

await target.postEphemeral(message.author, linkAccountCard(buildLinkAccountUrl(identity)), {
await target.postEphemeral(message.author, linkAccountCard(linkTarget), {
fallbackToDM: true,
});
}
Loading