ACP: add persistent Discord channel and Telegram topic bindings (#34873)

* 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>
This commit is contained in:
Bob
2026-03-05 09:38:12 +01:00
committed by GitHub
parent 2c8ee593b9
commit 6a705a37f2
50 changed files with 4830 additions and 186 deletions

View File

@@ -1,18 +1,19 @@
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js";
import type { ChannelId } from "../channels/plugins/types.js";
import { isRouteBinding, listRouteBindings } from "../config/bindings.js";
import type { OpenClawConfig } from "../config/config.js";
import type { AgentBinding } from "../config/types.js";
import type { AgentRouteBinding } from "../config/types.js";
import { DEFAULT_ACCOUNT_ID, normalizeAgentId } from "../routing/session-key.js";
import type { ChannelChoice } from "./onboard-types.js";
function bindingMatchKey(match: AgentBinding["match"]) {
function bindingMatchKey(match: AgentRouteBinding["match"]) {
const accountId = match.accountId?.trim() || DEFAULT_ACCOUNT_ID;
const identityKey = bindingMatchIdentityKey(match);
return [identityKey, accountId].join("|");
}
function bindingMatchIdentityKey(match: AgentBinding["match"]) {
function bindingMatchIdentityKey(match: AgentRouteBinding["match"]) {
const roles = Array.isArray(match.roles)
? Array.from(
new Set(
@@ -34,8 +35,8 @@ function bindingMatchIdentityKey(match: AgentBinding["match"]) {
}
function canUpgradeBindingAccountScope(params: {
existing: AgentBinding;
incoming: AgentBinding;
existing: AgentRouteBinding;
incoming: AgentRouteBinding;
normalizedIncomingAgentId: string;
}): boolean {
if (!params.incoming.match.accountId?.trim()) {
@@ -53,7 +54,7 @@ function canUpgradeBindingAccountScope(params: {
);
}
export function describeBinding(binding: AgentBinding) {
export function describeBinding(binding: AgentRouteBinding) {
const match = binding.match;
const parts = [match.channel];
if (match.accountId) {
@@ -73,27 +74,28 @@ export function describeBinding(binding: AgentBinding) {
export function applyAgentBindings(
cfg: OpenClawConfig,
bindings: AgentBinding[],
bindings: AgentRouteBinding[],
): {
config: OpenClawConfig;
added: AgentBinding[];
updated: AgentBinding[];
skipped: AgentBinding[];
conflicts: Array<{ binding: AgentBinding; existingAgentId: string }>;
added: AgentRouteBinding[];
updated: AgentRouteBinding[];
skipped: AgentRouteBinding[];
conflicts: Array<{ binding: AgentRouteBinding; existingAgentId: string }>;
} {
const existing = [...(cfg.bindings ?? [])];
const existingRoutes = [...listRouteBindings(cfg)];
const nonRouteBindings = (cfg.bindings ?? []).filter((binding) => !isRouteBinding(binding));
const existingMatchMap = new Map<string, string>();
for (const binding of existing) {
for (const binding of existingRoutes) {
const key = bindingMatchKey(binding.match);
if (!existingMatchMap.has(key)) {
existingMatchMap.set(key, normalizeAgentId(binding.agentId));
}
}
const added: AgentBinding[] = [];
const updated: AgentBinding[] = [];
const skipped: AgentBinding[] = [];
const conflicts: Array<{ binding: AgentBinding; existingAgentId: string }> = [];
const added: AgentRouteBinding[] = [];
const updated: AgentRouteBinding[] = [];
const skipped: AgentRouteBinding[] = [];
const conflicts: Array<{ binding: AgentRouteBinding; existingAgentId: string }> = [];
for (const binding of bindings) {
const agentId = normalizeAgentId(binding.agentId);
@@ -108,7 +110,7 @@ export function applyAgentBindings(
continue;
}
const upgradeIndex = existing.findIndex((candidate) =>
const upgradeIndex = existingRoutes.findIndex((candidate) =>
canUpgradeBindingAccountScope({
existing: candidate,
incoming: binding,
@@ -116,12 +118,12 @@ export function applyAgentBindings(
}),
);
if (upgradeIndex >= 0) {
const current = existing[upgradeIndex];
const current = existingRoutes[upgradeIndex];
if (!current) {
continue;
}
const previousKey = bindingMatchKey(current.match);
const upgradedBinding: AgentBinding = {
const upgradedBinding: AgentRouteBinding = {
...current,
agentId,
match: {
@@ -129,7 +131,7 @@ export function applyAgentBindings(
accountId: binding.match.accountId?.trim(),
},
};
existing[upgradeIndex] = upgradedBinding;
existingRoutes[upgradeIndex] = upgradedBinding;
existingMatchMap.delete(previousKey);
existingMatchMap.set(bindingMatchKey(upgradedBinding.match), agentId);
updated.push(upgradedBinding);
@@ -147,7 +149,7 @@ export function applyAgentBindings(
return {
config: {
...cfg,
bindings: [...existing, ...added],
bindings: [...existingRoutes, ...added, ...nonRouteBindings],
},
added,
updated,
@@ -158,29 +160,30 @@ export function applyAgentBindings(
export function removeAgentBindings(
cfg: OpenClawConfig,
bindings: AgentBinding[],
bindings: AgentRouteBinding[],
): {
config: OpenClawConfig;
removed: AgentBinding[];
missing: AgentBinding[];
conflicts: Array<{ binding: AgentBinding; existingAgentId: string }>;
removed: AgentRouteBinding[];
missing: AgentRouteBinding[];
conflicts: Array<{ binding: AgentRouteBinding; existingAgentId: string }>;
} {
const existing = cfg.bindings ?? [];
const existingRoutes = listRouteBindings(cfg);
const nonRouteBindings = (cfg.bindings ?? []).filter((binding) => !isRouteBinding(binding));
const removeIndexes = new Set<number>();
const removed: AgentBinding[] = [];
const missing: AgentBinding[] = [];
const conflicts: Array<{ binding: AgentBinding; existingAgentId: string }> = [];
const removed: AgentRouteBinding[] = [];
const missing: AgentRouteBinding[] = [];
const conflicts: Array<{ binding: AgentRouteBinding; existingAgentId: string }> = [];
for (const binding of bindings) {
const desiredAgentId = normalizeAgentId(binding.agentId);
const key = bindingMatchKey(binding.match);
let matchedIndex = -1;
let conflictingAgentId: string | null = null;
for (let i = 0; i < existing.length; i += 1) {
for (let i = 0; i < existingRoutes.length; i += 1) {
if (removeIndexes.has(i)) {
continue;
}
const current = existing[i];
const current = existingRoutes[i];
if (!current || bindingMatchKey(current.match) !== key) {
continue;
}
@@ -192,7 +195,7 @@ export function removeAgentBindings(
conflictingAgentId = currentAgentId;
}
if (matchedIndex >= 0) {
const matched = existing[matchedIndex];
const matched = existingRoutes[matchedIndex];
if (matched) {
removeIndexes.add(matchedIndex);
removed.push(matched);
@@ -210,7 +213,8 @@ export function removeAgentBindings(
return { config: cfg, removed, missing, conflicts };
}
const nextBindings = existing.filter((_, index) => !removeIndexes.has(index));
const nextRouteBindings = existingRoutes.filter((_, index) => !removeIndexes.has(index));
const nextBindings = [...nextRouteBindings, ...nonRouteBindings];
return {
config: {
...cfg,
@@ -262,11 +266,11 @@ export function buildChannelBindings(params: {
selection: ChannelChoice[];
config: OpenClawConfig;
accountIds?: Partial<Record<ChannelChoice, string>>;
}): AgentBinding[] {
const bindings: AgentBinding[] = [];
}): AgentRouteBinding[] {
const bindings: AgentRouteBinding[] = [];
const agentId = normalizeAgentId(params.agentId);
for (const channel of params.selection) {
const match: AgentBinding["match"] = { channel };
const match: AgentRouteBinding["match"] = { channel };
const accountId = resolveBindingAccountId({
channel,
config: params.config,
@@ -276,7 +280,7 @@ export function buildChannelBindings(params: {
if (accountId) {
match.accountId = accountId;
}
bindings.push({ agentId, match });
bindings.push({ type: "route", agentId, match });
}
return bindings;
}
@@ -285,8 +289,8 @@ export function parseBindingSpecs(params: {
agentId: string;
specs?: string[];
config: OpenClawConfig;
}): { bindings: AgentBinding[]; errors: string[] } {
const bindings: AgentBinding[] = [];
}): { bindings: AgentRouteBinding[]; errors: string[] } {
const bindings: AgentRouteBinding[] = [];
const errors: string[] = [];
const specs = params.specs ?? [];
const agentId = normalizeAgentId(params.agentId);
@@ -312,11 +316,11 @@ export function parseBindingSpecs(params: {
agentId,
explicitAccountId: accountId,
});
const match: AgentBinding["match"] = { channel };
const match: AgentRouteBinding["match"] = { channel };
if (accountId) {
match.accountId = accountId;
}
bindings.push({ agentId, match });
bindings.push({ type: "route", agentId, match });
}
return { bindings, errors };
}

View File

@@ -1,7 +1,8 @@
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { isRouteBinding, listRouteBindings } from "../config/bindings.js";
import { writeConfigFile } from "../config/config.js";
import { logConfigUpdated } from "../config/logging.js";
import type { AgentBinding } from "../config/types.js";
import type { AgentRouteBinding } from "../config/types.js";
import { normalizeAgentId } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
@@ -56,7 +57,7 @@ function hasAgent(cfg: Awaited<ReturnType<typeof requireValidConfig>>, agentId:
return buildAgentSummaries(cfg).some((summary) => summary.id === agentId);
}
function formatBindingOwnerLine(binding: AgentBinding): string {
function formatBindingOwnerLine(binding: AgentRouteBinding): string {
return `${normalizeAgentId(binding.agentId)} <- ${describeBinding(binding)}`;
}
@@ -82,7 +83,7 @@ function resolveTargetAgentIdOrExit(params: {
}
function formatBindingConflicts(
conflicts: Array<{ binding: AgentBinding; existingAgentId: string }>,
conflicts: Array<{ binding: AgentRouteBinding; existingAgentId: string }>,
): string[] {
return conflicts.map(
(conflict) => `${describeBinding(conflict.binding)} (agent=${conflict.existingAgentId})`,
@@ -171,7 +172,7 @@ export async function agentsBindingsCommand(
return;
}
const filtered = (cfg.bindings ?? []).filter(
const filtered = listRouteBindings(cfg).filter(
(binding) => !filterAgentId || normalizeAgentId(binding.agentId) === filterAgentId,
);
if (opts.json) {
@@ -300,16 +301,18 @@ export async function agentsUnbindCommand(
}
if (opts.all) {
const existing = cfg.bindings ?? [];
const existing = listRouteBindings(cfg);
const removed = existing.filter((binding) => normalizeAgentId(binding.agentId) === agentId);
const kept = existing.filter((binding) => normalizeAgentId(binding.agentId) !== agentId);
const keptRoutes = existing.filter((binding) => normalizeAgentId(binding.agentId) !== agentId);
const nonRoutes = (cfg.bindings ?? []).filter((binding) => !isRouteBinding(binding));
if (removed.length === 0) {
runtime.log(`No bindings to remove for agent "${agentId}".`);
return;
}
const next = {
...cfg,
bindings: kept.length > 0 ? kept : undefined,
bindings:
[...keptRoutes, ...nonRoutes].length > 0 ? [...keptRoutes, ...nonRoutes] : undefined,
};
await writeConfigFile(next);
if (!opts.json) {

View File

@@ -1,5 +1,6 @@
import { formatCliCommand } from "../cli/command-format.js";
import type { AgentBinding } from "../config/types.js";
import { listRouteBindings } from "../config/bindings.js";
import type { AgentRouteBinding } from "../config/types.js";
import { normalizeAgentId } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
@@ -81,8 +82,8 @@ export async function agentsListCommand(
}
const summaries = buildAgentSummaries(cfg);
const bindingMap = new Map<string, AgentBinding[]>();
for (const binding of cfg.bindings ?? []) {
const bindingMap = new Map<string, AgentRouteBinding[]>();
for (const binding of listRouteBindings(cfg)) {
const agentId = normalizeAgentId(binding.agentId);
const list = bindingMap.get(agentId) ?? [];
list.push(binding);

View File

@@ -10,6 +10,7 @@ import {
loadAgentIdentityFromWorkspace,
parseIdentityMarkdown as parseIdentityMarkdownFile,
} from "../agents/identity-file.js";
import { listRouteBindings } from "../config/bindings.js";
import type { OpenClawConfig } from "../config/config.js";
import { normalizeAgentId } from "../routing/session-key.js";
@@ -88,7 +89,7 @@ export function buildAgentSummaries(cfg: OpenClawConfig): AgentSummary[] {
? configuredAgents.map((agent) => normalizeAgentId(agent.id))
: [defaultAgentId];
const bindingCounts = new Map<string, number>();
for (const binding of cfg.bindings ?? []) {
for (const binding of listRouteBindings(cfg)) {
const agentId = normalizeAgentId(binding.agentId);
bindingCounts.set(agentId, (bindingCounts.get(agentId) ?? 0) + 1);
}

View File

@@ -8,6 +8,7 @@ import {
} from "../channels/telegram/allow-from.js";
import { fetchTelegramChatId } from "../channels/telegram/api.js";
import { formatCliCommand } from "../cli/command-format.js";
import { listRouteBindings } from "../config/bindings.js";
import type { OpenClawConfig } from "../config/config.js";
import { CONFIG_PATH, migrateLegacyConfig, readConfigFileSnapshot } from "../config/config.js";
import { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-name-matching.js";
@@ -265,7 +266,7 @@ function collectChannelsMissingDefaultAccount(
}
export function collectMissingDefaultAccountBindingWarnings(cfg: OpenClawConfig): string[] {
const bindings = Array.isArray(cfg.bindings) ? cfg.bindings : [];
const bindings = listRouteBindings(cfg);
const warnings: string[] = [];
for (const { channelKey, normalizedAccountIds } of collectChannelsMissingDefaultAccount(cfg)) {