diff --git a/src/agents/acp-spawn.ts b/src/agents/acp-spawn.ts index 7204362066d..91eeae3436a 100644 --- a/src/agents/acp-spawn.ts +++ b/src/agents/acp-spawn.ts @@ -13,14 +13,9 @@ import { import type { AcpRuntimeSessionMode } from "../acp/runtime/types.js"; import { DEFAULT_HEARTBEAT_EVERY } from "../auto-reply/heartbeat.js"; import { - getChannelPlugin, - getLoadedChannelPlugin, - normalizeChannelId, -} from "../channels/plugins/index.js"; -import { - resolveBundledChannelThreadBindingDefaultPlacement, - resolveBundledChannelThreadBindingInboundConversation, -} from "../channels/plugins/thread-binding-api.js"; + resolveChannelDefaultBindingPlacement, + resolveInboundConversationResolution, +} from "../channels/conversation-resolution.js"; import { resolveThreadBindingIntroText, resolveThreadBindingThreadName, @@ -45,8 +40,6 @@ import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { callGateway } from "../gateway/call.js"; import { areHeartbeatsEnabled } from "../infra/heartbeat-wake.js"; -import { resolveConversationIdFromTargets } from "../infra/outbound/conversation-id.js"; -import { normalizeConversationTargetRef } from "../infra/outbound/session-binding-normalization.js"; import { getSessionBindingService, isSessionBindingError, @@ -291,46 +284,6 @@ function resolvePlacementWithoutChannelPlugin(params: { return params.capabilities.placements.includes("child") ? "child" : "current"; } -function resolvePluginConversationRefForThreadBinding(params: { - channelId: string; - to?: string; - threadId?: string | number; - groupId?: string; -}): { conversationId: string; parentConversationId?: string } | null { - const resolverParams = { - // Keep the live delivery target authoritative; conversationId is only a fallback hint. - to: params.to, - conversationId: params.groupId ?? params.to, - threadId: params.threadId, - isGroup: true, - }; - const loadedPluginConversation = getLoadedChannelPlugin( - params.channelId, - )?.messaging?.resolveInboundConversation?.(resolverParams); - const bundledApiConversation = - loadedPluginConversation === undefined - ? resolveBundledChannelThreadBindingInboundConversation({ - channelId: params.channelId, - ...resolverParams, - }) - : undefined; - const resolvedConversation = - loadedPluginConversation ?? - (bundledApiConversation !== undefined - ? bundledApiConversation - : getChannelPlugin(params.channelId)?.messaging?.resolveInboundConversation?.( - resolverParams, - )); - const conversationId = normalizeOptionalString(resolvedConversation?.conversationId); - if (!conversationId) { - return null; - } - return normalizeConversationTargetRef({ - conversationId, - parentConversationId: resolvedConversation?.parentConversationId, - }); -} - function resolveSpawnMode(params: { requestedMode?: SpawnAcpMode; threadRequested: boolean; @@ -559,38 +512,23 @@ async function persistAcpSpawnSessionFileBestEffort(params: { } function resolveConversationRefForThreadBinding(params: { + cfg: OpenClawConfig; channel?: string; + accountId?: string; to?: string; threadId?: string | number; groupId?: string; }): { conversationId: string; parentConversationId?: string } | null { - const channel = normalizeOptionalLowercaseString(params.channel); - const normalizedChannelId = channel ? normalizeChannelId(channel) : null; - const pluginResolvedConversation = normalizedChannelId - ? resolvePluginConversationRefForThreadBinding({ - channelId: normalizedChannelId, - to: params.to, - threadId: params.threadId, - groupId: params.groupId, - }) - : null; - if (pluginResolvedConversation) { - return pluginResolvedConversation; - } - const parentConversationId = resolveConversationIdFromTargets({ - targets: [params.to], - }); - const genericConversationId = resolveConversationIdFromTargets({ + const resolution = resolveInboundConversationResolution({ + cfg: params.cfg, + channel: params.channel, + accountId: params.accountId, + to: params.to, threadId: params.threadId, - targets: [params.to], + groupId: params.groupId, + isGroup: true, }); - if (genericConversationId) { - return normalizeConversationTargetRef({ - conversationId: genericConversationId, - parentConversationId: params.threadId != null ? parentConversationId : undefined, - }); - } - return null; + return resolution?.canonical ?? null; } function resolveAcpSpawnChannelAccountId(params: { @@ -669,13 +607,7 @@ function prepareAcpThreadBinding(params: { error: `Thread bindings are unavailable for ${policy.channel}.`, }; } - const loadedPluginPlacement = getLoadedChannelPlugin(policy.channel)?.conversationBindings - ?.defaultTopLevelPlacement; - const bundledApiPlacement = - loadedPluginPlacement ?? resolveBundledChannelThreadBindingDefaultPlacement(policy.channel); - const pluginPlacement = - bundledApiPlacement ?? - getChannelPlugin(policy.channel)?.conversationBindings?.defaultTopLevelPlacement; + const pluginPlacement = resolveChannelDefaultBindingPlacement(policy.channel); const placementToUse = pluginPlacement ?? resolvePlacementWithoutChannelPlugin({ @@ -688,7 +620,9 @@ function prepareAcpThreadBinding(params: { }; } const conversationRef = resolveConversationRefForThreadBinding({ + cfg: params.cfg, channel: policy.channel, + accountId: policy.accountId, to: params.to, threadId: params.threadId, groupId: params.groupId, @@ -1031,7 +965,9 @@ function resolveAcpSpawnBootstrapDeliveryPlan(params: { fallbackThreadIdRaw != null ? normalizeOptionalString(String(fallbackThreadIdRaw)) : undefined; const deliveryThreadId = boundThreadId ?? fallbackThreadId; const requesterConversationRef = resolveConversationRefForThreadBinding({ + cfg: params.cfg, channel: params.requester.origin?.channel, + accountId: params.requester.origin?.accountId, threadId: fallbackThreadId, to: params.requester.origin?.to, }); diff --git a/src/channels/conversation-binding-context.ts b/src/channels/conversation-binding-context.ts index a61f9df8019..484fd0fc413 100644 --- a/src/channels/conversation-binding-context.ts +++ b/src/channels/conversation-binding-context.ts @@ -1,14 +1,8 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { resolveConversationIdFromTargets } from "../infra/outbound/conversation-id.js"; -import { getActivePluginChannelRegistry } from "../plugins/runtime.js"; import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalLowercaseString, - normalizeOptionalString, -} from "../shared/string-coerce.js"; -import { parseExplicitTargetForChannel } from "./plugins/target-parsing.js"; -import type { ChannelPlugin } from "./plugins/types.plugin.js"; -import { normalizeAnyChannelId, normalizeChannelId } from "./registry.js"; + resolveCommandConversationResolution, + type ResolveCommandConversationResolutionInput, +} from "./conversation-resolution.js"; export type ConversationBindingContext = { channel: string; @@ -18,241 +12,24 @@ export type ConversationBindingContext = { threadId?: string; }; -export type ResolveConversationBindingContextInput = { +export type ResolveConversationBindingContextInput = ResolveCommandConversationResolutionInput & { cfg: OpenClawConfig; - channel?: string | null; - accountId?: string | null; - chatType?: string | null; - threadId?: string | number | null; - threadParentId?: string | null; - senderId?: string | null; - sessionKey?: string | null; - parentSessionKey?: string | null; - originatingTo?: string | null; - commandTo?: string | null; - fallbackTo?: string | null; - from?: string | null; - nativeChannelId?: string | null; }; -const CANONICAL_TARGET_PREFIXES = [ - "user:", - "channel:", - "conversation:", - "group:", - "room:", - "dm:", - "spaces/", -] as const; - -function getLoadedChannelPlugin(rawChannel: string): ChannelPlugin | undefined { - const normalized = normalizeAnyChannelId(rawChannel) ?? normalizeOptionalString(rawChannel); - if (!normalized) { - return undefined; - } - return getActivePluginChannelRegistry()?.channels.find((entry) => entry.plugin.id === normalized) - ?.plugin; -} - -function shouldDefaultParentConversationToSelf(plugin?: ChannelPlugin): boolean { - return plugin?.bindings?.selfParentConversationByDefault === true; -} - -function resolveBindingAccountId(params: { - rawAccountId?: string | null; - plugin?: ChannelPlugin; - cfg: OpenClawConfig; -}): string { - return ( - normalizeOptionalString(params.rawAccountId) || - normalizeOptionalString(params.plugin?.config.defaultAccountId?.(params.cfg)) || - "default" - ); -} - -function resolveChannelTargetId(params: { - channel: string; - target?: string | null; -}): string | undefined { - const target = normalizeOptionalString(params.target); - if (!target) { - return undefined; - } - - const lower = normalizeLowercaseStringOrEmpty(target); - const channelPrefix = `${params.channel}:`; - if (lower.startsWith(channelPrefix)) { - return resolveChannelTargetId({ - channel: params.channel, - target: target.slice(channelPrefix.length), - }); - } - if (CANONICAL_TARGET_PREFIXES.some((prefix) => lower.startsWith(prefix))) { - return target; - } - - const explicitConversationId = resolveConversationIdFromTargets({ - targets: [target], - }); - if (explicitConversationId) { - return explicitConversationId; - } - - const parsed = parseExplicitTargetForChannel(params.channel, target); - const parsedTarget = normalizeOptionalString(parsed?.to); - if (parsedTarget) { - return ( - resolveConversationIdFromTargets({ - targets: [parsedTarget], - }) ?? parsedTarget - ); - } - - return target; -} - -function buildThreadingContext(params: { - fallbackTo?: string; - originatingTo?: string; - threadId?: string; - from?: string; - chatType?: string; - nativeChannelId?: string; -}) { - const to = - normalizeOptionalString(params.originatingTo) ?? normalizeOptionalString(params.fallbackTo); - return { - ...(to ? { To: to } : {}), - ...(params.from ? { From: params.from } : {}), - ...(params.chatType ? { ChatType: params.chatType } : {}), - ...(params.threadId ? { MessageThreadId: params.threadId } : {}), - ...(params.nativeChannelId ? { NativeChannelId: params.nativeChannelId } : {}), - }; -} - export function resolveConversationBindingContext( params: ResolveConversationBindingContextInput, ): ConversationBindingContext | null { - const channel = - normalizeAnyChannelId(params.channel) ?? - normalizeChannelId(params.channel) ?? - normalizeOptionalLowercaseString(params.channel); - if (!channel) { + const resolution = resolveCommandConversationResolution(params); + if (!resolution) { return null; } - const loadedPlugin = getLoadedChannelPlugin(channel); - const accountId = resolveBindingAccountId({ - rawAccountId: params.accountId, - plugin: loadedPlugin, - cfg: params.cfg, - }); - const threadId = normalizeOptionalString( - params.threadId != null ? String(params.threadId) : undefined, - ); - - const resolvedByProvider = loadedPlugin?.bindings?.resolveCommandConversation?.({ - accountId, - threadId, - threadParentId: normalizeOptionalString(params.threadParentId), - senderId: normalizeOptionalString(params.senderId), - sessionKey: normalizeOptionalString(params.sessionKey), - parentSessionKey: normalizeOptionalString(params.parentSessionKey), - from: normalizeOptionalString(params.from), - chatType: normalizeOptionalString(params.chatType), - originatingTo: params.originatingTo ?? undefined, - commandTo: params.commandTo ?? undefined, - fallbackTo: params.fallbackTo ?? undefined, - }); - if (resolvedByProvider?.conversationId) { - const providerConversationId = normalizeOptionalString(resolvedByProvider.conversationId); - if (!providerConversationId) { - return null; - } - const providerParentConversationId = normalizeOptionalString( - resolvedByProvider.parentConversationId, - ); - const resolvedParentConversationId = - shouldDefaultParentConversationToSelf(loadedPlugin) && - !threadId && - !providerParentConversationId - ? providerConversationId - : providerParentConversationId; - return { - channel, - accountId, - conversationId: providerConversationId, - ...(resolvedParentConversationId - ? { parentConversationId: resolvedParentConversationId } - : {}), - ...(threadId ? { threadId } : {}), - }; - } - - const focusedBinding = loadedPlugin?.threading?.resolveFocusedBinding?.({ - cfg: params.cfg, - accountId, - context: buildThreadingContext({ - fallbackTo: params.fallbackTo ?? undefined, - originatingTo: params.originatingTo ?? undefined, - threadId, - from: normalizeOptionalString(params.from), - chatType: normalizeOptionalString(params.chatType), - nativeChannelId: normalizeOptionalString(params.nativeChannelId), - }), - }); - if (focusedBinding?.conversationId) { - const focusedConversationId = normalizeOptionalString(focusedBinding.conversationId); - if (!focusedConversationId) { - return null; - } - const focusedParentConversationId = normalizeOptionalString( - focusedBinding.parentConversationId, - ); - return { - channel, - accountId, - conversationId: focusedConversationId, - ...(focusedParentConversationId ? { parentConversationId: focusedParentConversationId } : {}), - ...(threadId ? { threadId } : {}), - }; - } - - const baseConversationId = - resolveChannelTargetId({ - channel, - target: params.originatingTo, - }) ?? - resolveChannelTargetId({ - channel, - target: params.commandTo, - }) ?? - resolveChannelTargetId({ - channel, - target: params.fallbackTo, - }); - const parentConversationId = - resolveChannelTargetId({ - channel, - target: params.threadParentId, - }) ?? - (threadId && baseConversationId && baseConversationId !== threadId - ? baseConversationId - : undefined); - const conversationId = threadId || baseConversationId; - if (!conversationId) { - return null; - } - const normalizedParentConversationId = - shouldDefaultParentConversationToSelf(loadedPlugin) && !threadId && !parentConversationId - ? conversationId - : parentConversationId; return { - channel, - accountId, - conversationId, - ...(normalizedParentConversationId - ? { parentConversationId: normalizedParentConversationId } + channel: resolution.canonical.channel, + accountId: resolution.canonical.accountId, + conversationId: resolution.canonical.conversationId, + ...(resolution.canonical.parentConversationId + ? { parentConversationId: resolution.canonical.parentConversationId } : {}), - ...(threadId ? { threadId } : {}), + ...(resolution.threadId ? { threadId: resolution.threadId } : {}), }; } diff --git a/src/channels/conversation-resolution.test.ts b/src/channels/conversation-resolution.test.ts new file mode 100644 index 00000000000..3a1ae48a595 --- /dev/null +++ b/src/channels/conversation-resolution.test.ts @@ -0,0 +1,251 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; +import { + resolveChannelDefaultBindingPlacement, + resolveCommandConversationResolution, + resolveInboundConversationResolution, +} from "./conversation-resolution.js"; +import type { ChannelPlugin } from "./plugins/types.plugin.js"; + +const testConfig = {} as OpenClawConfig; + +function registerChannelPlugin(plugin: ChannelPlugin): void { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: plugin.id, + source: "test", + plugin, + }, + ]), + ); +} + +function createBindingProviderDefaults(): Pick< + NonNullable, + "compileConfiguredBinding" | "matchInboundConversation" +> { + return { + compileConfiguredBinding: (_params) => null, + matchInboundConversation: (_params) => null, + }; +} + +describe("conversation resolution", () => { + afterEach(() => { + setActivePluginRegistry(createTestRegistry()); + }); + + it("uses the runtime command resolver, plugin default account, and placement hint", () => { + registerChannelPlugin({ + ...createChannelTestPluginBase({ + id: "discord", + label: "Discord", + config: { + defaultAccountId: () => "work", + }, + }), + conversationBindings: { + supportsCurrentConversationBinding: true, + defaultTopLevelPlacement: "child", + }, + bindings: { + ...createBindingProviderDefaults(), + resolveCommandConversation: ({ originatingTo }) => { + const conversationId = originatingTo?.trim().replace(/^discord:/i, ""); + return conversationId ? { conversationId } : null; + }, + }, + }); + + expect( + resolveCommandConversationResolution({ + cfg: testConfig, + channel: "discord", + originatingTo: "discord:channel:123", + }), + ).toEqual({ + canonical: { + channel: "discord", + accountId: "work", + conversationId: "channel:123", + }, + placementHint: "child", + source: "command-provider", + }); + }); + + it("applies provider-owned self-parent defaults in one core path", () => { + registerChannelPlugin({ + ...createChannelTestPluginBase({ id: "line", label: "LINE" }), + bindings: { + ...createBindingProviderDefaults(), + selfParentConversationByDefault: true, + resolveCommandConversation: () => ({ + conversationId: "user:U1234567890abcdef1234567890abcdef", + }), + }, + }); + + expect( + resolveCommandConversationResolution({ + cfg: testConfig, + channel: "line", + accountId: "default", + originatingTo: "line:user:U1234567890abcdef1234567890abcdef", + })?.canonical, + ).toEqual({ + channel: "line", + accountId: "default", + conversationId: "user:U1234567890abcdef1234567890abcdef", + parentConversationId: "user:U1234567890abcdef1234567890abcdef", + }); + }); + + it("falls back from command context to channel-prefixed parent plus explicit thread", () => { + registerChannelPlugin({ + ...createChannelTestPluginBase({ id: "test-chat", label: "Test chat" }), + }); + + expect( + resolveCommandConversationResolution({ + cfg: testConfig, + channel: "test-chat", + accountId: "default", + originatingTo: "test-chat:channel:parent-room", + threadId: "child-thread", + }), + ).toEqual({ + canonical: { + channel: "test-chat", + accountId: "default", + conversationId: "child-thread", + parentConversationId: "parent-room", + }, + threadId: "child-thread", + source: "command-fallback", + }); + }); + + it("uses the runtime inbound resolver and preserves provider canonical ids", () => { + registerChannelPlugin({ + ...createChannelTestPluginBase({ id: "discord", label: "Discord" }), + conversationBindings: { + supportsCurrentConversationBinding: true, + defaultTopLevelPlacement: "child", + }, + messaging: { + resolveInboundConversation: ({ conversationId, to }) => { + const source = (conversationId ?? to ?? "").trim(); + const normalized = source.replace(/^discord:/i, ""); + return normalized ? { conversationId: normalized } : null; + }, + }, + }); + + expect( + resolveInboundConversationResolution({ + cfg: testConfig, + channel: "discord", + accountId: "default", + to: "discord:channel:123", + }), + ).toEqual({ + canonical: { + channel: "discord", + accountId: "default", + conversationId: "channel:123", + }, + placementHint: "child", + source: "inbound-provider", + }); + }); + + it("keeps Matrix room casing when the channel resolver returns a child thread", () => { + registerChannelPlugin({ + ...createChannelTestPluginBase({ id: "matrix", label: "Matrix" }), + conversationBindings: { + supportsCurrentConversationBinding: true, + defaultTopLevelPlacement: "child", + }, + messaging: { + resolveInboundConversation: ({ threadId, to }) => { + const parent = to?.trim().replace(/^(?:matrix:)?(?:channel:|room:)/iu, ""); + return threadId && parent + ? { conversationId: String(threadId), parentConversationId: parent } + : null; + }, + }, + }); + + expect( + resolveInboundConversationResolution({ + cfg: testConfig, + channel: "matrix", + to: "room:!Room:Example.org", + threadId: "$thread-root", + })?.canonical, + ).toEqual({ + channel: "matrix", + accountId: "default", + conversationId: "$thread-root", + parentConversationId: "!Room:Example.org", + }); + }); + + it("does not fall through when a channel explicitly rejects an inbound target", () => { + registerChannelPlugin({ + ...createChannelTestPluginBase({ id: "matrix", label: "Matrix" }), + messaging: { + resolveInboundConversation: () => null, + }, + }); + + expect( + resolveInboundConversationResolution({ + cfg: testConfig, + channel: "matrix", + to: "room:!Room:Example.org", + }), + ).toBeNull(); + }); + + it("falls back from inbound context to channel-prefixed parent plus explicit thread", () => { + registerChannelPlugin({ + ...createChannelTestPluginBase({ id: "test-chat", label: "Test chat" }), + }); + + expect( + resolveInboundConversationResolution({ + cfg: testConfig, + channel: "test-chat", + accountId: "default", + to: "test-chat:channel:parent-room", + threadId: "child-thread", + }), + ).toEqual({ + canonical: { + channel: "test-chat", + accountId: "default", + conversationId: "child-thread", + parentConversationId: "parent-room", + }, + threadId: "child-thread", + source: "inbound-fallback", + }); + }); + + it("resolves placement from runtime plugin metadata", () => { + registerChannelPlugin({ + ...createChannelTestPluginBase({ id: "telegram", label: "Telegram" }), + conversationBindings: { + supportsCurrentConversationBinding: true, + defaultTopLevelPlacement: "current", + }, + }); + + expect(resolveChannelDefaultBindingPlacement("telegram")).toBe("current"); + }); +}); diff --git a/src/channels/conversation-resolution.ts b/src/channels/conversation-resolution.ts new file mode 100644 index 00000000000..7c711b69ac0 --- /dev/null +++ b/src/channels/conversation-resolution.ts @@ -0,0 +1,454 @@ +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveConversationIdFromTargets } from "../infra/outbound/conversation-id.js"; +import { normalizeConversationTargetRef } from "../infra/outbound/session-binding-normalization.js"; +import { getActivePluginChannelRegistry } from "../plugins/runtime.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalLowercaseString, + normalizeOptionalString, +} from "../shared/string-coerce.js"; +import { getChannelPlugin, getLoadedChannelPlugin, normalizeChannelId } from "./plugins/index.js"; +import { parseExplicitTargetForChannel } from "./plugins/target-parsing.js"; +import { + resolveBundledChannelThreadBindingDefaultPlacement, + resolveBundledChannelThreadBindingInboundConversation, +} from "./plugins/thread-binding-api.js"; +import type { ChannelCommandConversationContext } from "./plugins/types.adapters.js"; +import type { ChannelPlugin } from "./plugins/types.plugin.js"; +import { normalizeAnyChannelId } from "./registry.js"; + +export type ConversationResolutionSource = + | "command-provider" + | "focused-binding" + | "command-fallback" + | "inbound-provider" + | "inbound-bundled-artifact" + | "inbound-bundled-plugin" + | "inbound-fallback"; + +export type ConversationResolution = { + canonical: { + channel: string; + accountId: string; + conversationId: string; + parentConversationId?: string; + }; + threadId?: string; + placementHint?: "current" | "child"; + source: ConversationResolutionSource; +}; + +export type ResolveCommandConversationResolutionInput = { + cfg: OpenClawConfig; + channel?: string | null; + accountId?: string | null; + chatType?: string | null; + threadId?: string | number | null; + threadParentId?: string | null; + senderId?: string | null; + sessionKey?: string | null; + parentSessionKey?: string | null; + originatingTo?: string | null; + commandTo?: string | null; + fallbackTo?: string | null; + from?: string | null; + nativeChannelId?: string | null; +}; + +export type ResolveInboundConversationResolutionInput = { + cfg: OpenClawConfig; + channel?: string | null; + accountId?: string | null; + to?: string | null; + threadId?: string | number | null; + conversationId?: string | null; + groupId?: string | null; + from?: string | null; + isGroup?: boolean; +}; + +const CANONICAL_TARGET_PREFIXES = ["user:", "spaces/"] as const; + +function resolveChannelId(raw?: string | null): string | null { + const normalizedRaw = normalizeOptionalString(raw); + if (!normalizedRaw) { + return null; + } + return ( + normalizeAnyChannelId(normalizedRaw) ?? + normalizeChannelId(normalizedRaw) ?? + normalizeOptionalLowercaseString(normalizedRaw) ?? + null + ); +} + +function getActiveRegistryChannelPlugin(rawChannel: string): ChannelPlugin | undefined { + const normalized = normalizeAnyChannelId(rawChannel) ?? normalizeOptionalString(rawChannel); + if (!normalized) { + return undefined; + } + return getActivePluginChannelRegistry()?.channels.find((entry) => entry.plugin.id === normalized) + ?.plugin; +} + +function getRuntimeChannelPluginCandidates(channel: string): ChannelPlugin[] { + const candidates = [ + getActiveRegistryChannelPlugin(channel), + getLoadedChannelPlugin(channel), + ].filter((plugin): plugin is ChannelPlugin => Boolean(plugin)); + return [...new Map(candidates.map((plugin) => [plugin.id, plugin])).values()]; +} + +function resolveRuntimeChannelPlugin(channel: string): ChannelPlugin | undefined { + return getRuntimeChannelPluginCandidates(channel)[0]; +} + +function shouldDefaultParentConversationToSelf(plugin?: ChannelPlugin): boolean { + return plugin?.bindings?.selfParentConversationByDefault === true; +} + +function normalizeResolutionTarget(params: { + channel: string; + accountId: string; + conversation: { conversationId?: string; parentConversationId?: string } | null | undefined; + source: ConversationResolutionSource; + threadId?: string; + plugin?: ChannelPlugin; +}): ConversationResolution | null { + const conversationId = normalizeOptionalString(params.conversation?.conversationId); + if (!conversationId) { + return null; + } + const parentConversationId = normalizeOptionalString(params.conversation?.parentConversationId); + const defaultParentToSelf = + shouldDefaultParentConversationToSelf(params.plugin) && + !params.threadId && + !parentConversationId; + const normalized = normalizeConversationTargetRef({ + conversationId, + parentConversationId: defaultParentToSelf ? conversationId : parentConversationId, + }); + const normalizedParentConversationId = defaultParentToSelf + ? normalized.conversationId + : normalized.parentConversationId; + return { + canonical: { + channel: params.channel, + accountId: params.accountId, + conversationId: normalized.conversationId, + ...(normalizedParentConversationId + ? { parentConversationId: normalizedParentConversationId } + : {}), + }, + ...(params.threadId ? { threadId: params.threadId } : {}), + placementHint: resolveChannelDefaultBindingPlacement(params.channel), + source: params.source, + }; +} + +function resolveBindingAccountId(params: { + rawAccountId?: string | null; + plugin?: ChannelPlugin; + cfg: OpenClawConfig; +}): string { + return ( + normalizeOptionalString(params.rawAccountId) || + normalizeOptionalString(params.plugin?.config.defaultAccountId?.(params.cfg)) || + "default" + ); +} + +function resolveChannelTargetId(params: { + channel: string; + target?: string | null; +}): string | undefined { + const target = normalizeOptionalString(params.target); + if (!target) { + return undefined; + } + + const lower = normalizeLowercaseStringOrEmpty(target); + const channelPrefix = `${params.channel}:`; + if (lower.startsWith(channelPrefix)) { + return resolveChannelTargetId({ + channel: params.channel, + target: target.slice(channelPrefix.length), + }); + } + if (CANONICAL_TARGET_PREFIXES.some((prefix) => lower.startsWith(prefix))) { + return target; + } + + const explicitConversationId = resolveConversationIdFromTargets({ + targets: [target], + }); + if (explicitConversationId) { + return explicitConversationId; + } + + const parsed = parseExplicitTargetForChannel(params.channel, target); + const parsedTarget = normalizeOptionalString(parsed?.to); + if (parsedTarget) { + return ( + resolveConversationIdFromTargets({ + targets: [parsedTarget], + }) ?? parsedTarget + ); + } + + return target; +} + +function buildThreadingContext(params: { + fallbackTo?: string; + originatingTo?: string; + threadId?: string; + from?: string; + chatType?: string; + nativeChannelId?: string; +}) { + const to = + normalizeOptionalString(params.originatingTo) ?? normalizeOptionalString(params.fallbackTo); + return { + ...(to ? { To: to } : {}), + ...(params.from ? { From: params.from } : {}), + ...(params.chatType ? { ChatType: params.chatType } : {}), + ...(params.threadId ? { MessageThreadId: params.threadId } : {}), + ...(params.nativeChannelId ? { NativeChannelId: params.nativeChannelId } : {}), + }; +} + +export function resolveChannelDefaultBindingPlacement( + rawChannel?: string | null, +): "current" | "child" | undefined { + const channel = resolveChannelId(rawChannel); + if (!channel) { + return undefined; + } + const pluginPlacement = + resolveRuntimeChannelPlugin(channel)?.conversationBindings?.defaultTopLevelPlacement; + return ( + pluginPlacement ?? + resolveBundledChannelThreadBindingDefaultPlacement(channel) ?? + getChannelPlugin(channel)?.conversationBindings?.defaultTopLevelPlacement + ); +} + +export function resolveCommandConversationResolution( + params: ResolveCommandConversationResolutionInput, +): ConversationResolution | null { + const channel = resolveChannelId(params.channel); + if (!channel) { + return null; + } + const plugin = resolveRuntimeChannelPlugin(channel); + const accountId = resolveBindingAccountId({ + rawAccountId: params.accountId, + plugin, + cfg: params.cfg, + }); + const threadId = normalizeOptionalString( + params.threadId != null ? String(params.threadId) : undefined, + ); + + const commandParams: ChannelCommandConversationContext = { + accountId, + threadId, + threadParentId: normalizeOptionalString(params.threadParentId), + senderId: normalizeOptionalString(params.senderId), + sessionKey: normalizeOptionalString(params.sessionKey), + parentSessionKey: normalizeOptionalString(params.parentSessionKey), + from: normalizeOptionalString(params.from), + chatType: normalizeOptionalString(params.chatType), + originatingTo: params.originatingTo ?? undefined, + commandTo: params.commandTo ?? undefined, + fallbackTo: params.fallbackTo ?? undefined, + }; + + const resolvedByProvider = plugin?.bindings?.resolveCommandConversation?.(commandParams); + const providerResolution = normalizeResolutionTarget({ + channel, + accountId, + conversation: resolvedByProvider, + source: "command-provider", + threadId, + plugin, + }); + if (providerResolution) { + return providerResolution; + } + + const focusedBinding = plugin?.threading?.resolveFocusedBinding?.({ + cfg: params.cfg, + accountId, + context: buildThreadingContext({ + fallbackTo: params.fallbackTo ?? undefined, + originatingTo: params.originatingTo ?? undefined, + threadId, + from: normalizeOptionalString(params.from), + chatType: normalizeOptionalString(params.chatType), + nativeChannelId: normalizeOptionalString(params.nativeChannelId), + }), + }); + const focusedResolution = normalizeResolutionTarget({ + channel, + accountId, + conversation: focusedBinding, + source: "focused-binding", + threadId, + plugin, + }); + if (focusedResolution) { + return focusedResolution; + } + + const baseConversationId = + resolveChannelTargetId({ + channel, + target: params.originatingTo, + }) ?? + resolveChannelTargetId({ + channel, + target: params.commandTo, + }) ?? + resolveChannelTargetId({ + channel, + target: params.fallbackTo, + }); + const parentConversationId = + resolveChannelTargetId({ + channel, + target: params.threadParentId, + }) ?? + (threadId && baseConversationId && baseConversationId !== threadId + ? baseConversationId + : undefined); + const conversationId = threadId || baseConversationId; + if (!conversationId) { + return null; + } + return normalizeResolutionTarget({ + channel, + accountId, + conversation: { + conversationId, + parentConversationId, + }, + source: "command-fallback", + threadId, + plugin, + }); +} + +export function resolveInboundConversationResolution( + params: ResolveInboundConversationResolutionInput, +): ConversationResolution | null { + const channel = resolveChannelId(params.channel); + if (!channel) { + return null; + } + const plugin = resolveRuntimeChannelPlugin(channel); + const accountId = resolveBindingAccountId({ + rawAccountId: params.accountId, + plugin, + cfg: params.cfg, + }); + const threadId = normalizeOptionalString( + params.threadId != null ? String(params.threadId) : undefined, + ); + const resolverParams = { + from: normalizeOptionalString(params.from), + to: normalizeOptionalString(params.to), + conversationId: + normalizeOptionalString(params.conversationId) ?? + normalizeOptionalString(params.groupId) ?? + normalizeOptionalString(params.to), + threadId, + isGroup: params.isGroup ?? true, + }; + + const providerConversation = plugin?.messaging?.resolveInboundConversation?.(resolverParams); + const providerResolution = normalizeResolutionTarget({ + channel, + accountId, + conversation: providerConversation, + source: "inbound-provider", + threadId, + plugin, + }); + if (providerResolution || providerConversation === null) { + return providerResolution; + } + + const artifactConversation = resolveBundledChannelThreadBindingInboundConversation({ + channelId: channel, + ...resolverParams, + }); + const artifactResolution = normalizeResolutionTarget({ + channel, + accountId, + conversation: artifactConversation, + source: "inbound-bundled-artifact", + threadId, + plugin, + }); + if (artifactResolution || artifactConversation === null) { + return artifactResolution; + } + + const bundledPlugin = getChannelPlugin(channel); + const bundledConversation = + bundledPlugin !== plugin + ? bundledPlugin?.messaging?.resolveInboundConversation?.(resolverParams) + : undefined; + const bundledResolution = normalizeResolutionTarget({ + channel, + accountId, + conversation: bundledConversation, + source: "inbound-bundled-plugin", + threadId, + plugin: bundledPlugin ?? plugin, + }); + if (bundledResolution || bundledConversation === null) { + return bundledResolution; + } + + const parentConversationId = + resolveChannelTargetId({ + channel, + target: params.to, + }) ?? + resolveChannelTargetId({ + channel, + target: params.conversationId, + }) ?? + resolveChannelTargetId({ + channel, + target: params.groupId, + }); + const genericConversationId = + threadId ?? + resolveChannelTargetId({ + channel, + target: params.conversationId, + }) ?? + resolveChannelTargetId({ + channel, + target: params.groupId, + }) ?? + parentConversationId; + if (!genericConversationId) { + return null; + } + return normalizeResolutionTarget({ + channel, + accountId, + conversation: { + conversationId: genericConversationId, + parentConversationId: threadId != null ? parentConversationId : undefined, + }, + source: "inbound-fallback", + threadId, + plugin, + }); +}