mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 15:44:31 +00:00
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:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user