refactor(channels): centralize conversation resolution

This commit is contained in:
Peter Steinberger
2026-04-22 22:15:03 +01:00
parent f1372681a8
commit 50c95d1d21
4 changed files with 735 additions and 317 deletions

View File

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

View File

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

View File

@@ -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<ChannelPlugin["bindings"]>,
"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");
});
});

View File

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