diff --git a/src/discord/monitor/agent-components.ts b/src/discord/monitor/agent-components.ts index f0aa62647..deeb9b352 100644 --- a/src/discord/monitor/agent-components.ts +++ b/src/discord/monitor/agent-components.ts @@ -63,9 +63,12 @@ import { resolveDiscordGuildEntry, resolveDiscordMemberAccessState, resolveDiscordOwnerAccess, - resolveDiscordOwnerAllowFrom, } from "./allow-list.js"; import { formatDiscordUserTag } from "./format.js"; +import { + buildDiscordInboundAccessContext, + buildDiscordGroupSystemPrompt, +} from "./inbound-context.js"; import { buildDirectLabel, buildGuildLabel } from "./reply-context.js"; import { deliverDiscordReply } from "./reply-delivery.js"; import { sendTyping } from "./typing.js"; @@ -865,13 +868,14 @@ async function dispatchDiscordComponentEvent(params: { scope: channelCtx.isThread ? "thread" : "channel", }); const allowNameMatching = isDangerousNameMatchingEnabled(ctx.discordConfig); - const groupSystemPrompt = channelConfig?.systemPrompt?.trim() || undefined; - const ownerAllowFrom = resolveDiscordOwnerAllowFrom({ + const { ownerAllowFrom } = buildDiscordInboundAccessContext({ channelConfig, guildInfo, sender: { id: interactionCtx.user.id, name: interactionCtx.user.username, tag: senderTag }, allowNameMatching, + isGuild: !interactionCtx.isDirectMessage, }); + const groupSystemPrompt = buildDiscordGroupSystemPrompt(channelConfig); const pinnedMainDmOwner = interactionCtx.isDirectMessage ? resolvePinnedMainDmOwnerFromAllowlist({ dmScope: ctx.cfg.session?.dmScope, diff --git a/src/discord/monitor/inbound-context.test.ts b/src/discord/monitor/inbound-context.test.ts new file mode 100644 index 000000000..39e68bf87 --- /dev/null +++ b/src/discord/monitor/inbound-context.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { + buildDiscordGroupSystemPrompt, + buildDiscordInboundAccessContext, + buildDiscordUntrustedContext, +} from "./inbound-context.js"; + +describe("Discord inbound context helpers", () => { + it("builds guild access context from channel config and topic", () => { + expect( + buildDiscordInboundAccessContext({ + channelConfig: { + allowed: true, + users: ["discord:user-1"], + systemPrompt: "Use the runbook.", + }, + guildInfo: { id: "guild-1" }, + sender: { + id: "user-1", + name: "tester", + tag: "tester#0001", + }, + isGuild: true, + channelTopic: "Production alerts only", + }), + ).toEqual({ + groupSystemPrompt: "Use the runbook.", + untrustedContext: [expect.stringContaining("Production alerts only")], + ownerAllowFrom: ["user-1"], + }); + }); + + it("omits guild-only metadata for direct messages", () => { + expect( + buildDiscordInboundAccessContext({ + sender: { + id: "user-1", + }, + isGuild: false, + channelTopic: "ignored", + }), + ).toEqual({ + groupSystemPrompt: undefined, + untrustedContext: undefined, + ownerAllowFrom: undefined, + }); + }); + + it("keeps direct helper behavior consistent", () => { + expect(buildDiscordGroupSystemPrompt({ allowed: true, systemPrompt: " hi " })).toBe("hi"); + expect(buildDiscordUntrustedContext({ isGuild: true, channelTopic: "topic" })).toEqual([ + expect.stringContaining("topic"), + ]); + }); +}); diff --git a/src/discord/monitor/inbound-context.ts b/src/discord/monitor/inbound-context.ts new file mode 100644 index 000000000..516746583 --- /dev/null +++ b/src/discord/monitor/inbound-context.ts @@ -0,0 +1,59 @@ +import { buildUntrustedChannelMetadata } from "../../security/channel-metadata.js"; +import { + resolveDiscordOwnerAllowFrom, + type DiscordChannelConfigResolved, + type DiscordGuildEntryResolved, +} from "./allow-list.js"; + +export function buildDiscordGroupSystemPrompt( + 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; +} + +export function buildDiscordUntrustedContext(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 buildDiscordInboundAccessContext(params: { + channelConfig?: DiscordChannelConfigResolved | null; + guildInfo?: DiscordGuildEntryResolved | null; + sender: { + id: string; + name?: string; + tag?: string; + }; + allowNameMatching?: boolean; + isGuild: boolean; + channelTopic?: string; +}) { + return { + groupSystemPrompt: params.isGuild + ? buildDiscordGroupSystemPrompt(params.channelConfig) + : undefined, + untrustedContext: buildDiscordUntrustedContext({ + isGuild: params.isGuild, + channelTopic: params.channelTopic, + }), + ownerAllowFrom: resolveDiscordOwnerAllowFrom({ + channelConfig: params.channelConfig, + guildInfo: params.guildInfo, + sender: params.sender, + allowNameMatching: params.allowNameMatching, + }), + }; +} diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 1fb0e8590..85bbccd59 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -30,7 +30,6 @@ import { convertMarkdownTables } from "../../markdown/tables.js"; import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; import { buildAgentSessionKey } from "../../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../../routing/session-key.js"; -import { buildUntrustedChannelMetadata } from "../../security/channel-metadata.js"; import { stripReasoningTagsFromText } from "../../shared/text/reasoning-tags.js"; import { truncateUtf16Safe } from "../../utils.js"; import { chunkDiscordTextWithMode } from "../chunk.js"; @@ -38,8 +37,9 @@ import { resolveDiscordDraftStreamingChunking } from "../draft-chunking.js"; import { createDiscordDraftStream } from "../draft-stream.js"; import { reactMessageDiscord, removeReactionDiscord } from "../send.js"; import { editMessageDiscord } from "../send.messages.js"; -import { normalizeDiscordSlug, resolveDiscordOwnerAllowFrom } from "./allow-list.js"; +import { normalizeDiscordSlug } from "./allow-list.js"; import { resolveTimestampMs } from "./format.js"; +import { buildDiscordInboundAccessContext } from "./inbound-context.js"; import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js"; import { buildDiscordMediaPayload, @@ -212,13 +212,6 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) const forumContextLine = isForumStarter ? `[Forum parent: #${forumParentSlug}]` : null; const groupChannel = isGuildMessage && displayChannelSlug ? `#${displayChannelSlug}` : undefined; const groupSubject = isDirectMessage ? undefined : groupChannel; - const untrustedChannelMetadata = isGuildMessage - ? buildUntrustedChannelMetadata({ - source: "discord", - label: "Discord channel topic", - entries: [channelInfo?.topic], - }) - : undefined; const senderName = sender.isPluralKit ? (sender.name ?? author.username) : (data.member?.nickname ?? author.globalName ?? author.username); @@ -226,16 +219,13 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) ? (sender.tag ?? sender.name ?? author.username) : author.username; const senderTag = sender.tag; - const systemPromptParts = [channelConfig?.systemPrompt?.trim() || null].filter( - (entry): entry is string => Boolean(entry), - ); - const groupSystemPrompt = - systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined; - const ownerAllowFrom = resolveDiscordOwnerAllowFrom({ + const { groupSystemPrompt, ownerAllowFrom, untrustedContext } = buildDiscordInboundAccessContext({ channelConfig, guildInfo, sender: { id: sender.id, name: sender.name, tag: sender.tag }, allowNameMatching: isDangerousNameMatchingEnabled(discordConfig), + isGuild: isGuildMessage, + channelTopic: channelInfo?.topic, }); const storePath = resolveStorePath(cfg.session?.store, { agentId: route.agentId, @@ -374,7 +364,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) SenderTag: senderTag, GroupSubject: groupSubject, GroupChannel: groupChannel, - UntrustedContext: untrustedChannelMetadata ? [untrustedChannelMetadata] : undefined, + UntrustedContext: untrustedContext, GroupSystemPrompt: isGuildMessage ? groupSystemPrompt : undefined, GroupSpace: isGuildMessage ? (guildInfo?.id ?? guildSlug) || undefined : undefined, OwnerAllowFrom: ownerAllowFrom, diff --git a/src/discord/monitor/native-command-context.ts b/src/discord/monitor/native-command-context.ts index 938e7b3e1..1d7989065 100644 --- a/src/discord/monitor/native-command-context.ts +++ b/src/discord/monitor/native-command-context.ts @@ -1,11 +1,7 @@ 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"; +import { type DiscordChannelConfigResolved, type DiscordGuildEntryResolved } from "./allow-list.js"; +import { buildDiscordInboundAccessContext } from "./inbound-context.js"; export type BuildDiscordNativeCommandContextParams = { prompt: string; @@ -39,39 +35,17 @@ export type BuildDiscordNativeCommandContextParams = { 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({ + const { groupSystemPrompt, ownerAllowFrom, untrustedContext } = buildDiscordInboundAccessContext({ channelConfig: params.channelConfig, guildInfo: params.guildInfo, sender: params.sender, allowNameMatching: params.allowNameMatching, + isGuild: params.isGuild, + channelTopic: params.channelTopic, }); return finalizeInboundContext({ @@ -92,13 +66,8 @@ export function buildDiscordNativeCommandContext(params: BuildDiscordNativeComma 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, - }), + GroupSystemPrompt: groupSystemPrompt, + UntrustedContext: untrustedContext, OwnerAllowFrom: ownerAllowFrom, SenderName: params.user.globalName ?? params.user.username, SenderId: params.user.id,