openclaw/src/channels/plugins/configured-binding-compiler.ts
Bob ea15819ecf
ACP: harden startup and move configured routing behind plugin seams (#48197)
* ACPX: keep plugin-local runtime installs out of dist

* Gateway: harden ACP startup and service PATH

* ACP: reinitialize error-state configured bindings

* ACP: classify pre-turn runtime failures as session init failures

* Plugins: move configured ACP routing behind channel seams

* Telegram tests: align startup probe assertions after rebase

* Discord: harden ACP configured binding recovery

* ACP: recover Discord bindings after stale runtime exits

* ACPX: replace dead sessions during ensure

* Discord: harden ACP binding recovery

* Discord: fix review follow-ups

* ACP bindings: load channel snapshots across workspaces

* ACP bindings: cache snapshot channel plugin resolution

* Experiments: add ACP pluginification holy grail plan

* Experiments: rename ACP pluginification plan doc

* Experiments: drop old ACP pluginification doc path

* ACP: move configured bindings behind plugin services

* Experiments: update bindings capability architecture plan

* Bindings: isolate configured binding routing and targets

* Discord tests: fix runtime env helper path

* Tests: fix channel binding CI regressions

* Tests: normalize ACP workspace assertion on Windows

* Bindings: isolate configured binding registry

* Bindings: finish configured binding cleanup

* Bindings: finish generic cleanup

* Bindings: align runtime approval callbacks

* ACP: delete residual bindings barrel

* Bindings: restore legacy compatibility

* Revert "Bindings: restore legacy compatibility"

This reverts commit ac2ed68fa2426ecc874d68278c71c71ad363fcfe.

* Tests: drop ACP route legacy helper names

* Discord/ACP: fix binding regressions

---------

Co-authored-by: Onur <2453968+osolmaz@users.noreply.github.com>
2026-03-17 17:27:52 +01:00

241 lines
6.6 KiB
TypeScript

import { listConfiguredBindings } from "../../config/bindings.js";
import type { OpenClawConfig } from "../../config/config.js";
import { getActivePluginRegistry, getActivePluginRegistryVersion } from "../../plugins/runtime.js";
import { pickFirstExistingAgentId } from "../../routing/resolve-route.js";
import { resolveChannelConfiguredBindingProvider } from "./binding-provider.js";
import type { CompiledConfiguredBinding, ConfiguredBindingChannel } from "./binding-types.js";
import { resolveConfiguredBindingConsumer } from "./configured-binding-consumers.js";
import { getChannelPlugin } from "./index.js";
import type {
ChannelConfiguredBindingConversationRef,
ChannelConfiguredBindingProvider,
} from "./types.adapters.js";
// Configured bindings are channel-owned rules compiled from config, separate
// from runtime plugin-owned conversation bindings.
type ChannelPluginLike = NonNullable<ReturnType<typeof getChannelPlugin>>;
export type CompiledConfiguredBindingRegistry = {
rulesByChannel: Map<ConfiguredBindingChannel, CompiledConfiguredBinding[]>;
};
type CachedCompiledConfiguredBindingRegistry = {
registryVersion: number;
registry: CompiledConfiguredBindingRegistry;
};
const compiledRegistryCache = new WeakMap<
OpenClawConfig,
CachedCompiledConfiguredBindingRegistry
>();
function findChannelPlugin(params: {
registry:
| {
channels?: Array<{ plugin?: ChannelPluginLike | null } | null> | null;
}
| null
| undefined;
channel: string;
}): ChannelPluginLike | undefined {
return (
params.registry?.channels?.find((entry) => entry?.plugin?.id === params.channel)?.plugin ??
undefined
);
}
function resolveLoadedChannelPlugin(channel: string) {
const normalized = channel.trim().toLowerCase();
if (!normalized) {
return undefined;
}
const current = getChannelPlugin(normalized as ConfiguredBindingChannel);
if (current) {
return current;
}
return findChannelPlugin({
registry: getActivePluginRegistry(),
channel: normalized,
});
}
function resolveConfiguredBindingAdapter(channel: string): {
channel: ConfiguredBindingChannel;
provider: ChannelConfiguredBindingProvider;
} | null {
const normalized = channel.trim().toLowerCase();
if (!normalized) {
return null;
}
const plugin = resolveLoadedChannelPlugin(normalized);
const provider = resolveChannelConfiguredBindingProvider(plugin);
if (
!plugin ||
!provider ||
!provider.compileConfiguredBinding ||
!provider.matchInboundConversation
) {
return null;
}
return {
channel: plugin.id,
provider,
};
}
function resolveBindingConversationId(binding: {
match?: { peer?: { id?: string } };
}): string | null {
const id = binding.match?.peer?.id?.trim();
return id ? id : null;
}
function compileConfiguredBindingTarget(params: {
provider: ChannelConfiguredBindingProvider;
binding: CompiledConfiguredBinding["binding"];
conversationId: string;
}): ChannelConfiguredBindingConversationRef | null {
return params.provider.compileConfiguredBinding({
binding: params.binding,
conversationId: params.conversationId,
});
}
function compileConfiguredBindingRule(params: {
cfg: OpenClawConfig;
channel: ConfiguredBindingChannel;
binding: CompiledConfiguredBinding["binding"];
target: ChannelConfiguredBindingConversationRef;
bindingConversationId: string;
provider: ChannelConfiguredBindingProvider;
}): CompiledConfiguredBinding | null {
const agentId = pickFirstExistingAgentId(params.cfg, params.binding.agentId ?? "main");
const consumer = resolveConfiguredBindingConsumer(params.binding);
if (!consumer) {
return null;
}
const targetFactory = consumer.buildTargetFactory({
cfg: params.cfg,
binding: params.binding,
channel: params.channel,
agentId,
target: params.target,
bindingConversationId: params.bindingConversationId,
});
if (!targetFactory) {
return null;
}
return {
channel: params.channel,
accountPattern: params.binding.match.accountId?.trim() || undefined,
binding: params.binding,
bindingConversationId: params.bindingConversationId,
target: params.target,
agentId,
provider: params.provider,
targetFactory,
};
}
function pushCompiledRule(
target: Map<ConfiguredBindingChannel, CompiledConfiguredBinding[]>,
rule: CompiledConfiguredBinding,
) {
const existing = target.get(rule.channel);
if (existing) {
existing.push(rule);
return;
}
target.set(rule.channel, [rule]);
}
function compileConfiguredBindingRegistry(params: {
cfg: OpenClawConfig;
}): CompiledConfiguredBindingRegistry {
const rulesByChannel = new Map<ConfiguredBindingChannel, CompiledConfiguredBinding[]>();
for (const binding of listConfiguredBindings(params.cfg)) {
const bindingConversationId = resolveBindingConversationId(binding);
if (!bindingConversationId) {
continue;
}
const resolvedChannel = resolveConfiguredBindingAdapter(binding.match.channel);
if (!resolvedChannel) {
continue;
}
const target = compileConfiguredBindingTarget({
provider: resolvedChannel.provider,
binding,
conversationId: bindingConversationId,
});
if (!target) {
continue;
}
const rule = compileConfiguredBindingRule({
cfg: params.cfg,
channel: resolvedChannel.channel,
binding,
target,
bindingConversationId,
provider: resolvedChannel.provider,
});
if (!rule) {
continue;
}
pushCompiledRule(rulesByChannel, rule);
}
return {
rulesByChannel,
};
}
export function resolveCompiledBindingRegistry(
cfg: OpenClawConfig,
): CompiledConfiguredBindingRegistry {
const registryVersion = getActivePluginRegistryVersion();
const cached = compiledRegistryCache.get(cfg);
if (cached?.registryVersion === registryVersion) {
return cached.registry;
}
const registry = compileConfiguredBindingRegistry({
cfg,
});
compiledRegistryCache.set(cfg, {
registryVersion,
registry,
});
return registry;
}
export function primeCompiledBindingRegistry(
cfg: OpenClawConfig,
): CompiledConfiguredBindingRegistry {
const registry = compileConfiguredBindingRegistry({ cfg });
compiledRegistryCache.set(cfg, {
registryVersion: getActivePluginRegistryVersion(),
registry,
});
return registry;
}
export function countCompiledBindingRegistry(registry: CompiledConfiguredBindingRegistry): {
bindingCount: number;
channelCount: number;
} {
return {
bindingCount: [...registry.rulesByChannel.values()].reduce(
(sum, rules) => sum + rules.length,
0,
),
channelCount: registry.rulesByChannel.size,
};
}