mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-29 10:50:58 +00:00
refactor(acp): centralize conversation binding context
This commit is contained in:
@@ -3,7 +3,7 @@ summary: "Use ACP runtime sessions for Codex, Claude Code, Cursor, Gemini CLI, O
|
||||
read_when:
|
||||
- Running coding harnesses through ACP
|
||||
- Setting up conversation-bound ACP sessions on messaging channels
|
||||
- Binding Discord channels, Telegram topics, BlueBubbles chats, or iMessage chats to persistent ACP sessions
|
||||
- Binding a message channel conversation to a persistent ACP session
|
||||
- Troubleshooting ACP backend and plugin wiring
|
||||
- Operating /acp commands from chat
|
||||
title: "ACP Agents"
|
||||
@@ -104,12 +104,11 @@ Examples:
|
||||
- `/acp spawn codex --thread auto`: OpenClaw may create a child thread/topic and bind the ACP session there
|
||||
- `/acp spawn codex --bind here --cwd /workspace/repo`: same chat binding as above, but Codex runs in `/workspace/repo`
|
||||
|
||||
Built-in current-conversation binding support:
|
||||
Current-conversation binding support:
|
||||
|
||||
- Discord current channel or current thread
|
||||
- Telegram current chat or current topic
|
||||
- BlueBubbles DM or group chat
|
||||
- iMessage DM or group chat
|
||||
- Any message channel can use `--bind here` through the shared conversation-binding path.
|
||||
- Channels with custom thread/topic semantics can still provide channel-specific canonicalization behind the same shared interface.
|
||||
- `--bind here` always means "bind the current conversation in place"; it does not require a per-channel ACP adapter anymore.
|
||||
|
||||
Notes:
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { normalizeConversationText } from "../../../acp/conversation-id.js";
|
||||
import { resolveChannelConfiguredBindingProviderByChannel } from "../../../channels/plugins/binding-provider.js";
|
||||
import { resolveConversationIdFromTargets } from "../../../infra/outbound/conversation-id.js";
|
||||
import { resolveConversationBindingContext } from "../../../channels/conversation-binding-context.js";
|
||||
import type { HandleCommandsParams } from "../commands-types.js";
|
||||
|
||||
export function resolveAcpCommandChannel(params: HandleCommandsParams): string {
|
||||
@@ -29,40 +28,29 @@ function resolveAcpCommandConversationRef(params: HandleCommandsParams): {
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
} | null {
|
||||
const channel = resolveAcpCommandChannel(params);
|
||||
const threadId = resolveAcpCommandThreadId(params);
|
||||
const provider = resolveChannelConfiguredBindingProviderByChannel(channel);
|
||||
const resolvedByProvider = provider?.resolveCommandConversation?.({
|
||||
const resolved = resolveConversationBindingContext({
|
||||
cfg: params.cfg,
|
||||
channel: resolveAcpCommandChannel(params),
|
||||
accountId: resolveAcpCommandAccountId(params),
|
||||
threadId,
|
||||
threadParentId: normalizeConversationText(params.ctx.ThreadParentId),
|
||||
senderId: normalizeConversationText(params.command.senderId ?? params.ctx.SenderId),
|
||||
chatType: params.ctx.ChatType,
|
||||
threadId: resolveAcpCommandThreadId(params),
|
||||
threadParentId: params.ctx.ThreadParentId,
|
||||
senderId: params.command.senderId ?? params.ctx.SenderId,
|
||||
sessionKey: params.sessionKey,
|
||||
parentSessionKey: normalizeConversationText(params.ctx.ParentSessionKey),
|
||||
parentSessionKey: params.ctx.ParentSessionKey,
|
||||
originatingTo: params.ctx.OriginatingTo,
|
||||
commandTo: params.command.to,
|
||||
fallbackTo: params.ctx.To,
|
||||
from: params.ctx.From,
|
||||
nativeChannelId: params.ctx.NativeChannelId,
|
||||
});
|
||||
if (resolvedByProvider?.conversationId) {
|
||||
return resolvedByProvider;
|
||||
}
|
||||
const targets = [params.ctx.OriginatingTo, params.command.to, params.ctx.To];
|
||||
const conversationId = resolveConversationIdFromTargets({
|
||||
threadId,
|
||||
targets,
|
||||
});
|
||||
if (!conversationId) {
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
const parentConversationId = threadId
|
||||
? resolveConversationIdFromTargets({
|
||||
targets,
|
||||
})
|
||||
: undefined;
|
||||
return {
|
||||
conversationId,
|
||||
...(parentConversationId && parentConversationId !== conversationId
|
||||
? { parentConversationId }
|
||||
conversationId: resolved.conversationId,
|
||||
...(resolved.parentConversationId && resolved.parentConversationId !== resolved.conversationId
|
||||
? { parentConversationId: resolved.parentConversationId }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import {
|
||||
buildTelegramTopicConversationId,
|
||||
normalizeConversationText,
|
||||
parseTelegramChatIdFromTarget,
|
||||
} from "../../acp/conversation-id.js";
|
||||
import { normalizeConversationText } from "../../acp/conversation-id.js";
|
||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||
import { clearBootstrapSnapshotOnSessionRollover } from "../../agents/bootstrap-cache.js";
|
||||
import { normalizeChatType } from "../../channels/chat-type.js";
|
||||
import { resolveConversationBindingContext } from "../../channels/conversation-binding-context.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { resolveGroupSessionKey } from "../../config/sessions/group.js";
|
||||
import { deriveSessionMetaPatch } from "../../config/sessions/metadata.js";
|
||||
@@ -29,7 +26,7 @@ import {
|
||||
type SessionScope,
|
||||
} from "../../config/sessions/types.js";
|
||||
import type { TtsAutoMode } from "../../config/types.tts.js";
|
||||
import { resolveConversationIdFromTargets } from "../../infra/outbound/conversation-id.js";
|
||||
import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js";
|
||||
import { deliverSessionMaintenanceWarning } from "../../infra/session-maintenance-warning.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
||||
@@ -39,7 +36,6 @@ import { isInternalMessageChannel } from "../../utils/message-channel.js";
|
||||
import { resolveCommandAuthorization } from "../command-auth.js";
|
||||
import type { MsgContext, TemplateContext } from "../templating.js";
|
||||
import { resolveEffectiveResetTargetSessionKey } from "./acp-reset-target.js";
|
||||
import { parseDiscordParentChannelFromSessionKey } from "./discord-parent-channel.js";
|
||||
import { normalizeInboundTextNewlines } from "./inbound-text.js";
|
||||
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
|
||||
import {
|
||||
@@ -102,79 +98,40 @@ function isResetAuthorizedForContext(params: {
|
||||
return scopes.includes("operator.admin");
|
||||
}
|
||||
|
||||
function resolveAcpResetBindingContext(ctx: MsgContext): {
|
||||
function resolveAcpResetBindingContext(
|
||||
cfg: OpenClawConfig,
|
||||
ctx: MsgContext,
|
||||
): {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
} | null {
|
||||
const channelRaw = normalizeConversationText(
|
||||
ctx.OriginatingChannel ?? ctx.Surface ?? ctx.Provider ?? "",
|
||||
).toLowerCase();
|
||||
if (!channelRaw) {
|
||||
return null;
|
||||
}
|
||||
const accountId = normalizeConversationText(ctx.AccountId) || "default";
|
||||
const normalizedThreadId =
|
||||
ctx.MessageThreadId != null ? normalizeConversationText(String(ctx.MessageThreadId)) : "";
|
||||
|
||||
if (channelRaw === "telegram") {
|
||||
const parentConversationId =
|
||||
parseTelegramChatIdFromTarget(ctx.OriginatingTo) ?? parseTelegramChatIdFromTarget(ctx.To);
|
||||
let conversationId =
|
||||
resolveConversationIdFromTargets({
|
||||
threadId: normalizedThreadId || undefined,
|
||||
targets: [ctx.OriginatingTo, ctx.To],
|
||||
}) ?? "";
|
||||
if (normalizedThreadId && parentConversationId) {
|
||||
conversationId =
|
||||
buildTelegramTopicConversationId({
|
||||
chatId: parentConversationId,
|
||||
topicId: normalizedThreadId,
|
||||
}) ?? conversationId;
|
||||
}
|
||||
if (!conversationId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
channel: channelRaw,
|
||||
accountId,
|
||||
conversationId,
|
||||
...(parentConversationId ? { parentConversationId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const conversationId = resolveConversationIdFromTargets({
|
||||
threadId: normalizedThreadId || undefined,
|
||||
targets: [ctx.OriginatingTo, ctx.To],
|
||||
const bindingContext = resolveConversationBindingContext({
|
||||
cfg,
|
||||
channel: ctx.OriginatingChannel ?? ctx.Surface ?? ctx.Provider,
|
||||
accountId: ctx.AccountId,
|
||||
chatType: ctx.ChatType,
|
||||
threadId: ctx.MessageThreadId,
|
||||
threadParentId: ctx.ThreadParentId,
|
||||
senderId: ctx.SenderId,
|
||||
sessionKey: ctx.SessionKey,
|
||||
parentSessionKey: ctx.ParentSessionKey,
|
||||
originatingTo: ctx.OriginatingTo,
|
||||
fallbackTo: ctx.To,
|
||||
from: ctx.From,
|
||||
nativeChannelId: ctx.NativeChannelId,
|
||||
});
|
||||
if (!conversationId) {
|
||||
if (!bindingContext) {
|
||||
return null;
|
||||
}
|
||||
let parentConversationId: string | undefined;
|
||||
if (channelRaw === "discord" && normalizedThreadId) {
|
||||
const fromContext = normalizeConversationText(ctx.ThreadParentId);
|
||||
if (fromContext && fromContext !== conversationId) {
|
||||
parentConversationId = fromContext;
|
||||
} else {
|
||||
const fromParentSession = parseDiscordParentChannelFromSessionKey(ctx.ParentSessionKey);
|
||||
if (fromParentSession && fromParentSession !== conversationId) {
|
||||
parentConversationId = fromParentSession;
|
||||
} else {
|
||||
const fromTargets = resolveConversationIdFromTargets({
|
||||
targets: [ctx.OriginatingTo, ctx.To],
|
||||
});
|
||||
if (fromTargets && fromTargets !== conversationId) {
|
||||
parentConversationId = fromTargets;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
channel: channelRaw,
|
||||
accountId,
|
||||
conversationId,
|
||||
...(parentConversationId ? { parentConversationId } : {}),
|
||||
channel: bindingContext.channel,
|
||||
accountId: bindingContext.accountId,
|
||||
conversationId: bindingContext.conversationId,
|
||||
...(bindingContext.parentConversationId
|
||||
? { parentConversationId: bindingContext.parentConversationId }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -183,7 +140,7 @@ function resolveBoundAcpSessionForReset(params: {
|
||||
ctx: MsgContext;
|
||||
}): string | undefined {
|
||||
const activeSessionKey = normalizeConversationText(params.ctx.SessionKey);
|
||||
const bindingContext = resolveAcpResetBindingContext(params.ctx);
|
||||
const bindingContext = resolveAcpResetBindingContext(params.cfg, params.ctx);
|
||||
return resolveEffectiveResetTargetSessionKey({
|
||||
cfg: params.cfg,
|
||||
channel: bindingContext?.channel,
|
||||
@@ -197,6 +154,43 @@ function resolveBoundAcpSessionForReset(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function resolveBoundConversationSessionKey(params: {
|
||||
cfg: OpenClawConfig;
|
||||
ctx: MsgContext;
|
||||
}): string | undefined {
|
||||
const bindingContext = resolveConversationBindingContext({
|
||||
cfg: params.cfg,
|
||||
channel: params.ctx.OriginatingChannel ?? params.ctx.Surface ?? params.ctx.Provider,
|
||||
accountId: params.ctx.AccountId,
|
||||
chatType: params.ctx.ChatType,
|
||||
threadId: params.ctx.MessageThreadId,
|
||||
threadParentId: params.ctx.ThreadParentId,
|
||||
senderId: params.ctx.SenderId,
|
||||
sessionKey: params.ctx.SessionKey,
|
||||
parentSessionKey: params.ctx.ParentSessionKey,
|
||||
originatingTo: params.ctx.OriginatingTo,
|
||||
fallbackTo: params.ctx.To,
|
||||
from: params.ctx.From,
|
||||
nativeChannelId: params.ctx.NativeChannelId,
|
||||
});
|
||||
if (!bindingContext) {
|
||||
return undefined;
|
||||
}
|
||||
const binding = getSessionBindingService().resolveByConversation({
|
||||
channel: bindingContext.channel,
|
||||
accountId: bindingContext.accountId,
|
||||
conversationId: bindingContext.conversationId,
|
||||
...(bindingContext.parentConversationId
|
||||
? { parentConversationId: bindingContext.parentConversationId }
|
||||
: {}),
|
||||
});
|
||||
if (!binding?.targetSessionKey) {
|
||||
return undefined;
|
||||
}
|
||||
getSessionBindingService().touch(binding.bindingId);
|
||||
return binding.targetSessionKey;
|
||||
}
|
||||
|
||||
export async function initSessionState(params: {
|
||||
ctx: MsgContext;
|
||||
cfg: OpenClawConfig;
|
||||
@@ -205,8 +199,13 @@ export async function initSessionState(params: {
|
||||
const { ctx, cfg, commandAuthorized } = params;
|
||||
// Native slash commands (Telegram/Discord/Slack) are delivered on a separate
|
||||
// "slash session" key, but should mutate the target chat session.
|
||||
const targetSessionKey =
|
||||
const commandTargetSessionKey =
|
||||
ctx.CommandSource === "native" ? ctx.CommandTargetSessionKey?.trim() : undefined;
|
||||
const targetSessionKey =
|
||||
resolveBoundConversationSessionKey({
|
||||
cfg,
|
||||
ctx,
|
||||
}) ?? commandTargetSessionKey;
|
||||
const sessionCtxForState =
|
||||
targetSessionKey && targetSessionKey !== ctx.SessionKey
|
||||
? { ...ctx, SessionKey: targetSessionKey }
|
||||
|
||||
213
src/channels/conversation-binding-context.ts
Normal file
213
src/channels/conversation-binding-context.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { normalizeConversationText } from "../acp/conversation-id.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { resolveConversationIdFromTargets } from "../infra/outbound/conversation-id.js";
|
||||
import { getActivePluginChannelRegistry } from "../plugins/runtime.js";
|
||||
import { parseExplicitTargetForChannel } from "./plugins/target-parsing.js";
|
||||
import type { ChannelPlugin } from "./plugins/types.js";
|
||||
import { normalizeAnyChannelId, normalizeChannelId } from "./registry.js";
|
||||
|
||||
export type ConversationBindingContext = {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
threadId?: string;
|
||||
};
|
||||
|
||||
export type ResolveConversationBindingContextInput = {
|
||||
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 normalizeText(value: unknown): string | undefined {
|
||||
const normalized = normalizeConversationText(value);
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
function getLoadedChannelPlugin(rawChannel: string): ChannelPlugin | undefined {
|
||||
const normalized = normalizeAnyChannelId(rawChannel) ?? normalizeText(rawChannel);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
return getActivePluginChannelRegistry()?.channels.find((entry) => entry.plugin.id === normalized)
|
||||
?.plugin;
|
||||
}
|
||||
|
||||
function resolveChannelTargetId(params: {
|
||||
channel: string;
|
||||
target?: string | null;
|
||||
}): string | undefined {
|
||||
const target = normalizeText(params.target);
|
||||
if (!target) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = parseExplicitTargetForChannel(params.channel, target);
|
||||
const parsedTarget = normalizeText(parsed?.to);
|
||||
if (parsedTarget) {
|
||||
return (
|
||||
resolveConversationIdFromTargets({
|
||||
targets: [parsedTarget],
|
||||
}) ?? parsedTarget
|
||||
);
|
||||
}
|
||||
|
||||
const lower = target.toLowerCase();
|
||||
const channelPrefix = `${params.channel}:`;
|
||||
if (lower.startsWith(channelPrefix)) {
|
||||
return normalizeText(target.slice(channelPrefix.length));
|
||||
}
|
||||
if (CANONICAL_TARGET_PREFIXES.some((prefix) => lower.startsWith(prefix))) {
|
||||
return target;
|
||||
}
|
||||
|
||||
const explicitConversationId = resolveConversationIdFromTargets({
|
||||
targets: [target],
|
||||
});
|
||||
return explicitConversationId ?? target;
|
||||
}
|
||||
|
||||
function buildThreadingContext(params: {
|
||||
fallbackTo?: string;
|
||||
originatingTo?: string;
|
||||
threadId?: string;
|
||||
from?: string;
|
||||
chatType?: string;
|
||||
nativeChannelId?: string;
|
||||
}) {
|
||||
const to = normalizeText(params.originatingTo) ?? normalizeText(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) ??
|
||||
normalizeText(params.channel)?.toLowerCase();
|
||||
if (!channel) {
|
||||
return null;
|
||||
}
|
||||
const accountId = normalizeText(params.accountId) || "default";
|
||||
const threadId = normalizeText(params.threadId != null ? String(params.threadId) : undefined);
|
||||
const loadedPlugin = getLoadedChannelPlugin(channel);
|
||||
|
||||
const resolvedByProvider = loadedPlugin?.bindings?.resolveCommandConversation?.({
|
||||
accountId,
|
||||
threadId,
|
||||
threadParentId: normalizeText(params.threadParentId),
|
||||
senderId: normalizeText(params.senderId),
|
||||
sessionKey: normalizeText(params.sessionKey),
|
||||
parentSessionKey: normalizeText(params.parentSessionKey),
|
||||
originatingTo: params.originatingTo ?? undefined,
|
||||
commandTo: params.commandTo ?? undefined,
|
||||
fallbackTo: params.fallbackTo ?? undefined,
|
||||
});
|
||||
if (resolvedByProvider?.conversationId) {
|
||||
const resolvedParentConversationId =
|
||||
channel === "telegram" && !threadId && !resolvedByProvider.parentConversationId
|
||||
? resolvedByProvider.conversationId
|
||||
: resolvedByProvider.parentConversationId;
|
||||
return {
|
||||
channel,
|
||||
accountId,
|
||||
conversationId: resolvedByProvider.conversationId,
|
||||
...(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: normalizeText(params.from),
|
||||
chatType: normalizeText(params.chatType),
|
||||
nativeChannelId: normalizeText(params.nativeChannelId),
|
||||
}),
|
||||
});
|
||||
if (focusedBinding?.conversationId) {
|
||||
return {
|
||||
channel,
|
||||
accountId,
|
||||
conversationId: focusedBinding.conversationId,
|
||||
...(focusedBinding.parentConversationId
|
||||
? { parentConversationId: focusedBinding.parentConversationId }
|
||||
: {}),
|
||||
...(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 =
|
||||
channel === "telegram" && !threadId && !parentConversationId
|
||||
? conversationId
|
||||
: parentConversationId;
|
||||
return {
|
||||
channel,
|
||||
accountId,
|
||||
conversationId,
|
||||
...(normalizedParentConversationId
|
||||
? { parentConversationId: normalizedParentConversationId }
|
||||
: {}),
|
||||
...(threadId ? { threadId } : {}),
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { normalizeAnyChannelId, normalizeChannelId } from "../../channels/registry.js";
|
||||
import { getActivePluginChannelRegistry } from "../../plugins/runtime.js";
|
||||
import { normalizeAccountId } from "../../routing/session-key.js";
|
||||
import { resolveGlobalMap } from "../../shared/global-singleton.js";
|
||||
|
||||
@@ -157,6 +159,14 @@ const ADAPTERS_BY_CHANNEL_ACCOUNT = resolveGlobalMap<string, SessionBindingAdapt
|
||||
SESSION_BINDING_ADAPTERS_KEY,
|
||||
);
|
||||
|
||||
const GENERIC_SESSION_BINDINGS_KEY = Symbol.for("openclaw.sessionBinding.genericBindings");
|
||||
|
||||
const GENERIC_BINDINGS_BY_CONVERSATION = resolveGlobalMap<string, SessionBindingRecord>(
|
||||
GENERIC_SESSION_BINDINGS_KEY,
|
||||
);
|
||||
|
||||
const GENERIC_BINDING_ID_PREFIX = "generic:";
|
||||
|
||||
function getActiveAdapterForKey(key: string): SessionBindingAdapter | null {
|
||||
const registrations = ADAPTERS_BY_CHANNEL_ACCOUNT.get(key);
|
||||
return registrations?.[0]?.normalizedAdapter ?? null;
|
||||
@@ -229,6 +239,146 @@ function resolveAdapterForChannelAccount(params: {
|
||||
return getActiveAdapterForKey(key);
|
||||
}
|
||||
|
||||
function supportsGenericCurrentConversationBindings(params: {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
}): boolean {
|
||||
void params.accountId;
|
||||
return Boolean(
|
||||
normalizeChannelId(params.channel) ||
|
||||
normalizeAnyChannelId(params.channel) ||
|
||||
getActivePluginChannelRegistry()?.channels.some(
|
||||
(entry) => entry.plugin.id === params.channel.trim().toLowerCase(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function buildGenericConversationKey(ref: ConversationRef): string {
|
||||
const normalized = normalizeConversationRef(ref);
|
||||
return [
|
||||
normalized.channel,
|
||||
normalized.accountId,
|
||||
normalized.parentConversationId ?? "",
|
||||
normalized.conversationId,
|
||||
].join("\u241f");
|
||||
}
|
||||
|
||||
function buildGenericBindingId(ref: ConversationRef): string {
|
||||
return `${GENERIC_BINDING_ID_PREFIX}${buildGenericConversationKey(ref)}`;
|
||||
}
|
||||
|
||||
function isGenericBindingExpired(record: SessionBindingRecord, now = Date.now()): boolean {
|
||||
return typeof record.expiresAt === "number" && Number.isFinite(record.expiresAt)
|
||||
? record.expiresAt <= now
|
||||
: false;
|
||||
}
|
||||
|
||||
function pruneExpiredGenericBinding(key: string): SessionBindingRecord | null {
|
||||
const record = GENERIC_BINDINGS_BY_CONVERSATION.get(key) ?? null;
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
if (!isGenericBindingExpired(record)) {
|
||||
return record;
|
||||
}
|
||||
GENERIC_BINDINGS_BY_CONVERSATION.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
function bindGenericConversation(input: SessionBindingBindInput): SessionBindingRecord | null {
|
||||
const conversation = normalizeConversationRef(input.conversation);
|
||||
const targetSessionKey = input.targetSessionKey.trim();
|
||||
if (!conversation.channel || !conversation.conversationId || !targetSessionKey) {
|
||||
return null;
|
||||
}
|
||||
const now = Date.now();
|
||||
const key = buildGenericConversationKey(conversation);
|
||||
const existing = pruneExpiredGenericBinding(key);
|
||||
const ttlMs =
|
||||
typeof input.ttlMs === "number" && Number.isFinite(input.ttlMs)
|
||||
? Math.max(0, Math.floor(input.ttlMs))
|
||||
: undefined;
|
||||
const metadata = {
|
||||
...existing?.metadata,
|
||||
...input.metadata,
|
||||
lastActivityAt: now,
|
||||
};
|
||||
const record: SessionBindingRecord = {
|
||||
bindingId: buildGenericBindingId(conversation),
|
||||
targetSessionKey,
|
||||
targetKind: input.targetKind,
|
||||
conversation,
|
||||
status: "active",
|
||||
boundAt: now,
|
||||
...(ttlMs != null ? { expiresAt: now + ttlMs } : {}),
|
||||
metadata,
|
||||
};
|
||||
GENERIC_BINDINGS_BY_CONVERSATION.set(key, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
function listGenericBindingsBySession(targetSessionKey: string): SessionBindingRecord[] {
|
||||
const results: SessionBindingRecord[] = [];
|
||||
for (const key of GENERIC_BINDINGS_BY_CONVERSATION.keys()) {
|
||||
const active = pruneExpiredGenericBinding(key);
|
||||
if (!active || active.targetSessionKey !== targetSessionKey) {
|
||||
continue;
|
||||
}
|
||||
results.push(active);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function resolveGenericBindingByConversation(ref: ConversationRef): SessionBindingRecord | null {
|
||||
const key = buildGenericConversationKey(ref);
|
||||
return pruneExpiredGenericBinding(key);
|
||||
}
|
||||
|
||||
function touchGenericBinding(bindingId: string, at = Date.now()): void {
|
||||
if (!bindingId.startsWith(GENERIC_BINDING_ID_PREFIX)) {
|
||||
return;
|
||||
}
|
||||
const key = bindingId.slice(GENERIC_BINDING_ID_PREFIX.length);
|
||||
const record = pruneExpiredGenericBinding(key);
|
||||
if (!record) {
|
||||
return;
|
||||
}
|
||||
GENERIC_BINDINGS_BY_CONVERSATION.set(key, {
|
||||
...record,
|
||||
metadata: {
|
||||
...record.metadata,
|
||||
lastActivityAt: at,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function unbindGenericBindings(input: SessionBindingUnbindInput): SessionBindingRecord[] {
|
||||
const removed: SessionBindingRecord[] = [];
|
||||
const normalizedBindingId = input.bindingId?.trim();
|
||||
const normalizedTargetSessionKey = input.targetSessionKey?.trim();
|
||||
if (normalizedBindingId?.startsWith(GENERIC_BINDING_ID_PREFIX)) {
|
||||
const key = normalizedBindingId.slice(GENERIC_BINDING_ID_PREFIX.length);
|
||||
const record = pruneExpiredGenericBinding(key);
|
||||
if (record) {
|
||||
GENERIC_BINDINGS_BY_CONVERSATION.delete(key);
|
||||
removed.push(record);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
if (!normalizedTargetSessionKey) {
|
||||
return removed;
|
||||
}
|
||||
for (const key of GENERIC_BINDINGS_BY_CONVERSATION.keys()) {
|
||||
const active = pruneExpiredGenericBinding(key);
|
||||
if (!active || active.targetSessionKey !== normalizedTargetSessionKey) {
|
||||
continue;
|
||||
}
|
||||
GENERIC_BINDINGS_BY_CONVERSATION.delete(key);
|
||||
removed.push(active);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
function getActiveRegisteredAdapters(): SessionBindingAdapter[] {
|
||||
return [...ADAPTERS_BY_CHANNEL_ACCOUNT.values()]
|
||||
.map((registrations) => registrations[0]?.normalizedAdapter ?? null)
|
||||
@@ -252,6 +402,43 @@ function createDefaultSessionBindingService(): SessionBindingService {
|
||||
const normalizedConversation = normalizeConversationRef(input.conversation);
|
||||
const adapter = resolveAdapterForConversation(normalizedConversation);
|
||||
if (!adapter) {
|
||||
if (
|
||||
supportsGenericCurrentConversationBindings({
|
||||
channel: normalizedConversation.channel,
|
||||
accountId: normalizedConversation.accountId,
|
||||
})
|
||||
) {
|
||||
const placement =
|
||||
normalizePlacement(input.placement) ?? inferDefaultPlacement(normalizedConversation);
|
||||
if (placement !== "current") {
|
||||
throw new SessionBindingError(
|
||||
"BINDING_CAPABILITY_UNSUPPORTED",
|
||||
`Session binding placement "${placement}" is not supported for ${normalizedConversation.channel}:${normalizedConversation.accountId}`,
|
||||
{
|
||||
channel: normalizedConversation.channel,
|
||||
accountId: normalizedConversation.accountId,
|
||||
placement,
|
||||
},
|
||||
);
|
||||
}
|
||||
const bound = bindGenericConversation({
|
||||
...input,
|
||||
conversation: normalizedConversation,
|
||||
placement,
|
||||
});
|
||||
if (!bound) {
|
||||
throw new SessionBindingError(
|
||||
"BINDING_CREATE_FAILED",
|
||||
"Session binding adapter failed to bind target conversation",
|
||||
{
|
||||
channel: normalizedConversation.channel,
|
||||
accountId: normalizedConversation.accountId,
|
||||
placement,
|
||||
},
|
||||
);
|
||||
}
|
||||
return bound;
|
||||
}
|
||||
throw new SessionBindingError(
|
||||
"BINDING_ADAPTER_UNAVAILABLE",
|
||||
`Session binding adapter unavailable for ${normalizedConversation.channel}:${normalizedConversation.accountId}`,
|
||||
@@ -308,6 +495,14 @@ function createDefaultSessionBindingService(): SessionBindingService {
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
if (!adapter && supportsGenericCurrentConversationBindings(params)) {
|
||||
return {
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current"],
|
||||
};
|
||||
}
|
||||
return resolveAdapterCapabilities(adapter);
|
||||
},
|
||||
listBySession: (targetSessionKey) => {
|
||||
@@ -322,6 +517,7 @@ function createDefaultSessionBindingService(): SessionBindingService {
|
||||
results.push(...entries);
|
||||
}
|
||||
}
|
||||
results.push(...listGenericBindingsBySession(key));
|
||||
return dedupeBindings(results);
|
||||
},
|
||||
resolveByConversation: (ref) => {
|
||||
@@ -331,7 +527,7 @@ function createDefaultSessionBindingService(): SessionBindingService {
|
||||
}
|
||||
const adapter = resolveAdapterForConversation(normalized);
|
||||
if (!adapter) {
|
||||
return null;
|
||||
return resolveGenericBindingByConversation(normalized);
|
||||
}
|
||||
return adapter.resolveByConversation(normalized);
|
||||
},
|
||||
@@ -343,6 +539,7 @@ function createDefaultSessionBindingService(): SessionBindingService {
|
||||
for (const adapter of getActiveRegisteredAdapters()) {
|
||||
adapter.touch?.(normalizedBindingId, at);
|
||||
}
|
||||
touchGenericBinding(normalizedBindingId, at);
|
||||
},
|
||||
unbind: async (input) => {
|
||||
const removed: SessionBindingRecord[] = [];
|
||||
@@ -355,6 +552,7 @@ function createDefaultSessionBindingService(): SessionBindingService {
|
||||
removed.push(...entries);
|
||||
}
|
||||
}
|
||||
removed.push(...unbindGenericBindings(input));
|
||||
return dedupeBindings(removed);
|
||||
},
|
||||
};
|
||||
@@ -369,6 +567,7 @@ export function getSessionBindingService(): SessionBindingService {
|
||||
export const __testing = {
|
||||
resetSessionBindingAdaptersForTests() {
|
||||
ADAPTERS_BY_CHANNEL_ACCOUNT.clear();
|
||||
GENERIC_BINDINGS_BY_CONVERSATION.clear();
|
||||
},
|
||||
getRegisteredAdapterKeys() {
|
||||
return [...ADAPTERS_BY_CHANNEL_ACCOUNT.keys()];
|
||||
|
||||
Reference in New Issue
Block a user