Skip to content
Merged
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
139 changes: 105 additions & 34 deletions apps/web/src/lib/discord-bot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ import { NEXTAUTH_SECRET } from '@kilocode/web-shared/lib/config.server';
import { verifyKiloTokenForPolicy } from '@kilocode/worker-utils/kilo-token-policy';
import { createTestOrganization } from '@kilocode/web-shared/tests/helpers/organization.helper';
import { generateBotUserId } from '@/lib/bot-users/types';
import { eq } from 'drizzle-orm';
import {
DISCORD_SESSION_DENIAL_MESSAGES,
type DiscordSessionDenialReason,
} from './discord-bot/discord-session-authorization';
import { and, eq } from 'drizzle-orm';

const mockRunBot = jest.fn<(input: BotRunInput) => Promise<BotRunResult>>();
const mockRunSessionToCompletion = jest.fn<(input: RunSessionInput) => Promise<RunSessionResult>>();
Expand Down Expand Up @@ -180,59 +184,126 @@ describe('processDiscordBotMessage session start authorization', () => {
};
}

it('answers without session tools when the author has no linked Kilo account', async () => {
const refusalCases: Array<{
label: string;
reason: DiscordSessionDenialReason;
prepare: () => Promise<string>;
}> = [
{
label: 'an author with no linked Kilo account',
reason: 'unlinked_account',
prepare: async () => nextSnowflake(),
},
{
label: 'a linked account that is blocked',
reason: 'account_unavailable',
prepare: async () => {
const blockedUser = await insertTestUser({ blocked_at: new Date().toISOString() });
const discordUserId = nextSnowflake();
await linkDiscordAccount(blockedUser.id, discordUserId);
return discordUserId;
},
},
{
label: 'a linked account that does not own the installation',
reason: 'not_installation_owner',
prepare: async () => {
const otherUser = await insertTestUser();
const discordUserId = nextSnowflake();
await linkDiscordAccount(otherUser.id, discordUserId);
return discordUserId;
},
},
{
label: 'a linked account outside the installation organization',
reason: 'not_organization_member',
prepare: async () => {
const organization = await createTestOrganization(
'Discord refusal workspace',
installationOwner.id,
0
);
await db
.update(platform_integrations)
.set({ owned_by_user_id: null, owned_by_organization_id: organization.id })
.where(
and(
eq(platform_integrations.owned_by_user_id, installationOwner.id),
eq(platform_integrations.platform, 'discord')
)
);
const otherUser = await insertTestUser();
const discordUserId = nextSnowflake();
await linkDiscordAccount(otherUser.id, discordUserId);
return discordUserId;
},
},
];

for (const refusalCase of refusalCases) {
it(`refuses ${refusalCase.label} without calling the model`, async () => {
const discordUserId = await refusalCase.prepare();

const result = await processDiscordBotMessage(
`Change something in ${CONNECTED_REPOSITORY}`,
guildId,
eventContext(discordUserId)
);

expect(mockRunBot).not.toHaveBeenCalled();
expect(mockRunSessionToCompletion).not.toHaveBeenCalled();
expect(result.response).toBe(DISCORD_SESSION_DENIAL_MESSAGES[refusalCase.reason]);
expect(result.modelUsed).toBe('');
expect(result.toolCallsMade).toEqual([]);
expect(result.cloudAgentSessionId).toBeUndefined();
});
}

it.each([
{ label: 'a blocked account', state: { blocked_at: new Date().toISOString() } },
{ label: 'a disabled personal account', state: { personal_account_disabled: true } },
])('keeps the account state out of the public reply for $label', async ({ state }) => {
const unavailableUser = await insertTestUser(state);
const discordUserId = nextSnowflake();
await linkDiscordAccount(unavailableUser.id, discordUserId);

const result = await processDiscordBotMessage(
`Change something in ${CONNECTED_REPOSITORY}`,
guildId,
eventContext(nextSnowflake())
eventContext(discordUserId)
);

expect(mockRunBot).toHaveBeenCalledTimes(1);
const [runInput] = mockRunBot.mock.calls[0];
expect(runInput.tools).toEqual([]);
expect(runInput.systemPrompt).toContain('/connected-accounts');
expect(runInput.systemPrompt).not.toContain(CONNECTED_REPOSITORY);
expect(result.response).toBe('Here is an answer.');

const toolResult = await runInput.toolExecutor(spawnToolCall(CONNECTED_REPOSITORY));
expect(toolResult.content).toContain('/connected-accounts');
expect(mockRunSessionToCompletion).not.toHaveBeenCalled();
expect(result.cloudAgentSessionId).toBeUndefined();
expect(mockRunBot).not.toHaveBeenCalled();
expect(result.response).toBe(DISCORD_SESSION_DENIAL_MESSAGES.account_unavailable);
expect(result.response).not.toMatch(/block|disabl|suspend|bann/i);
});
it('places Discord context in its named prompt section', async () => {
mockDiscordContextForPrompt = 'Channel topic: weekly update';

await processDiscordBotMessage(
it('refuses a message that carries no Discord author context', async () => {
const result = await processDiscordBotMessage(
`Change something in ${CONNECTED_REPOSITORY}`,
guildId,
eventContext(nextSnowflake())
guildId
);

const [runInput] = mockRunBot.mock.calls[0];
expect(runInput.systemPrompt).toContain('<platform_conversation_context platform="discord">');
expect(runInput.systemPrompt).toContain('Channel topic: weekly update');
expect(runInput.systemPrompt).toContain('</platform_conversation_context>');
expect(mockRunBot).not.toHaveBeenCalled();
expect(result.response).toBe(DISCORD_SESSION_DENIAL_MESSAGES.unknown_requester);
expect(result.modelUsed).toBe('');
});

it('answers without session tools when the linked account lacks workspace access', async () => {
const otherUser = await insertTestUser();
it('places Discord context in its named prompt section', async () => {
mockDiscordContextForPrompt = 'Channel topic: weekly update';
const discordUserId = nextSnowflake();
await linkDiscordAccount(otherUser.id, discordUserId);
await linkDiscordAccount(installationOwner.id, discordUserId);

const result = await processDiscordBotMessage(
await processDiscordBotMessage(
`Change something in ${CONNECTED_REPOSITORY}`,
guildId,
eventContext(discordUserId)
);

const [runInput] = mockRunBot.mock.calls[0];
expect(runInput.tools).toEqual([]);
expect(runInput.systemPrompt).not.toContain('/connected-accounts');

const toolResult = await runInput.toolExecutor(spawnToolCall(CONNECTED_REPOSITORY));
expect(toolResult.content).toContain('connected to a different Kilo account');
expect(mockRunSessionToCompletion).not.toHaveBeenCalled();
expect(result.cloudAgentSessionId).toBeUndefined();
expect(runInput.systemPrompt).toContain('<platform_conversation_context platform="discord">');
expect(runInput.systemPrompt).toContain('Channel topic: weekly update');
expect(runInput.systemPrompt).toContain('</platform_conversation_context>');
});

it('starts a session for the linked installation owner on a connected repository', async () => {
Expand Down
42 changes: 22 additions & 20 deletions apps/web/src/lib/discord-bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ import {
import {
authorizeDiscordSessionRequester,
DISCORD_SESSION_DENIAL_MESSAGES,
DISCORD_SESSION_DENIAL_PROMPT_NOTICES,
} from '@/lib/discord-bot/discord-session-authorization';
import {
formatDiscordConversationContextForPrompt,
Expand Down Expand Up @@ -309,6 +308,26 @@ export async function processDiscordBotMessage(
};
}

const authorization = await authorizeDiscordSessionRequester(owner, discordEventContext?.userId);
console.log('[DiscordBot] Session authorization', {
discordUserId: authorization.discordUserId ?? null,
authorized: authorization.authorized,
reason: authorization.authorized ? undefined : authorization.reason,
});

if (!authorization.authorized) {
Comment thread
iscekic marked this conversation as resolved.
Resolved
console.log('[DiscordBot] Refused message', {
discordUserId: authorization.discordUserId ?? null,
reason: authorization.reason,
});
return {
response: DISCORD_SESSION_DENIAL_MESSAGES[authorization.reason],
modelUsed: '',
toolCallsMade: [],
installation,
};
}

// Get the configured model for this integration (validated at setup/update time)
const selectedModel = await getModel(owner);
if (!selectedModel) {
Expand Down Expand Up @@ -337,14 +356,6 @@ export async function processDiscordBotMessage(
const authToken = authResult.authToken;
const authUserId = authResult.userId;

const authorization = await authorizeDiscordSessionRequester(owner, discordEventContext?.userId);
console.log('[DiscordBot] Session authorization', {
discordUserId: authorization.discordUserId ?? null,
authorized: authorization.authorized,
reason: authorization.authorized ? undefined : authorization.reason,
});

// Gather context in parallel
let discordContextForPrompt = '';
let requesterInfo: DiscordRequesterInfo | undefined;

Expand All @@ -366,11 +377,10 @@ export async function processDiscordBotMessage(
};
}

const repoContext = authorization.authorized ? await getGitHubRepositoryContext(owner) : null;
const repoContext = await getGitHubRepositoryContext(owner);

const systemPrompt =
KILO_BOT_SYSTEM_PROMPT +
(authorization.authorized ? '' : DISCORD_SESSION_DENIAL_PROMPT_NOTICES[authorization.reason]) +
(discordContextForPrompt
? `\n\n${formatPlatformConversationContext(PLATFORM.DISCORD, discordContextForPrompt)}`
: '') +
Expand All @@ -381,7 +391,7 @@ export async function processDiscordBotMessage(
model: selectedModel,
systemPrompt,
userMessage,
tools: authorization.authorized ? [SPAWN_CLOUD_AGENT_TOOL] : [],
tools: [SPAWN_CLOUD_AGENT_TOOL],
logPrefix: '[DiscordBot]',
requestOptions: {
version: DISCORD_BOT_VERSION,
Expand All @@ -398,14 +408,6 @@ export async function processDiscordBotMessage(
return { content: `Error executing tool: Unknown tool ${toolCall.function.name}` };
}

if (!authorization.authorized) {
console.log('[DiscordBot] Denied session start', {
discordUserId: authorization.discordUserId ?? null,
reason: authorization.reason,
});
return { content: DISCORD_SESSION_DENIAL_MESSAGES[authorization.reason] };
}

const args = JSON.parse(toolCall.function.arguments);
const toolResult = await spawnCloudAgentSession(
args,
Expand Down
45 changes: 6 additions & 39 deletions apps/web/src/lib/discord-bot/discord-session-authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,54 +40,21 @@ const INSTALLATION_OWNER_MESSAGE =
'This Discord server is connected to a different Kilo account, and only its owner can start sessions from it. Use the Kilo account that owns the installation, or ask the owner to connect the installation to an organization you belong to.';
const ORGANIZATION_MEMBER_MESSAGE =
'Your linked Kilo account is not a member of this organization. Ask an organization owner or admin to add you, then mention the bot again.';

const LINK_ACCOUNT_PROMPT_NOTICE = `

## Session starts are unavailable to this Discord account
This Discord account is not linked to a Kilo account with access to this workspace, so you cannot start Cloud Agent sessions and no tools are available.
- If the request needs code changes, repository inspection, or a Cloud Agent session, reply that starting a session from Discord needs a linked Kilo account with access to this workspace and ask the user to connect Discord at ${APP_URL}/connected-accounts, then mention the bot again.
- Never claim a session started, code changed, or a pull request was opened.`;

const ACCOUNT_UNAVAILABLE_PROMPT_NOTICE = `

## Session starts are unavailable to this Kilo account
The Kilo account linked to this Discord account cannot start Cloud Agent sessions, so no tools are available.
- If the request needs code changes, repository inspection, or a Cloud Agent session, reply that this Kilo account cannot start sessions and that Kilo support can review the account.
- Never claim a session started, code changed, or a pull request was opened.`;

const INSTALLATION_OWNER_PROMPT_NOTICE = `

## Session starts are unavailable to this Discord account
This Discord server is connected to a different Kilo account, and only its owner can start sessions from it, so no tools are available.
- If the request needs code changes, repository inspection, or a Cloud Agent session, reply that the installation belongs to another Kilo account and that the owner can either use it from their own Discord account or connect the installation to an organization the user belongs to.
- Never claim a session started, code changed, or a pull request was opened.`;

const ORGANIZATION_MEMBER_PROMPT_NOTICE = `

## Session starts are unavailable to this Discord account
This Discord account is linked to a Kilo account that is not a member of the organization that owns this installation, so no tools are available.
- If the request needs code changes, repository inspection, or a Cloud Agent session, reply that the linked Kilo account needs membership in the organization and that an organization owner or admin can add it.
- Never claim a session started, code changed, or a pull request was opened.`;
/**
* The refusal reply is posted in the channel, so it gives the next step and
* names no account condition; the precise denial reason stays in the server log.
*/
const ACCOUNT_UNAVAILABLE_MESSAGE = `A Cloud Agent session cannot be started from this Discord account. Check the Discord connection of the Kilo account you use here at ${APP_URL}/connected-accounts, or contact Kilo support.`;

/** Reply text for each denial reason, so the guidance matches what can unblock it. */
export const DISCORD_SESSION_DENIAL_MESSAGES: Record<DiscordSessionDenialReason, string> = {
unknown_requester: LINK_ACCOUNT_MESSAGE,
unlinked_account: LINK_ACCOUNT_MESSAGE,
account_unavailable:
'This Kilo account cannot start Cloud Agent sessions. Contact Kilo support if you believe this is a mistake.',
account_unavailable: ACCOUNT_UNAVAILABLE_MESSAGE,
not_installation_owner: INSTALLATION_OWNER_MESSAGE,
not_organization_member: ORGANIZATION_MEMBER_MESSAGE,
};

/** System-prompt instructions for each denial reason. */
export const DISCORD_SESSION_DENIAL_PROMPT_NOTICES: Record<DiscordSessionDenialReason, string> = {
unknown_requester: LINK_ACCOUNT_PROMPT_NOTICE,
unlinked_account: LINK_ACCOUNT_PROMPT_NOTICE,
account_unavailable: ACCOUNT_UNAVAILABLE_PROMPT_NOTICE,
not_installation_owner: INSTALLATION_OWNER_PROMPT_NOTICE,
not_organization_member: ORGANIZATION_MEMBER_PROMPT_NOTICE,
};

/**
* Decide whether a Discord author may start an owner-credentialed Cloud Agent
* session for this installation.
Expand Down
Loading