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
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ AI_RESPONSE_MODEL= # Override AI response model (default: claude-sonn
AI_CONFIDENCE_MODEL= # Override confidence scoring model
AI_CLASSIFIER_MODEL= # Override ticket classifier model
AI_SENTIMENT_MODEL= # Override sentiment analysis model
AI_QUERY_DISTILLER_MODEL= # Override the docs-search query distiller model

# ─── Shadow Mode ─────────────────────────────────────────────────────────────
# Set to 'true' to run the full AI pipeline but LOG responses instead of posting
Expand All @@ -49,7 +50,7 @@ DISCORD_BOT_TOKEN="" # Bot token used by the discord-bot app
DISCORD_CLIENT_ID=""
DISCORD_CLIENT_SECRET="" # OAuth client secret (dashboard Discord login)
DISCORD_MCP_TOKEN="" # Shared secret guarding the discord-mcp server
MONITORED_CHANNEL_IDS=""
MONITORED_CHANNEL_IDS="" # Forum/channel IDs the bot answers in. REQUIRED — empty means no channels
DISCORD_DIGEST_CHANNEL_ID= # Channel for onboarding digest posts
# Guild (server) IDs per community — replaces the old single GUILD_ID
DISCORD_GUILD_COPILOTKIT= # CopilotKit Discord server ID
Expand Down
49 changes: 49 additions & 0 deletions apps/discord-bot/src/__tests__/message-create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ vi.mock('discord.js', async (importOriginal) => {
import { handleMessageCreate } from '../events/message-create.js';
import { prisma } from '@copilotkit/outpost/db';
import { createJob } from '@copilotkit/outpost/queue';
import { isShadowMode, handleShadowMessage } from '../lib/shadow-mode.js';

const TICKET = {
id: 'ticket-1',
Expand Down Expand Up @@ -68,6 +69,7 @@ function makeMessage(overrides: Record<string, unknown> = {}) {

describe('handleMessageCreate', () => {
beforeEach(() => {
vi.mocked(isShadowMode).mockReturnValue(false);
// findTicketByThreadId returns the existing ticket
vi.mocked(prisma.ticket.findFirst).mockResolvedValue(TICKET as ReturnType<typeof prisma.ticket.findFirst> extends Promise<infer T> ? T : never);
vi.mocked(prisma.message.create).mockResolvedValue({
Expand Down Expand Up @@ -149,6 +151,53 @@ describe('handleMessageCreate', () => {
});
});

// Regression: Discord dispatches BOTH ThreadCreate and MessageCreate for a
// new forum post. handleThreadCreate already ingests the starter message,
// so handling it again here enqueued a SECOND AI_RESPONSE job for the same
// ticket — the same question retrieved and answered twice, ~0.2s apart.
// A thread's starter message shares the thread's own ID.
it('ignores the thread starter message already ingested by ThreadCreate', async () => {
const starter = makeMessage({ id: 'thread-123' });

await handleMessageCreate(starter);

expect(prisma.ticket.findFirst).not.toHaveBeenCalled();
expect(prisma.message.create).not.toHaveBeenCalled();
expect(createJob).not.toHaveBeenCalled();
});

it('ignores the thread starter message in shadow mode too', async () => {
vi.mocked(isShadowMode).mockReturnValue(true);
const starter = makeMessage({ id: 'thread-123' });

await handleMessageCreate(starter);

expect(handleShadowMessage).not.toHaveBeenCalled();
expect(createJob).not.toHaveBeenCalled();
});

// The gate above must not swallow real replies. Asserted on the message
// record rather than on an enqueue: since #172/#191, `InboundHandler` never
// enqueues AI_RESPONSE for a reply on ANY platform — Outpost answers once per
// ticket, on the opening message, and a human owns the thread after that. This
// test predates that rule and asserted the enqueue, which is why it survived
// the textual merge and then failed. What it is actually here to prove is that
// `message.id === threadId` distinguishes the starter message from a reply,
// and the message record is what shows that.
it('still processes genuine replies in the same thread', async () => {
const reply = makeMessage({ id: 'msg-777' });

await handleMessageCreate(reply);

expect(prisma.message.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ ticketId: 'ticket-1', type: 'USER' }),
}),
);
// And the one-answer rule still holds: a reply enqueues nothing.
expect(createJob).not.toHaveBeenCalled();
});

it('uses DiscordAdapter.parseInboundEvent to normalize message events', async () => {
const message = makeMessage();
await handleMessageCreate(message);
Expand Down
85 changes: 82 additions & 3 deletions apps/discord-bot/src/__tests__/thread-create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,18 @@ vi.mock('../lib/shadow-mode.js', () => ({
handleShadowThreadCreate: vi.fn().mockResolvedValue('shadow-ticket-id'),
}));

vi.mock('../config.js', () => ({
config: {
// Mutable so the fail-closed case can empty MONITORED_CHANNEL_IDS.
const { testConfig } = vi.hoisted(() => ({
testConfig: {
discordToken: 'test-token',
clientId: 'test-client-id',
guildId: 'test-guild-id',
monitoredChannelIds: ['forum-channel-1'],
monitoredChannelIds: ['forum-channel-1'] as string[],
},
}));

vi.mock('../config.js', () => ({ config: testConfig }));

// Mock discord.js REST to prevent real HTTP calls
vi.mock('discord.js', async (importOriginal) => {
const actual = await importOriginal() as Record<string, unknown>;
Expand All @@ -36,6 +39,7 @@ vi.mock('discord.js', async (importOriginal) => {
import { handleThreadCreate } from '../events/thread-create.js';
import { prisma } from '@copilotkit/outpost/db';
import { createJob } from '@copilotkit/outpost/queue';
import { isShadowMode, handleShadowThreadCreate } from '../lib/shadow-mode.js';
import { PlatformDiscordAdapter } from '@copilotkit/outpost/shared/platforms';

function makeThread(overrides: Record<string, unknown> = {}) {
Expand All @@ -57,6 +61,9 @@ function makeThread(overrides: Record<string, unknown> = {}) {

describe('handleThreadCreate', () => {
beforeEach(() => {
testConfig.monitoredChannelIds = ['forum-channel-1'];
vi.mocked(isShadowMode).mockReturnValue(false);

vi.mocked(prisma.ticket.create).mockResolvedValue({
id: 'ticket-internal-id',
displayId: 'TKT-AB12CD34',
Expand Down Expand Up @@ -204,6 +211,78 @@ describe('handleThreadCreate', () => {
consoleSpy.mockRestore();
});

// An unset MONITORED_CHANNEL_IDS used to mean "monitor every channel", so a
// missing env var silently opted the whole guild into a retrieval +
// generation cycle per thread. It now fails closed.
it('ignores every thread when MONITORED_CHANNEL_IDS is empty', async () => {
testConfig.monitoredChannelIds = [];

await handleThreadCreate(makeThread(), true);

expect(prisma.ticket.create).not.toHaveBeenCalled();
expect(createJob).not.toHaveBeenCalled();
});

// Announcements and release notes are threads too — they should not spend a
// full retrieval + generation cycle.
it('ignores a thread that does not read as a support request', async () => {
const thread = makeThread({
name: 'v1.10.0 released',
fetchStarterMessage: vi.fn().mockResolvedValue({
content: 'v1.10.0 is out. Release notes are in the changelog.',
author: { tag: 'Maintainer#0001', id: 'user-1', username: 'Maintainer' },
}),
});

await handleThreadCreate(thread, true);

expect(prisma.ticket.create).not.toHaveBeenCalled();
expect(createJob).not.toHaveBeenCalled();
});

it('answers an announcement-shaped thread that @-mentions the bot', async () => {
const thread = makeThread({
name: 'v1.10.0 released',
fetchStarterMessage: vi.fn().mockResolvedValue({
content: '<@test-client-id> v1.10.0 is out. Notes in the changelog.',
author: { tag: 'Maintainer#0001', id: 'user-1', username: 'Maintainer' },
}),
});

await handleThreadCreate(thread, true);

expect(prisma.ticket.create).toHaveBeenCalled();
});

it('answers a thread whose question is only in the title', async () => {
const thread = makeThread({
name: 'How do I render generative UI?',
fetchStarterMessage: vi.fn().mockResolvedValue({
content: 'Details below.',
author: { tag: 'TestUser#1234', id: 'user-456', username: 'TestUser' },
}),
});

await handleThreadCreate(thread, true);

expect(prisma.ticket.create).toHaveBeenCalled();
});

it('applies the support-request gate in shadow mode too', async () => {
vi.mocked(isShadowMode).mockReturnValue(true);
const thread = makeThread({
name: 'v1.10.0 released',
fetchStarterMessage: vi.fn().mockResolvedValue({
content: 'v1.10.0 is out. Release notes are in the changelog.',
author: { tag: 'Maintainer#0001', id: 'user-1', username: 'Maintainer' },
}),
});

await handleThreadCreate(thread, true);

expect(handleShadowThreadCreate).not.toHaveBeenCalled();
});

it('uses DiscordAdapter.parseInboundEvent to normalize the thread event', async () => {
const thread = makeThread();
await handleThreadCreate(thread, true);
Expand Down
8 changes: 8 additions & 0 deletions apps/discord-bot/src/events/message-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ export async function handleMessageCreate(message: Message): Promise<void> {

const threadId = message.channel.id;

// Discord dispatches BOTH ThreadCreate and MessageCreate for a new forum
// post, and handleThreadCreate has already ingested this exact message as
// the ticket's first message. Processing it again enqueues a second
// AI_RESPONSE job for the same ticket, so the same question is retrieved
// and answered twice. A thread's starter message shares the thread's ID —
// that identity is what makes this detectable.
if (message.id === threadId) return;

try {
// Look up the ticket associated with this thread (for shadow mode check)
const ticket = await findTicketByThreadId(threadId);
Expand Down
51 changes: 44 additions & 7 deletions apps/discord-bot/src/events/thread-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ChannelType, type ThreadChannel } from 'discord.js';
import { prisma } from '@copilotkit/outpost/db';
import { createJob } from '@copilotkit/outpost/queue';
import { PlatformDiscordAdapter, InboundHandler } from '@copilotkit/outpost/shared/platforms';
import { generateTicketId } from '@copilotkit/outpost/shared';
import { generateTicketId, isSupportRequest } from '@copilotkit/outpost/shared';
import type { CreateJobFn } from '@copilotkit/outpost/shared';
import { config } from '../config.js';
import { isShadowMode, handleShadowThreadCreate } from '../lib/shadow-mode.js';
Expand All @@ -25,20 +25,42 @@ const createJobFn: CreateJobFn = async (
);
};

/** Log the unconfigured-channel warning once, not once per thread. */
let warnedUnconfiguredChannels = false;
function warnUnconfiguredChannels(): void {
if (warnedUnconfiguredChannels) return;
warnedUnconfiguredChannels = true;
console.warn(
'[Discord Bot] MONITORED_CHANNEL_IDS is empty — ignoring all threads. ' +
'Set it to the forum channel IDs Outpost should answer in.',
);
}

/**
* A forum post's title often carries the question while the body carries the
* repro, so both are considered. The bot's own application ID doubles as its
* user ID, so an @-mention of the bot always qualifies.
*/
function shouldAnswer(threadName: string, content: string): boolean {
return isSupportRequest(`${threadName}\n${content}`, { botUserId: config.clientId });
}

export async function handleThreadCreate(thread: ThreadChannel, newlyCreated: boolean): Promise<void> {
if (!newlyCreated) return;

// Only monitor threads in configured forum channels
const parentId = thread.parentId;
if (!parentId) return;

// If monitoredChannelIds is configured, only track those channels.
// If empty, monitor all channels (useful for development).
const isMonitored =
config.monitoredChannelIds.length === 0 ||
config.monitoredChannelIds.includes(parentId);
// Fail CLOSED on an unset MONITORED_CHANNEL_IDS. Treating "empty" as
// "every channel" meant a missing env var silently opted the whole guild
// into a retrieval + generation cycle per thread.
if (config.monitoredChannelIds.length === 0) {
warnUnconfiguredChannels();
return;
}

if (!isMonitored) return;
if (!config.monitoredChannelIds.includes(parentId)) return;

// Only handle public/private threads (includes forum posts)
if (
Expand All @@ -52,6 +74,12 @@ export async function handleThreadCreate(thread: ThreadChannel, newlyCreated: bo
if (isShadowMode()) {
const starterMessage = await thread.fetchStarterMessage();
const content = starterMessage?.content ?? '';
if (!shouldAnswer(thread.name, content)) {
console.log(
`[Discord Bot] Thread ${thread.id} does not read as a support request, skipping`,
);
return;
}
const authorTag = starterMessage?.author.tag ?? 'Unknown';
const authorId = starterMessage?.author.id ?? '';
const displayId = generateTicketId();
Expand All @@ -71,6 +99,15 @@ export async function handleThreadCreate(thread: ThreadChannel, newlyCreated: bo
// Fetch the starter message (first message in the thread)
const starterMessage = await thread.fetchStarterMessage();

// Announcements and release notes are threads too — only spend a full
// retrieval + generation cycle on something that reads like a question.
if (!shouldAnswer(thread.name, starterMessage?.content ?? '')) {
console.log(
`[Discord Bot] Thread ${thread.id} does not read as a support request, skipping`,
);
return;
}

// Parse the raw event through the platform adapter
const inboundMessage = adapter.parseInboundEvent({
thread,
Expand Down
9 changes: 9 additions & 0 deletions packages/outpost/ai/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ export const config = {
/** Maximum tokens for classification */
maxClassifierTokens: 512,

/** Model used to distill a raw message body into a docs-search query (cheap, fast) */
queryDistillerModel: process.env.AI_QUERY_DISTILLER_MODEL ?? 'claude-haiku-4-5-20251001',

/** Maximum tokens for query distillation — the output is one short query */
maxQueryDistillerTokens: 128,

/** Temperature for query distillation (lower = more deterministic) */
queryDistillerTemperature: 0,

/** Model used for sentiment analysis (cheap, fast) */
sentimentModel: process.env.AI_SENTIMENT_MODEL ?? 'claude-haiku-4-5-20251001',

Expand Down
2 changes: 2 additions & 0 deletions packages/outpost/ai/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export {
AI_DISCLAIMER_REVIEWED,
ResponseFormatter,
} from './formatter.js';
export { SearchQueryBuilder, heuristicSearchQuery } from './query.js';
export type { SearchQuery } from './query.js';
export { AIPipeline, SUPPRESSED_RESPONSE_TEXT } from './pipeline.js';
export { analyzeSentiment } from './sentiment.js';
export { scoreEngagement } from './engagement.js';
Expand Down
Loading