* docs: add ACP persistent binding experiment plan * docs: align ACP persistent binding spec to channel-local config * docs: scope Telegram ACP bindings to forum topics only * docs: lock bound /new and /reset behavior to in-place ACP reset * ACP: add persistent discord/telegram conversation bindings * ACP: fix persistent binding reuse and discord thread parent context * docs: document channel-specific persistent ACP bindings * ACP: split persistent bindings and share conversation id helpers * ACP: defer configured binding init until preflight passes * ACP: fix discord thread parent fallback and explicit disable inheritance * ACP: keep bound /new and /reset in-place * ACP: honor configured bindings in native command flows * ACP: avoid configured fallback after runtime bind failure * docs: refine ACP bindings experiment config examples * acp: cut over to typed top-level persistent bindings * ACP bindings: harden reset recovery and native command auth * Docs: add ACP bound command auth proposal * Tests: normalize i18n registry zh-CN assertion encoding * ACP bindings: address review findings for reset and fallback routing * ACP reset: gate hooks on success and preserve /new arguments * ACP bindings: fix auth and binding-priority review findings * Telegram ACP: gate ensure on auth and accepted messages * ACP bindings: fix session-key precedence and unavailable handling * ACP reset/native commands: honor fallback targets and abort on bootstrap failure * Config schema: validate ACP binding channel and Telegram topic IDs * Discord ACP: apply configured DM bindings to native commands * ACP reset tails: dispatch through ACP after command handling * ACP tails/native reset auth: fix target dispatch and restore full auth * ACP reset detection: fallback to active ACP keys for DM contexts * Tests: type runTurn mock input in ACP dispatch test * ACP: dedup binding route bootstrap and reset target resolution * reply: align ACP reset hooks with bound session key * docs: replace personal discord ids with placeholders * fix: add changelog entry for ACP persistent bindings (#34873) (thanks @dutifulbob) --------- Co-authored-by: Onur <2453968+osolmaz@users.noreply.github.com>
342 lines
10 KiB
TypeScript
342 lines
10 KiB
TypeScript
import { listAcpBindings } from "../config/bindings.js";
|
|
import type { OpenClawConfig } from "../config/config.js";
|
|
import type { AgentAcpBinding } from "../config/types.js";
|
|
import { pickFirstExistingAgentId } from "../routing/resolve-route.js";
|
|
import {
|
|
DEFAULT_ACCOUNT_ID,
|
|
normalizeAccountId,
|
|
parseAgentSessionKey,
|
|
} from "../routing/session-key.js";
|
|
import { parseTelegramTopicConversation } from "./conversation-id.js";
|
|
import {
|
|
buildConfiguredAcpSessionKey,
|
|
normalizeBindingConfig,
|
|
normalizeMode,
|
|
normalizeText,
|
|
toConfiguredAcpBindingRecord,
|
|
type ConfiguredAcpBindingChannel,
|
|
type ConfiguredAcpBindingSpec,
|
|
type ResolvedConfiguredAcpBinding,
|
|
} from "./persistent-bindings.types.js";
|
|
|
|
function normalizeBindingChannel(value: string | undefined): ConfiguredAcpBindingChannel | null {
|
|
const normalized = (value ?? "").trim().toLowerCase();
|
|
if (normalized === "discord" || normalized === "telegram") {
|
|
return normalized;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolveAccountMatchPriority(match: string | undefined, actual: string): 0 | 1 | 2 {
|
|
const trimmed = (match ?? "").trim();
|
|
if (!trimmed) {
|
|
return actual === DEFAULT_ACCOUNT_ID ? 2 : 0;
|
|
}
|
|
if (trimmed === "*") {
|
|
return 1;
|
|
}
|
|
return normalizeAccountId(trimmed) === actual ? 2 : 0;
|
|
}
|
|
|
|
function resolveBindingConversationId(binding: AgentAcpBinding): string | null {
|
|
const id = binding.match.peer?.id?.trim();
|
|
return id ? id : null;
|
|
}
|
|
|
|
function parseConfiguredBindingSessionKey(params: {
|
|
sessionKey: string;
|
|
}): { channel: ConfiguredAcpBindingChannel; accountId: string } | null {
|
|
const parsed = parseAgentSessionKey(params.sessionKey);
|
|
const rest = parsed?.rest?.trim().toLowerCase() ?? "";
|
|
if (!rest) {
|
|
return null;
|
|
}
|
|
const tokens = rest.split(":");
|
|
if (tokens.length !== 5 || tokens[0] !== "acp" || tokens[1] !== "binding") {
|
|
return null;
|
|
}
|
|
const channel = normalizeBindingChannel(tokens[2]);
|
|
if (!channel) {
|
|
return null;
|
|
}
|
|
const accountId = normalizeAccountId(tokens[3]);
|
|
return {
|
|
channel,
|
|
accountId,
|
|
};
|
|
}
|
|
|
|
function resolveAgentRuntimeAcpDefaults(params: { cfg: OpenClawConfig; ownerAgentId: string }): {
|
|
acpAgentId?: string;
|
|
mode?: string;
|
|
cwd?: string;
|
|
backend?: string;
|
|
} {
|
|
const agent = params.cfg.agents?.list?.find(
|
|
(entry) => entry.id?.trim().toLowerCase() === params.ownerAgentId.toLowerCase(),
|
|
);
|
|
if (!agent || agent.runtime?.type !== "acp") {
|
|
return {};
|
|
}
|
|
return {
|
|
acpAgentId: normalizeText(agent.runtime.acp?.agent),
|
|
mode: normalizeText(agent.runtime.acp?.mode),
|
|
cwd: normalizeText(agent.runtime.acp?.cwd),
|
|
backend: normalizeText(agent.runtime.acp?.backend),
|
|
};
|
|
}
|
|
|
|
function toConfiguredBindingSpec(params: {
|
|
cfg: OpenClawConfig;
|
|
channel: ConfiguredAcpBindingChannel;
|
|
accountId: string;
|
|
conversationId: string;
|
|
parentConversationId?: string;
|
|
binding: AgentAcpBinding;
|
|
}): ConfiguredAcpBindingSpec {
|
|
const accountId = normalizeAccountId(params.accountId);
|
|
const agentId = pickFirstExistingAgentId(params.cfg, params.binding.agentId ?? "main");
|
|
const runtimeDefaults = resolveAgentRuntimeAcpDefaults({
|
|
cfg: params.cfg,
|
|
ownerAgentId: agentId,
|
|
});
|
|
const bindingOverrides = normalizeBindingConfig(params.binding.acp);
|
|
const acpAgentId = normalizeText(runtimeDefaults.acpAgentId);
|
|
const mode = normalizeMode(bindingOverrides.mode ?? runtimeDefaults.mode);
|
|
return {
|
|
channel: params.channel,
|
|
accountId,
|
|
conversationId: params.conversationId,
|
|
parentConversationId: params.parentConversationId,
|
|
agentId,
|
|
acpAgentId,
|
|
mode,
|
|
cwd: bindingOverrides.cwd ?? runtimeDefaults.cwd,
|
|
backend: bindingOverrides.backend ?? runtimeDefaults.backend,
|
|
label: bindingOverrides.label,
|
|
};
|
|
}
|
|
|
|
export function resolveConfiguredAcpBindingSpecBySessionKey(params: {
|
|
cfg: OpenClawConfig;
|
|
sessionKey: string;
|
|
}): ConfiguredAcpBindingSpec | null {
|
|
const sessionKey = params.sessionKey.trim();
|
|
if (!sessionKey) {
|
|
return null;
|
|
}
|
|
const parsedSessionKey = parseConfiguredBindingSessionKey({ sessionKey });
|
|
if (!parsedSessionKey) {
|
|
return null;
|
|
}
|
|
let wildcardMatch: ConfiguredAcpBindingSpec | null = null;
|
|
for (const binding of listAcpBindings(params.cfg)) {
|
|
const channel = normalizeBindingChannel(binding.match.channel);
|
|
if (!channel || channel !== parsedSessionKey.channel) {
|
|
continue;
|
|
}
|
|
const accountMatchPriority = resolveAccountMatchPriority(
|
|
binding.match.accountId,
|
|
parsedSessionKey.accountId,
|
|
);
|
|
if (accountMatchPriority === 0) {
|
|
continue;
|
|
}
|
|
const targetConversationId = resolveBindingConversationId(binding);
|
|
if (!targetConversationId) {
|
|
continue;
|
|
}
|
|
if (channel === "discord") {
|
|
const spec = toConfiguredBindingSpec({
|
|
cfg: params.cfg,
|
|
channel: "discord",
|
|
accountId: parsedSessionKey.accountId,
|
|
conversationId: targetConversationId,
|
|
binding,
|
|
});
|
|
if (buildConfiguredAcpSessionKey(spec) === sessionKey) {
|
|
if (accountMatchPriority === 2) {
|
|
return spec;
|
|
}
|
|
if (!wildcardMatch) {
|
|
wildcardMatch = spec;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
const parsedTopic = parseTelegramTopicConversation({
|
|
conversationId: targetConversationId,
|
|
});
|
|
if (!parsedTopic || !parsedTopic.chatId.startsWith("-")) {
|
|
continue;
|
|
}
|
|
const spec = toConfiguredBindingSpec({
|
|
cfg: params.cfg,
|
|
channel: "telegram",
|
|
accountId: parsedSessionKey.accountId,
|
|
conversationId: parsedTopic.canonicalConversationId,
|
|
parentConversationId: parsedTopic.chatId,
|
|
binding,
|
|
});
|
|
if (buildConfiguredAcpSessionKey(spec) === sessionKey) {
|
|
if (accountMatchPriority === 2) {
|
|
return spec;
|
|
}
|
|
if (!wildcardMatch) {
|
|
wildcardMatch = spec;
|
|
}
|
|
}
|
|
}
|
|
return wildcardMatch;
|
|
}
|
|
|
|
export function resolveConfiguredAcpBindingRecord(params: {
|
|
cfg: OpenClawConfig;
|
|
channel: string;
|
|
accountId: string;
|
|
conversationId: string;
|
|
parentConversationId?: string;
|
|
}): ResolvedConfiguredAcpBinding | null {
|
|
const channel = params.channel.trim().toLowerCase();
|
|
const accountId = normalizeAccountId(params.accountId);
|
|
const conversationId = params.conversationId.trim();
|
|
const parentConversationId = params.parentConversationId?.trim() || undefined;
|
|
if (!conversationId) {
|
|
return null;
|
|
}
|
|
|
|
if (channel === "discord") {
|
|
const bindings = listAcpBindings(params.cfg);
|
|
const resolveDiscordBindingForConversation = (
|
|
targetConversationId: string,
|
|
): ResolvedConfiguredAcpBinding | null => {
|
|
let wildcardMatch: AgentAcpBinding | null = null;
|
|
for (const binding of bindings) {
|
|
if (normalizeBindingChannel(binding.match.channel) !== "discord") {
|
|
continue;
|
|
}
|
|
const accountMatchPriority = resolveAccountMatchPriority(
|
|
binding.match.accountId,
|
|
accountId,
|
|
);
|
|
if (accountMatchPriority === 0) {
|
|
continue;
|
|
}
|
|
const bindingConversationId = resolveBindingConversationId(binding);
|
|
if (!bindingConversationId || bindingConversationId !== targetConversationId) {
|
|
continue;
|
|
}
|
|
if (accountMatchPriority === 2) {
|
|
const spec = toConfiguredBindingSpec({
|
|
cfg: params.cfg,
|
|
channel: "discord",
|
|
accountId,
|
|
conversationId: targetConversationId,
|
|
binding,
|
|
});
|
|
return {
|
|
spec,
|
|
record: toConfiguredAcpBindingRecord(spec),
|
|
};
|
|
}
|
|
if (!wildcardMatch) {
|
|
wildcardMatch = binding;
|
|
}
|
|
}
|
|
if (wildcardMatch) {
|
|
const spec = toConfiguredBindingSpec({
|
|
cfg: params.cfg,
|
|
channel: "discord",
|
|
accountId,
|
|
conversationId: targetConversationId,
|
|
binding: wildcardMatch,
|
|
});
|
|
return {
|
|
spec,
|
|
record: toConfiguredAcpBindingRecord(spec),
|
|
};
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const directMatch = resolveDiscordBindingForConversation(conversationId);
|
|
if (directMatch) {
|
|
return directMatch;
|
|
}
|
|
if (parentConversationId && parentConversationId !== conversationId) {
|
|
const inheritedMatch = resolveDiscordBindingForConversation(parentConversationId);
|
|
if (inheritedMatch) {
|
|
return inheritedMatch;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
if (channel === "telegram") {
|
|
const parsed = parseTelegramTopicConversation({
|
|
conversationId,
|
|
parentConversationId,
|
|
});
|
|
if (!parsed || !parsed.chatId.startsWith("-")) {
|
|
return null;
|
|
}
|
|
let wildcardMatch: AgentAcpBinding | null = null;
|
|
for (const binding of listAcpBindings(params.cfg)) {
|
|
if (normalizeBindingChannel(binding.match.channel) !== "telegram") {
|
|
continue;
|
|
}
|
|
const accountMatchPriority = resolveAccountMatchPriority(binding.match.accountId, accountId);
|
|
if (accountMatchPriority === 0) {
|
|
continue;
|
|
}
|
|
const targetConversationId = resolveBindingConversationId(binding);
|
|
if (!targetConversationId) {
|
|
continue;
|
|
}
|
|
const targetParsed = parseTelegramTopicConversation({
|
|
conversationId: targetConversationId,
|
|
});
|
|
if (!targetParsed || !targetParsed.chatId.startsWith("-")) {
|
|
continue;
|
|
}
|
|
if (targetParsed.canonicalConversationId !== parsed.canonicalConversationId) {
|
|
continue;
|
|
}
|
|
if (accountMatchPriority === 2) {
|
|
const spec = toConfiguredBindingSpec({
|
|
cfg: params.cfg,
|
|
channel: "telegram",
|
|
accountId,
|
|
conversationId: parsed.canonicalConversationId,
|
|
parentConversationId: parsed.chatId,
|
|
binding,
|
|
});
|
|
return {
|
|
spec,
|
|
record: toConfiguredAcpBindingRecord(spec),
|
|
};
|
|
}
|
|
if (!wildcardMatch) {
|
|
wildcardMatch = binding;
|
|
}
|
|
}
|
|
if (wildcardMatch) {
|
|
const spec = toConfiguredBindingSpec({
|
|
cfg: params.cfg,
|
|
channel: "telegram",
|
|
accountId,
|
|
conversationId: parsed.canonicalConversationId,
|
|
parentConversationId: parsed.chatId,
|
|
binding: wildcardMatch,
|
|
});
|
|
return {
|
|
spec,
|
|
record: toConfiguredAcpBindingRecord(spec),
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
return null;
|
|
}
|