refactor(discord): extract route resolution helpers

This commit is contained in:
Peter Steinberger
2026-03-08 01:10:31 +00:00
parent 269cc22b61
commit c1d07b09ce
4 changed files with 221 additions and 54 deletions

View File

@@ -29,8 +29,7 @@ import { enqueueSystemEvent } from "../../infra/system-events.js";
import { logDebug } from "../../logger.js"; import { logDebug } from "../../logger.js";
import { getChildLogger } from "../../logging.js"; import { getChildLogger } from "../../logging.js";
import { buildPairingReply } from "../../pairing/pairing-messages.js"; import { buildPairingReply } from "../../pairing/pairing-messages.js";
import { resolveAgentRoute } from "../../routing/resolve-route.js"; import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
import { DEFAULT_ACCOUNT_ID, resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { fetchPluralKitMessageInfo } from "../pluralkit.js"; import { fetchPluralKitMessageInfo } from "../pluralkit.js";
import { sendMessageDiscord } from "../send.js"; import { sendMessageDiscord } from "../send.js";
import { import {
@@ -60,6 +59,11 @@ import {
resolveDiscordMessageText, resolveDiscordMessageText,
} from "./message-utils.js"; } from "./message-utils.js";
import { resolveDiscordPreflightAudioMentionContext } from "./preflight-audio.js"; import { resolveDiscordPreflightAudioMentionContext } from "./preflight-audio.js";
import {
buildDiscordRoutePeer,
resolveDiscordConversationRoute,
resolveDiscordEffectiveRoute,
} from "./route-resolution.js";
import { resolveDiscordSenderIdentity, resolveDiscordWebhookId } from "./sender-identity.js"; import { resolveDiscordSenderIdentity, resolveDiscordWebhookId } from "./sender-identity.js";
import { resolveDiscordSystemEvent } from "./system-events.js"; import { resolveDiscordSystemEvent } from "./system-events.js";
import { isRecentlyUnboundThreadWebhookMessage } from "./thread-bindings.js"; import { isRecentlyUnboundThreadWebhookMessage } from "./thread-bindings.js";
@@ -333,18 +337,18 @@ export async function preflightDiscordMessage(
? params.data.rawMember.roles.map((roleId: string) => String(roleId)) ? params.data.rawMember.roles.map((roleId: string) => String(roleId))
: []; : [];
const freshCfg = loadConfig(); const freshCfg = loadConfig();
const route = resolveAgentRoute({ const route = resolveDiscordConversationRoute({
cfg: freshCfg, cfg: freshCfg,
channel: "discord",
accountId: params.accountId, accountId: params.accountId,
guildId: params.data.guild_id ?? undefined, guildId: params.data.guild_id ?? undefined,
memberRoleIds, memberRoleIds,
peer: { peer: buildDiscordRoutePeer({
kind: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel", isDirectMessage,
id: isDirectMessage ? author.id : messageChannelId, isGroupDm,
}, directUserId: author.id,
// Pass parent peer for thread binding inheritance conversationId: messageChannelId,
parentPeer: earlyThreadParentId ? { kind: "channel", id: earlyThreadParentId } : undefined, }),
parentConversationId: earlyThreadParentId,
}); });
let threadBinding: SessionBindingRecord | undefined; let threadBinding: SessionBindingRecord | undefined;
threadBinding = threadBinding =
@@ -381,15 +385,13 @@ export async function preflightDiscordMessage(
return null; return null;
} }
const boundSessionKey = threadBinding?.targetSessionKey?.trim(); const boundSessionKey = threadBinding?.targetSessionKey?.trim();
const boundAgentId = boundSessionKey ? resolveAgentIdFromSessionKey(boundSessionKey) : undefined; const effectiveRoute = resolveDiscordEffectiveRoute({
const effectiveRoute = boundSessionKey route,
? { boundSessionKey,
...route, configuredRoute,
sessionKey: boundSessionKey, matchedBy: "binding.channel",
agentId: boundAgentId ?? route.agentId, });
matchedBy: "binding.channel" as const, const boundAgentId = boundSessionKey ? effectiveRoute.agentId : undefined;
}
: (configuredRoute?.route ?? route);
const isBoundThreadSession = Boolean(boundSessionKey && earlyThreadChannel); const isBoundThreadSession = Boolean(boundSessionKey && earlyThreadChannel);
if ( if (
isBoundThreadBotSystemMessage({ isBoundThreadBotSystemMessage({

View File

@@ -52,8 +52,7 @@ import { createSubsystemLogger } from "../../logging/subsystem.js";
import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; 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 { resolveAgentRoute } from "../../routing/resolve-route.js"; import type { ResolvedAgentRoute } from "../../routing/resolve-route.js";
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { buildUntrustedChannelMetadata } from "../../security/channel-metadata.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";
@@ -86,6 +85,11 @@ import {
toDiscordModelPickerMessagePayload, toDiscordModelPickerMessagePayload,
type DiscordModelPickerCommandContext, type DiscordModelPickerCommandContext,
} from "./model-picker.js"; } from "./model-picker.js";
import {
buildDiscordRoutePeer,
resolveDiscordConversationRoute,
resolveDiscordEffectiveRoute,
} from "./route-resolution.js";
import { resolveDiscordSenderIdentity } from "./sender-identity.js"; import { resolveDiscordSenderIdentity } from "./sender-identity.js";
import type { ThreadBindingManager } from "./thread-bindings.js"; import type { ThreadBindingManager } from "./thread-bindings.js";
import { resolveDiscordThreadParentInfo } from "./threading.js"; import { resolveDiscordThreadParentInfo } from "./threading.js";
@@ -448,36 +452,32 @@ async function resolveDiscordModelPickerRoute(params: {
threadParentId = parentInfo.id; threadParentId = parentInfo.id;
} }
const route = resolveAgentRoute({ const route = resolveDiscordConversationRoute({
cfg, cfg,
channel: "discord",
accountId, accountId,
guildId: interaction.guild?.id ?? undefined, guildId: interaction.guild?.id ?? undefined,
memberRoleIds, memberRoleIds,
peer: { peer: buildDiscordRoutePeer({
kind: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel", isDirectMessage,
id: isDirectMessage ? (interaction.user?.id ?? rawChannelId) : rawChannelId, isGroupDm,
}, directUserId: interaction.user?.id ?? rawChannelId,
parentPeer: threadParentId ? { kind: "channel", id: threadParentId } : undefined, conversationId: rawChannelId,
}),
parentConversationId: threadParentId,
}); });
const threadBinding = isThreadChannel const threadBinding = isThreadChannel
? params.threadBindings.getByThreadId(rawChannelId) ? params.threadBindings.getByThreadId(rawChannelId)
: undefined; : undefined;
const boundSessionKey = threadBinding?.targetSessionKey?.trim(); return resolveDiscordEffectiveRoute({
const boundAgentId = boundSessionKey ? resolveAgentIdFromSessionKey(boundSessionKey) : undefined; route,
return boundSessionKey boundSessionKey: threadBinding?.targetSessionKey,
? { });
...route,
sessionKey: boundSessionKey,
agentId: boundAgentId ?? route.agentId,
}
: route;
} }
function resolveDiscordModelPickerCurrentModel(params: { function resolveDiscordModelPickerCurrentModel(params: {
cfg: ReturnType<typeof loadConfig>; cfg: ReturnType<typeof loadConfig>;
route: ReturnType<typeof resolveAgentRoute>; route: ResolvedAgentRoute;
data: Awaited<ReturnType<typeof loadDiscordModelPickerData>>; data: Awaited<ReturnType<typeof loadDiscordModelPickerData>>;
}): string { }): string {
const fallback = buildDiscordModelPickerCurrentModel( const fallback = buildDiscordModelPickerCurrentModel(
@@ -1606,17 +1606,18 @@ async function dispatchDiscordCommandInteraction(params: {
const isGuild = Boolean(interaction.guild); const isGuild = Boolean(interaction.guild);
const channelId = rawChannelId || "unknown"; const channelId = rawChannelId || "unknown";
const interactionId = interaction.rawData.id; const interactionId = interaction.rawData.id;
const route = resolveAgentRoute({ const route = resolveDiscordConversationRoute({
cfg, cfg,
channel: "discord",
accountId, accountId,
guildId: interaction.guild?.id ?? undefined, guildId: interaction.guild?.id ?? undefined,
memberRoleIds, memberRoleIds,
peer: { peer: buildDiscordRoutePeer({
kind: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel", isDirectMessage,
id: isDirectMessage ? user.id : channelId, isGroupDm,
}, directUserId: user.id,
parentPeer: threadParentId ? { kind: "channel", id: threadParentId } : undefined, conversationId: channelId,
}),
parentConversationId: threadParentId,
}); });
const threadBinding = isThreadChannel ? threadBindings.getByThreadId(rawChannelId) : undefined; const threadBinding = isThreadChannel ? threadBindings.getByThreadId(rawChannelId) : undefined;
const configuredRoute = const configuredRoute =
@@ -1646,15 +1647,12 @@ async function dispatchDiscordCommandInteraction(params: {
} }
const configuredBoundSessionKey = configuredRoute?.boundSessionKey?.trim() || undefined; const configuredBoundSessionKey = configuredRoute?.boundSessionKey?.trim() || undefined;
const boundSessionKey = threadBinding?.targetSessionKey?.trim() || configuredBoundSessionKey; const boundSessionKey = threadBinding?.targetSessionKey?.trim() || configuredBoundSessionKey;
const boundAgentId = boundSessionKey ? resolveAgentIdFromSessionKey(boundSessionKey) : undefined; const effectiveRoute = resolveDiscordEffectiveRoute({
const effectiveRoute = boundSessionKey route,
? { boundSessionKey,
...route, configuredRoute,
sessionKey: boundSessionKey, matchedBy: configuredBinding ? "binding.channel" : undefined,
agentId: boundAgentId ?? route.agentId, });
...(configuredBinding ? { matchedBy: "binding.channel" as const } : {}),
}
: (configuredRoute?.route ?? route);
const conversationLabel = isDirectMessage ? (user.globalName ?? user.username) : channelId; const conversationLabel = isDirectMessage ? (user.globalName ?? user.username) : channelId;
const ownerAllowFrom = resolveDiscordOwnerAllowFrom({ const ownerAllowFrom = resolveDiscordOwnerAllowFrom({
channelConfig, channelConfig,

View File

@@ -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",
});
});
});

View File

@@ -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 } : {}),
};
}