mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 12:47:39 +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,
|
resolveCommandArgMenu,
|
||||||
serializeCommandArgs,
|
serializeCommandArgs,
|
||||||
} from "../../auto-reply/commands-registry.js";
|
} 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 { resolveStoredModelOverride } from "../../auto-reply/reply/model-selection.js";
|
||||||
import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
|
import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
|
||||||
import type { ReplyPayload } from "../../auto-reply/types.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 { buildPairingReply } from "../../pairing/pairing-messages.js";
|
||||||
import { executePluginCommand, matchPluginCommand } from "../../plugins/commands.js";
|
import { executePluginCommand, matchPluginCommand } from "../../plugins/commands.js";
|
||||||
import type { ResolvedAgentRoute } from "../../routing/resolve-route.js";
|
import type { ResolvedAgentRoute } from "../../routing/resolve-route.js";
|
||||||
import { buildUntrustedChannelMetadata } from "../../security/channel-metadata.js";
|
|
||||||
import { chunkItems } from "../../utils/chunk-items.js";
|
import { chunkItems } from "../../utils/chunk-items.js";
|
||||||
import { withTimeout } from "../../utils/with-timeout.js";
|
import { withTimeout } from "../../utils/with-timeout.js";
|
||||||
import { loadWebMedia } from "../../web/media.js";
|
import { loadWebMedia } from "../../web/media.js";
|
||||||
@@ -65,7 +63,6 @@ import {
|
|||||||
resolveDiscordGuildEntry,
|
resolveDiscordGuildEntry,
|
||||||
resolveDiscordMemberAccessState,
|
resolveDiscordMemberAccessState,
|
||||||
resolveDiscordOwnerAccess,
|
resolveDiscordOwnerAccess,
|
||||||
resolveDiscordOwnerAllowFrom,
|
|
||||||
} from "./allow-list.js";
|
} from "./allow-list.js";
|
||||||
import { resolveDiscordDmCommandAccess } from "./dm-command-auth.js";
|
import { resolveDiscordDmCommandAccess } from "./dm-command-auth.js";
|
||||||
import { handleDiscordDmCommandDecision } from "./dm-command-decision.js";
|
import { handleDiscordDmCommandDecision } from "./dm-command-decision.js";
|
||||||
@@ -85,6 +82,7 @@ import {
|
|||||||
toDiscordModelPickerMessagePayload,
|
toDiscordModelPickerMessagePayload,
|
||||||
type DiscordModelPickerCommandContext,
|
type DiscordModelPickerCommandContext,
|
||||||
} from "./model-picker.js";
|
} from "./model-picker.js";
|
||||||
|
import { buildDiscordNativeCommandContext } from "./native-command-context.js";
|
||||||
import {
|
import {
|
||||||
buildDiscordRoutePeer,
|
buildDiscordRoutePeer,
|
||||||
resolveDiscordConversationRoute,
|
resolveDiscordConversationRoute,
|
||||||
@@ -1653,70 +1651,31 @@ async function dispatchDiscordCommandInteraction(params: {
|
|||||||
configuredRoute,
|
configuredRoute,
|
||||||
matchedBy: configuredBinding ? "binding.channel" : undefined,
|
matchedBy: configuredBinding ? "binding.channel" : undefined,
|
||||||
});
|
});
|
||||||
const conversationLabel = isDirectMessage ? (user.globalName ?? user.username) : channelId;
|
const ctxPayload = buildDiscordNativeCommandContext({
|
||||||
const ownerAllowFrom = resolveDiscordOwnerAllowFrom({
|
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,
|
channelConfig,
|
||||||
guildInfo,
|
guildInfo,
|
||||||
sender: { id: sender.id, name: sender.name, tag: sender.tag },
|
|
||||||
allowNameMatching,
|
allowNameMatching,
|
||||||
});
|
commandAuthorized,
|
||||||
const ctxPayload = finalizeInboundContext({
|
isDirectMessage,
|
||||||
Body: prompt,
|
isGroupDm,
|
||||||
BodyForAgent: prompt,
|
isGuild,
|
||||||
RawBody: prompt,
|
isThreadChannel,
|
||||||
CommandBody: prompt,
|
user: {
|
||||||
CommandArgs: commandArgs,
|
id: user.id,
|
||||||
From: isDirectMessage
|
username: user.username,
|
||||||
? `discord:${user.id}`
|
globalName: user.globalName,
|
||||||
: isGroupDm
|
},
|
||||||
? `discord:group:${channelId}`
|
sender: { id: sender.id, name: sender.name, tag: sender.tag },
|
||||||
: `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,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||||
|
|||||||
Reference in New Issue
Block a user