diff --git a/src/discord/monitor/native-command-context.test.ts b/src/discord/monitor/native-command-context.test.ts new file mode 100644 index 00000000000..e20c9d9c778 --- /dev/null +++ b/src/discord/monitor/native-command-context.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { buildDiscordNativeCommandContext } from "./native-command-context.js"; + +describe("buildDiscordNativeCommandContext", () => { + it("builds direct-message slash command context", () => { + const ctx = buildDiscordNativeCommandContext({ + prompt: "/status", + commandArgs: {}, + sessionKey: "agent:codex:discord:slash:user-1", + commandTargetSessionKey: "agent:codex:discord:direct:user-1", + accountId: "default", + interactionId: "interaction-1", + channelId: "dm-1", + commandAuthorized: true, + isDirectMessage: true, + isGroupDm: false, + isGuild: false, + isThreadChannel: false, + user: { + id: "user-1", + username: "tester", + globalName: "Tester", + }, + sender: { + id: "user-1", + tag: "tester#0001", + }, + timestampMs: 123, + }); + + expect(ctx.From).toBe("discord:user-1"); + expect(ctx.To).toBe("slash:user-1"); + expect(ctx.ChatType).toBe("direct"); + expect(ctx.ConversationLabel).toBe("Tester"); + expect(ctx.SessionKey).toBe("agent:codex:discord:slash:user-1"); + expect(ctx.CommandTargetSessionKey).toBe("agent:codex:discord:direct:user-1"); + expect(ctx.OriginatingTo).toBe("user:user-1"); + expect(ctx.UntrustedContext).toBeUndefined(); + expect(ctx.GroupSystemPrompt).toBeUndefined(); + expect(ctx.Timestamp).toBe(123); + }); + + it("builds guild slash command context with owner allowlist and channel metadata", () => { + const ctx = buildDiscordNativeCommandContext({ + prompt: "/status", + commandArgs: { model: "gpt-5.2" }, + sessionKey: "agent:codex:discord:slash:user-1", + commandTargetSessionKey: "agent:codex:discord:channel:chan-1", + accountId: "default", + interactionId: "interaction-1", + channelId: "chan-1", + threadParentId: "parent-1", + guildName: "Ops", + channelTopic: "Production alerts only", + channelConfig: { + allowed: true, + users: ["discord:user-1"], + systemPrompt: "Use the runbook.", + }, + guildInfo: { + id: "guild-1", + }, + allowNameMatching: false, + commandAuthorized: true, + isDirectMessage: false, + isGroupDm: false, + isGuild: true, + isThreadChannel: true, + user: { + id: "user-1", + username: "tester", + }, + sender: { + id: "user-1", + name: "tester", + tag: "tester#0001", + }, + timestampMs: 456, + }); + + expect(ctx.From).toBe("discord:channel:chan-1"); + expect(ctx.ChatType).toBe("channel"); + expect(ctx.ConversationLabel).toBe("chan-1"); + expect(ctx.GroupSubject).toBe("Ops"); + expect(ctx.GroupSystemPrompt).toBe("Use the runbook."); + expect(ctx.OwnerAllowFrom).toEqual(["user-1"]); + expect(ctx.MessageThreadId).toBe("chan-1"); + expect(ctx.ThreadParentId).toBe("parent-1"); + expect(ctx.OriginatingTo).toBe("channel:chan-1"); + expect(ctx.UntrustedContext).toEqual([ + expect.stringContaining("Discord channel topic:\nProduction alerts only"), + ]); + expect(ctx.Timestamp).toBe(456); + }); +}); diff --git a/src/discord/monitor/native-command-context.ts b/src/discord/monitor/native-command-context.ts new file mode 100644 index 00000000000..938e7b3e1c4 --- /dev/null +++ b/src/discord/monitor/native-command-context.ts @@ -0,0 +1,124 @@ +import type { CommandArgs } from "../../auto-reply/commands-registry.js"; +import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; +import { buildUntrustedChannelMetadata } from "../../security/channel-metadata.js"; +import { + resolveDiscordOwnerAllowFrom, + type DiscordChannelConfigResolved, + type DiscordGuildEntryResolved, +} from "./allow-list.js"; + +export type BuildDiscordNativeCommandContextParams = { + prompt: string; + commandArgs: CommandArgs; + sessionKey: string; + commandTargetSessionKey: string; + accountId?: string | null; + interactionId: string; + channelId: string; + threadParentId?: string; + guildName?: string; + channelTopic?: string; + channelConfig?: DiscordChannelConfigResolved | null; + guildInfo?: DiscordGuildEntryResolved | null; + allowNameMatching?: boolean; + commandAuthorized: boolean; + isDirectMessage: boolean; + isGroupDm: boolean; + isGuild: boolean; + isThreadChannel: boolean; + user: { + id: string; + username: string; + globalName?: string | null; + }; + sender: { + id: string; + name?: string; + tag?: string; + }; + timestampMs?: number; +}; + +function buildDiscordNativeCommandSystemPrompt( + channelConfig?: DiscordChannelConfigResolved | null, +): string | undefined { + const systemPromptParts = [channelConfig?.systemPrompt?.trim() || null].filter( + (entry): entry is string => Boolean(entry), + ); + return systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined; +} + +function buildDiscordNativeCommandUntrustedContext(params: { + isGuild: boolean; + channelTopic?: string; +}): string[] | undefined { + if (!params.isGuild) { + return undefined; + } + const untrustedChannelMetadata = buildUntrustedChannelMetadata({ + source: "discord", + label: "Discord channel topic", + entries: [params.channelTopic], + }); + return untrustedChannelMetadata ? [untrustedChannelMetadata] : undefined; +} + +export function buildDiscordNativeCommandContext(params: BuildDiscordNativeCommandContextParams) { + const conversationLabel = params.isDirectMessage + ? (params.user.globalName ?? params.user.username) + : params.channelId; + const ownerAllowFrom = resolveDiscordOwnerAllowFrom({ + channelConfig: params.channelConfig, + guildInfo: params.guildInfo, + sender: params.sender, + allowNameMatching: params.allowNameMatching, + }); + + return finalizeInboundContext({ + Body: params.prompt, + BodyForAgent: params.prompt, + RawBody: params.prompt, + CommandBody: params.prompt, + CommandArgs: params.commandArgs, + From: params.isDirectMessage + ? `discord:${params.user.id}` + : params.isGroupDm + ? `discord:group:${params.channelId}` + : `discord:channel:${params.channelId}`, + To: `slash:${params.user.id}`, + SessionKey: params.sessionKey, + CommandTargetSessionKey: params.commandTargetSessionKey, + AccountId: params.accountId ?? undefined, + ChatType: params.isDirectMessage ? "direct" : params.isGroupDm ? "group" : "channel", + ConversationLabel: conversationLabel, + GroupSubject: params.isGuild ? params.guildName : undefined, + GroupSystemPrompt: params.isGuild + ? buildDiscordNativeCommandSystemPrompt(params.channelConfig) + : undefined, + UntrustedContext: buildDiscordNativeCommandUntrustedContext({ + isGuild: params.isGuild, + channelTopic: params.channelTopic, + }), + OwnerAllowFrom: ownerAllowFrom, + SenderName: params.user.globalName ?? params.user.username, + SenderId: params.user.id, + SenderUsername: params.user.username, + SenderTag: params.sender.tag, + Provider: "discord" as const, + Surface: "discord" as const, + WasMentioned: true, + MessageSid: params.interactionId, + MessageThreadId: params.isThreadChannel ? params.channelId : undefined, + Timestamp: params.timestampMs ?? Date.now(), + CommandAuthorized: params.commandAuthorized, + CommandSource: "native" as const, + // Native slash contexts use To=slash: for interaction routing. + // For follow-up delivery (for example subagent completion announces), + // preserve the real Discord target separately. + OriginatingChannel: "discord" as const, + OriginatingTo: params.isDirectMessage + ? `user:${params.user.id}` + : `channel:${params.channelId}`, + ThreadParentId: params.isThreadChannel ? params.threadParentId : undefined, + }); +} diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts index 987fa39cfe1..3ec8bfa03fd 100644 --- a/src/discord/monitor/native-command.ts +++ b/src/discord/monitor/native-command.ts @@ -37,7 +37,6 @@ import { resolveCommandArgMenu, serializeCommandArgs, } from "../../auto-reply/commands-registry.js"; -import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; import { resolveStoredModelOverride } from "../../auto-reply/reply/model-selection.js"; import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; @@ -53,7 +52,6 @@ import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; import { buildPairingReply } from "../../pairing/pairing-messages.js"; import { executePluginCommand, matchPluginCommand } from "../../plugins/commands.js"; import type { ResolvedAgentRoute } from "../../routing/resolve-route.js"; -import { buildUntrustedChannelMetadata } from "../../security/channel-metadata.js"; import { chunkItems } from "../../utils/chunk-items.js"; import { withTimeout } from "../../utils/with-timeout.js"; import { loadWebMedia } from "../../web/media.js"; @@ -65,7 +63,6 @@ import { resolveDiscordGuildEntry, resolveDiscordMemberAccessState, resolveDiscordOwnerAccess, - resolveDiscordOwnerAllowFrom, } from "./allow-list.js"; import { resolveDiscordDmCommandAccess } from "./dm-command-auth.js"; import { handleDiscordDmCommandDecision } from "./dm-command-decision.js"; @@ -85,6 +82,7 @@ import { toDiscordModelPickerMessagePayload, type DiscordModelPickerCommandContext, } from "./model-picker.js"; +import { buildDiscordNativeCommandContext } from "./native-command-context.js"; import { buildDiscordRoutePeer, resolveDiscordConversationRoute, @@ -1653,70 +1651,31 @@ async function dispatchDiscordCommandInteraction(params: { configuredRoute, matchedBy: configuredBinding ? "binding.channel" : undefined, }); - const conversationLabel = isDirectMessage ? (user.globalName ?? user.username) : channelId; - const ownerAllowFrom = resolveDiscordOwnerAllowFrom({ + const ctxPayload = buildDiscordNativeCommandContext({ + prompt, + commandArgs, + sessionKey: boundSessionKey ?? `agent:${effectiveRoute.agentId}:${sessionPrefix}:${user.id}`, + commandTargetSessionKey: boundSessionKey ?? effectiveRoute.sessionKey, + accountId: effectiveRoute.accountId, + interactionId, + channelId, + threadParentId, + guildName: interaction.guild?.name, + channelTopic: channel && "topic" in channel ? (channel.topic ?? undefined) : undefined, channelConfig, guildInfo, - sender: { id: sender.id, name: sender.name, tag: sender.tag }, allowNameMatching, - }); - const ctxPayload = finalizeInboundContext({ - Body: prompt, - BodyForAgent: prompt, - RawBody: prompt, - CommandBody: prompt, - CommandArgs: commandArgs, - From: isDirectMessage - ? `discord:${user.id}` - : isGroupDm - ? `discord:group:${channelId}` - : `discord:channel:${channelId}`, - To: `slash:${user.id}`, - SessionKey: boundSessionKey ?? `agent:${effectiveRoute.agentId}:${sessionPrefix}:${user.id}`, - CommandTargetSessionKey: boundSessionKey ?? effectiveRoute.sessionKey, - AccountId: effectiveRoute.accountId, - ChatType: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel", - ConversationLabel: conversationLabel, - GroupSubject: isGuild ? interaction.guild?.name : undefined, - GroupSystemPrompt: isGuild - ? (() => { - const systemPromptParts = [channelConfig?.systemPrompt?.trim() || null].filter( - (entry): entry is string => Boolean(entry), - ); - return systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined; - })() - : undefined, - UntrustedContext: isGuild - ? (() => { - const channelTopic = - channel && "topic" in channel ? (channel.topic ?? undefined) : undefined; - const untrustedChannelMetadata = buildUntrustedChannelMetadata({ - source: "discord", - label: "Discord channel topic", - entries: [channelTopic], - }); - return untrustedChannelMetadata ? [untrustedChannelMetadata] : undefined; - })() - : undefined, - OwnerAllowFrom: ownerAllowFrom, - SenderName: user.globalName ?? user.username, - SenderId: user.id, - SenderUsername: user.username, - SenderTag: sender.tag, - Provider: "discord" as const, - Surface: "discord" as const, - WasMentioned: true, - MessageSid: interactionId, - MessageThreadId: isThreadChannel ? channelId : undefined, - Timestamp: Date.now(), - CommandAuthorized: commandAuthorized, - CommandSource: "native" as const, - // Native slash contexts use To=slash: for interaction routing. - // For follow-up delivery (for example subagent completion announces), - // preserve the real Discord target separately. - OriginatingChannel: "discord" as const, - OriginatingTo: isDirectMessage ? `user:${user.id}` : `channel:${channelId}`, - ThreadParentId: isThreadChannel ? threadParentId : undefined, + commandAuthorized, + isDirectMessage, + isGroupDm, + isGuild, + isThreadChannel, + user: { + id: user.id, + username: user.username, + globalName: user.globalName, + }, + sender: { id: sender.id, name: sender.name, tag: sender.tag }, }); const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({