mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
refactor(discord): extract native command context builder
This commit is contained in:
95
src/discord/monitor/native-command-context.test.ts
Normal file
95
src/discord/monitor/native-command-context.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
124
src/discord/monitor/native-command-context.ts
Normal file
124
src/discord/monitor/native-command-context.ts
Normal file
@@ -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:<user> 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,
|
||||
});
|
||||
}
|
||||
@@ -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:<user> 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({
|
||||
|
||||
Reference in New Issue
Block a user