mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:00:42 +00:00
refactor(channels): centralize conversation resolution
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
251
src/channels/conversation-resolution.test.ts
Normal file
251
src/channels/conversation-resolution.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
454
src/channels/conversation-resolution.ts
Normal file
454
src/channels/conversation-resolution.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user