fix(binding): allow one agent to bind multiple channels while preserving per-channel account replacement (#541)

This commit is contained in:
Felix
2026-03-17 11:19:24 +08:00
committed by GitHub
Unverified
parent ca1a325993
commit 43fe7a4d1c
2 changed files with 104 additions and 27 deletions

View File

@@ -297,30 +297,6 @@ function upsertBindingsForChannel(
return nextBindings.length > 0 ? nextBindings : undefined; return nextBindings.length > 0 ? nextBindings : undefined;
} }
function assertAgentNotBoundToOtherChannel(
bindings: unknown,
agentId: string,
nextChannelType: string,
): void {
if (!Array.isArray(bindings)) return;
const normalizedAgentId = normalizeAgentIdForBinding(agentId);
if (!normalizedAgentId) return;
const conflictChannels = new Set<string>();
for (const binding of bindings) {
if (!isChannelBinding(binding)) continue;
if (normalizeAgentIdForBinding(binding.agentId || '') !== normalizedAgentId) continue;
const boundChannel = binding.match?.channel;
if (!boundChannel || boundChannel === nextChannelType) continue;
conflictChannels.add(boundChannel);
}
if (conflictChannels.size > 0) {
const channels = Array.from(conflictChannels).sort().join(', ');
throw new Error(`Agent "${agentId}" is already bound to channel(s): ${channels}. One agent can only bind one channel.`);
}
}
async function listExistingAgentIdsOnDisk(): Promise<Set<string>> { async function listExistingAgentIdsOnDisk(): Promise<Set<string>> {
const ids = new Set<string>(); const ids = new Set<string>();
const agentsDir = join(getOpenClawConfigDir(), 'agents'); const agentsDir = join(getOpenClawConfigDir(), 'agents');
@@ -477,7 +453,7 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument): Promise<Age
accountToAgent.get(`${channelType}:${accountId}`) accountToAgent.get(`${channelType}:${accountId}`)
|| ( || (
accountId === DEFAULT_ACCOUNT_ID && !hasExplicitAccountBindingForChannel accountId === DEFAULT_ACCOUNT_ID && !hasExplicitAccountBindingForChannel
? (channelToAgent.get(channelType) || defaultAgentIdNorm) ? channelToAgent.get(channelType)
: undefined : undefined
); );
@@ -672,7 +648,6 @@ export async function assignChannelToAgent(agentId: string, channelType: string)
throw new Error(`Agent "${agentId}" not found`); throw new Error(`Agent "${agentId}" not found`);
} }
assertAgentNotBoundToOtherChannel(config.bindings, agentId, channelType);
const accountId = resolveAccountIdForAgent(agentId); const accountId = resolveAccountIdForAgent(agentId);
config.bindings = upsertBindingsForChannel(config.bindings, channelType, agentId, accountId); config.bindings = upsertBindingsForChannel(config.bindings, channelType, agentId, accountId);
await writeOpenClawConfig(config); await writeOpenClawConfig(config);
@@ -696,7 +671,6 @@ export async function assignChannelAccountToAgent(
throw new Error('accountId is required'); throw new Error('accountId is required');
} }
assertAgentNotBoundToOtherChannel(config.bindings, agentId, channelType);
config.bindings = upsertBindingsForChannel(config.bindings, channelType, agentId, accountId.trim()); config.bindings = upsertBindingsForChannel(config.bindings, channelType, agentId, accountId.trim());
await writeOpenClawConfig(config); await writeOpenClawConfig(config);
logger.info('Assigned channel account to agent', { agentId, channelType, accountId: accountId.trim() }); logger.info('Assigned channel account to agent', { agentId, channelType, accountId: accountId.trim() });

View File

@@ -260,4 +260,107 @@ describe('agent config lifecycle', () => {
}; };
expect(feishu.accounts?.test2).toBeDefined(); expect(feishu.accounts?.test2).toBeDefined();
}); });
it('allows the same agent to bind multiple different channels', async () => {
await writeOpenClawJson({
agents: {
list: [
{ id: 'main', name: 'Main', default: true },
],
},
channels: {
feishu: { enabled: true },
telegram: { enabled: true },
},
});
const { assignChannelAccountToAgent, listAgentsSnapshot } = await import('@electron/utils/agent-config');
await assignChannelAccountToAgent('main', 'feishu', 'default');
await assignChannelAccountToAgent('main', 'telegram', 'default');
const snapshot = await listAgentsSnapshot();
expect(snapshot.channelAccountOwners['feishu:default']).toBe('main');
expect(snapshot.channelAccountOwners['telegram:default']).toBe('main');
});
it('replaces previous account binding for the same agent and channel', async () => {
await writeOpenClawJson({
agents: {
list: [
{ id: 'main', name: 'Main', default: true },
],
},
channels: {
feishu: {
enabled: true,
defaultAccount: 'default',
accounts: {
default: { enabled: true, appId: 'main-app' },
alt: { enabled: true, appId: 'alt-app' },
},
},
},
});
const { assignChannelAccountToAgent, listAgentsSnapshot } = await import('@electron/utils/agent-config');
await assignChannelAccountToAgent('main', 'feishu', 'default');
await assignChannelAccountToAgent('main', 'feishu', 'alt');
const snapshot = await listAgentsSnapshot();
expect(snapshot.channelAccountOwners['feishu:default']).toBeUndefined();
expect(snapshot.channelAccountOwners['feishu:alt']).toBe('main');
});
it('keeps a single owner for the same channel account', async () => {
await writeOpenClawJson({
agents: {
list: [
{ id: 'main', name: 'Main', default: true },
{ id: 'test2', name: 'test2' },
],
},
channels: {
feishu: {
enabled: true,
accounts: {
default: { enabled: true, appId: 'main-app' },
},
},
},
});
const { assignChannelAccountToAgent, listAgentsSnapshot } = await import('@electron/utils/agent-config');
await assignChannelAccountToAgent('main', 'feishu', 'default');
await assignChannelAccountToAgent('test2', 'feishu', 'default');
const snapshot = await listAgentsSnapshot();
expect(snapshot.channelAccountOwners['feishu:default']).toBe('test2');
});
it('can clear one channel account binding without affecting another channel on the same agent', async () => {
await writeOpenClawJson({
agents: {
list: [
{ id: 'main', name: 'Main', default: true },
],
},
channels: {
feishu: { enabled: true },
telegram: { enabled: true },
},
});
const { assignChannelAccountToAgent, clearChannelBinding, listAgentsSnapshot } = await import('@electron/utils/agent-config');
await assignChannelAccountToAgent('main', 'feishu', 'default');
await assignChannelAccountToAgent('main', 'telegram', 'default');
await clearChannelBinding('feishu', 'default');
const snapshot = await listAgentsSnapshot();
expect(snapshot.channelAccountOwners['feishu:default']).toBeUndefined();
expect(snapshot.channelAccountOwners['telegram:default']).toBe('main');
});
}); });