diff --git a/docs/tools/acp-agents.md b/docs/tools/acp-agents.md index 23b9f7b388f..5ee091e6125 100644 --- a/docs/tools/acp-agents.md +++ b/docs/tools/acp-agents.md @@ -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: diff --git a/src/auto-reply/reply/commands-acp/context.ts b/src/auto-reply/reply/commands-acp/context.ts index fba9cd12bb3..ca99f96a23f 100644 --- a/src/auto-reply/reply/commands-acp/context.ts +++ b/src/auto-reply/reply/commands-acp/context.ts @@ -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 } : {}), }; } diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 3b4f238939d..afa48f46fc7 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -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 } diff --git a/src/channels/conversation-binding-context.ts b/src/channels/conversation-binding-context.ts new file mode 100644 index 00000000000..2c2110fb6ae --- /dev/null +++ b/src/channels/conversation-binding-context.ts @@ -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 } : {}), + }; +} diff --git a/src/infra/outbound/session-binding-service.ts b/src/infra/outbound/session-binding-service.ts index 4aab3fd0e7c..c1dd123bd17 100644 --- a/src/infra/outbound/session-binding-service.ts +++ b/src/infra/outbound/session-binding-service.ts @@ -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( + 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()];