From c1d07b09ce3481f4066ab933bb5e0892a8b22f54 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 01:10:31 +0000 Subject: [PATCH] refactor(discord): extract route resolution helpers --- .../monitor/message-handler.preflight.ts | 40 +++---- src/discord/monitor/native-command.ts | 68 ++++++----- src/discord/monitor/route-resolution.test.ts | 107 ++++++++++++++++++ src/discord/monitor/route-resolution.ts | 60 ++++++++++ 4 files changed, 221 insertions(+), 54 deletions(-) create mode 100644 src/discord/monitor/route-resolution.test.ts create mode 100644 src/discord/monitor/route-resolution.ts diff --git a/src/discord/monitor/message-handler.preflight.ts b/src/discord/monitor/message-handler.preflight.ts index d5a536bf661..ddd79e42064 100644 --- a/src/discord/monitor/message-handler.preflight.ts +++ b/src/discord/monitor/message-handler.preflight.ts @@ -29,8 +29,7 @@ import { enqueueSystemEvent } from "../../infra/system-events.js"; import { logDebug } from "../../logger.js"; import { getChildLogger } from "../../logging.js"; import { buildPairingReply } from "../../pairing/pairing-messages.js"; -import { resolveAgentRoute } from "../../routing/resolve-route.js"; -import { DEFAULT_ACCOUNT_ID, resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; import { fetchPluralKitMessageInfo } from "../pluralkit.js"; import { sendMessageDiscord } from "../send.js"; import { @@ -60,6 +59,11 @@ import { resolveDiscordMessageText, } from "./message-utils.js"; import { resolveDiscordPreflightAudioMentionContext } from "./preflight-audio.js"; +import { + buildDiscordRoutePeer, + resolveDiscordConversationRoute, + resolveDiscordEffectiveRoute, +} from "./route-resolution.js"; import { resolveDiscordSenderIdentity, resolveDiscordWebhookId } from "./sender-identity.js"; import { resolveDiscordSystemEvent } from "./system-events.js"; import { isRecentlyUnboundThreadWebhookMessage } from "./thread-bindings.js"; @@ -333,18 +337,18 @@ export async function preflightDiscordMessage( ? params.data.rawMember.roles.map((roleId: string) => String(roleId)) : []; const freshCfg = loadConfig(); - const route = resolveAgentRoute({ + const route = resolveDiscordConversationRoute({ cfg: freshCfg, - channel: "discord", accountId: params.accountId, guildId: params.data.guild_id ?? undefined, memberRoleIds, - peer: { - kind: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel", - id: isDirectMessage ? author.id : messageChannelId, - }, - // Pass parent peer for thread binding inheritance - parentPeer: earlyThreadParentId ? { kind: "channel", id: earlyThreadParentId } : undefined, + peer: buildDiscordRoutePeer({ + isDirectMessage, + isGroupDm, + directUserId: author.id, + conversationId: messageChannelId, + }), + parentConversationId: earlyThreadParentId, }); let threadBinding: SessionBindingRecord | undefined; threadBinding = @@ -381,15 +385,13 @@ export async function preflightDiscordMessage( return null; } const boundSessionKey = threadBinding?.targetSessionKey?.trim(); - const boundAgentId = boundSessionKey ? resolveAgentIdFromSessionKey(boundSessionKey) : undefined; - const effectiveRoute = boundSessionKey - ? { - ...route, - sessionKey: boundSessionKey, - agentId: boundAgentId ?? route.agentId, - matchedBy: "binding.channel" as const, - } - : (configuredRoute?.route ?? route); + const effectiveRoute = resolveDiscordEffectiveRoute({ + route, + boundSessionKey, + configuredRoute, + matchedBy: "binding.channel", + }); + const boundAgentId = boundSessionKey ? effectiveRoute.agentId : undefined; const isBoundThreadSession = Boolean(boundSessionKey && earlyThreadChannel); if ( isBoundThreadBotSystemMessage({ diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts index 71c3008f88d..987fa39cfe1 100644 --- a/src/discord/monitor/native-command.ts +++ b/src/discord/monitor/native-command.ts @@ -52,8 +52,7 @@ import { createSubsystemLogger } from "../../logging/subsystem.js"; import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; import { buildPairingReply } from "../../pairing/pairing-messages.js"; import { executePluginCommand, matchPluginCommand } from "../../plugins/commands.js"; -import { resolveAgentRoute } from "../../routing/resolve-route.js"; -import { resolveAgentIdFromSessionKey } from "../../routing/session-key.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"; @@ -86,6 +85,11 @@ import { toDiscordModelPickerMessagePayload, type DiscordModelPickerCommandContext, } from "./model-picker.js"; +import { + buildDiscordRoutePeer, + resolveDiscordConversationRoute, + resolveDiscordEffectiveRoute, +} from "./route-resolution.js"; import { resolveDiscordSenderIdentity } from "./sender-identity.js"; import type { ThreadBindingManager } from "./thread-bindings.js"; import { resolveDiscordThreadParentInfo } from "./threading.js"; @@ -448,36 +452,32 @@ async function resolveDiscordModelPickerRoute(params: { threadParentId = parentInfo.id; } - const route = resolveAgentRoute({ + const route = resolveDiscordConversationRoute({ cfg, - channel: "discord", accountId, guildId: interaction.guild?.id ?? undefined, memberRoleIds, - peer: { - kind: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel", - id: isDirectMessage ? (interaction.user?.id ?? rawChannelId) : rawChannelId, - }, - parentPeer: threadParentId ? { kind: "channel", id: threadParentId } : undefined, + peer: buildDiscordRoutePeer({ + isDirectMessage, + isGroupDm, + directUserId: interaction.user?.id ?? rawChannelId, + conversationId: rawChannelId, + }), + parentConversationId: threadParentId, }); const threadBinding = isThreadChannel ? params.threadBindings.getByThreadId(rawChannelId) : undefined; - const boundSessionKey = threadBinding?.targetSessionKey?.trim(); - const boundAgentId = boundSessionKey ? resolveAgentIdFromSessionKey(boundSessionKey) : undefined; - return boundSessionKey - ? { - ...route, - sessionKey: boundSessionKey, - agentId: boundAgentId ?? route.agentId, - } - : route; + return resolveDiscordEffectiveRoute({ + route, + boundSessionKey: threadBinding?.targetSessionKey, + }); } function resolveDiscordModelPickerCurrentModel(params: { cfg: ReturnType; - route: ReturnType; + route: ResolvedAgentRoute; data: Awaited>; }): string { const fallback = buildDiscordModelPickerCurrentModel( @@ -1606,17 +1606,18 @@ async function dispatchDiscordCommandInteraction(params: { const isGuild = Boolean(interaction.guild); const channelId = rawChannelId || "unknown"; const interactionId = interaction.rawData.id; - const route = resolveAgentRoute({ + const route = resolveDiscordConversationRoute({ cfg, - channel: "discord", accountId, guildId: interaction.guild?.id ?? undefined, memberRoleIds, - peer: { - kind: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel", - id: isDirectMessage ? user.id : channelId, - }, - parentPeer: threadParentId ? { kind: "channel", id: threadParentId } : undefined, + peer: buildDiscordRoutePeer({ + isDirectMessage, + isGroupDm, + directUserId: user.id, + conversationId: channelId, + }), + parentConversationId: threadParentId, }); const threadBinding = isThreadChannel ? threadBindings.getByThreadId(rawChannelId) : undefined; const configuredRoute = @@ -1646,15 +1647,12 @@ async function dispatchDiscordCommandInteraction(params: { } const configuredBoundSessionKey = configuredRoute?.boundSessionKey?.trim() || undefined; const boundSessionKey = threadBinding?.targetSessionKey?.trim() || configuredBoundSessionKey; - const boundAgentId = boundSessionKey ? resolveAgentIdFromSessionKey(boundSessionKey) : undefined; - const effectiveRoute = boundSessionKey - ? { - ...route, - sessionKey: boundSessionKey, - agentId: boundAgentId ?? route.agentId, - ...(configuredBinding ? { matchedBy: "binding.channel" as const } : {}), - } - : (configuredRoute?.route ?? route); + const effectiveRoute = resolveDiscordEffectiveRoute({ + route, + boundSessionKey, + configuredRoute, + matchedBy: configuredBinding ? "binding.channel" : undefined, + }); const conversationLabel = isDirectMessage ? (user.globalName ?? user.username) : channelId; const ownerAllowFrom = resolveDiscordOwnerAllowFrom({ channelConfig, diff --git a/src/discord/monitor/route-resolution.test.ts b/src/discord/monitor/route-resolution.test.ts new file mode 100644 index 00000000000..afddde3fd2d --- /dev/null +++ b/src/discord/monitor/route-resolution.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import type { ResolvedAgentRoute } from "../../routing/resolve-route.js"; +import { + buildDiscordRoutePeer, + resolveDiscordConversationRoute, + resolveDiscordEffectiveRoute, +} from "./route-resolution.js"; + +describe("discord route resolution helpers", () => { + it("builds a direct peer from DM metadata", () => { + expect( + buildDiscordRoutePeer({ + isDirectMessage: true, + isGroupDm: false, + directUserId: "user-1", + conversationId: "channel-1", + }), + ).toEqual({ + kind: "direct", + id: "user-1", + }); + }); + + it("resolves bound session keys on top of the routed session", () => { + const route: ResolvedAgentRoute = { + agentId: "main", + channel: "discord", + accountId: "default", + sessionKey: "agent:main:discord:channel:c1", + mainSessionKey: "agent:main:main", + matchedBy: "default", + }; + + expect( + resolveDiscordEffectiveRoute({ + route, + boundSessionKey: "agent:worker:discord:channel:c1", + matchedBy: "binding.channel", + }), + ).toEqual({ + ...route, + agentId: "worker", + sessionKey: "agent:worker:discord:channel:c1", + matchedBy: "binding.channel", + }); + }); + + it("falls back to configured route when no bound session exists", () => { + const route: ResolvedAgentRoute = { + agentId: "main", + channel: "discord", + accountId: "default", + sessionKey: "agent:main:discord:channel:c1", + mainSessionKey: "agent:main:main", + matchedBy: "default", + }; + const configuredRoute = { + route: { + ...route, + agentId: "worker", + sessionKey: "agent:worker:discord:channel:c1", + mainSessionKey: "agent:worker:main", + matchedBy: "binding.peer" as const, + }, + }; + + expect( + resolveDiscordEffectiveRoute({ + route, + configuredRoute, + }), + ).toEqual(configuredRoute.route); + }); + + it("resolves the same route shape as the inline Discord route inputs", () => { + const cfg: OpenClawConfig = { + agents: { + list: [{ id: "worker" }], + }, + bindings: [ + { + agentId: "worker", + match: { + channel: "discord", + accountId: "default", + peer: { kind: "channel", id: "c1" }, + }, + }, + ], + }; + + expect( + resolveDiscordConversationRoute({ + cfg, + accountId: "default", + guildId: "g1", + memberRoleIds: [], + peer: { kind: "channel", id: "c1" }, + }), + ).toMatchObject({ + agentId: "worker", + sessionKey: "agent:worker:discord:channel:c1", + matchedBy: "binding.peer", + }); + }); +}); diff --git a/src/discord/monitor/route-resolution.ts b/src/discord/monitor/route-resolution.ts new file mode 100644 index 00000000000..99ac767f7ff --- /dev/null +++ b/src/discord/monitor/route-resolution.ts @@ -0,0 +1,60 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import { + resolveAgentRoute, + type ResolvedAgentRoute, + type RoutePeer, +} from "../../routing/resolve-route.js"; +import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; + +export function buildDiscordRoutePeer(params: { + isDirectMessage: boolean; + isGroupDm: boolean; + directUserId?: string | null; + conversationId: string; +}): RoutePeer { + return { + kind: params.isDirectMessage ? "direct" : params.isGroupDm ? "group" : "channel", + id: params.isDirectMessage + ? params.directUserId?.trim() || params.conversationId + : params.conversationId, + }; +} + +export function resolveDiscordConversationRoute(params: { + cfg: OpenClawConfig; + accountId?: string | null; + guildId?: string | null; + memberRoleIds?: string[]; + peer: RoutePeer; + parentConversationId?: string | null; +}): ResolvedAgentRoute { + return resolveAgentRoute({ + cfg: params.cfg, + channel: "discord", + accountId: params.accountId, + guildId: params.guildId ?? undefined, + memberRoleIds: params.memberRoleIds, + peer: params.peer, + parentPeer: params.parentConversationId + ? { kind: "channel", id: params.parentConversationId } + : undefined, + }); +} + +export function resolveDiscordEffectiveRoute(params: { + route: ResolvedAgentRoute; + boundSessionKey?: string | null; + configuredRoute?: { route: ResolvedAgentRoute } | null; + matchedBy?: ResolvedAgentRoute["matchedBy"]; +}): ResolvedAgentRoute { + const boundSessionKey = params.boundSessionKey?.trim(); + if (!boundSessionKey) { + return params.configuredRoute?.route ?? params.route; + } + return { + ...params.route, + sessionKey: boundSessionKey, + agentId: resolveAgentIdFromSessionKey(boundSessionKey), + ...(params.matchedBy ? { matchedBy: params.matchedBy } : {}), + }; +}