committed by
GitHub
Unverified
parent
4ff6861042
commit
b2c478d554
@@ -101,9 +101,11 @@ async function isLegacyConfiguredAccountId(channelType: string, accountId: strin
|
|||||||
async function validateCanonicalAccountId(
|
async function validateCanonicalAccountId(
|
||||||
channelType: string,
|
channelType: string,
|
||||||
accountId: string | undefined,
|
accountId: string | undefined,
|
||||||
options?: { allowLegacyConfiguredId?: boolean },
|
options?: { allowLegacyConfiguredId?: boolean; required?: boolean },
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
if (!accountId) return null;
|
if (!accountId) {
|
||||||
|
return options?.required ? 'accountId is required' : null;
|
||||||
|
}
|
||||||
const trimmed = accountId.trim();
|
const trimmed = accountId.trim();
|
||||||
if (!trimmed) return 'accountId cannot be empty';
|
if (!trimmed) return 'accountId cannot be empty';
|
||||||
if (isCanonicalOpenClawAccountId(trimmed)) {
|
if (isCanonicalOpenClawAccountId(trimmed)) {
|
||||||
@@ -122,8 +124,12 @@ async function validateAccountIdOrReply(
|
|||||||
res: ServerResponse,
|
res: ServerResponse,
|
||||||
channelType: string,
|
channelType: string,
|
||||||
accountId: string | undefined,
|
accountId: string | undefined,
|
||||||
|
options?: { required?: boolean },
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const error = await validateCanonicalAccountId(channelType, accountId, { allowLegacyConfiguredId: true });
|
const error = await validateCanonicalAccountId(channelType, accountId, {
|
||||||
|
allowLegacyConfiguredId: true,
|
||||||
|
required: options?.required,
|
||||||
|
});
|
||||||
if (!error) {
|
if (!error) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -313,8 +319,58 @@ async function ensureScopedChannelBinding(channelType: string, accountId?: strin
|
|||||||
|
|
||||||
// Legacy compatibility: if accountId matches an existing agentId, keep auto-binding.
|
// Legacy compatibility: if accountId matches an existing agentId, keep auto-binding.
|
||||||
if (agents.agents.some((entry) => entry.id === accountId)) {
|
if (agents.agents.some((entry) => entry.id === accountId)) {
|
||||||
|
await migrateLegacyChannelWideBinding(storedChannelType);
|
||||||
await assignChannelAccountToAgent(accountId, storedChannelType, accountId);
|
await assignChannelAccountToAgent(accountId, storedChannelType, accountId);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await migrateLegacyChannelWideBinding(storedChannelType);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrateLegacyChannelWideBinding(channelType: string): Promise<void> {
|
||||||
|
const explicitDefaultOwner = await readChannelBindingOwner(channelType, 'default');
|
||||||
|
const legacyOwner = await readChannelBindingOwner(channelType);
|
||||||
|
if (!legacyOwner) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const agents = await listAgentsSnapshot();
|
||||||
|
const validAgentIds = new Set(agents.agents.map((agent) => agent.id));
|
||||||
|
const defaultOwner = explicitDefaultOwner && validAgentIds.has(explicitDefaultOwner)
|
||||||
|
? explicitDefaultOwner
|
||||||
|
: (legacyOwner && validAgentIds.has(legacyOwner) ? legacyOwner : null);
|
||||||
|
|
||||||
|
if (defaultOwner) {
|
||||||
|
await assignChannelAccountToAgent(defaultOwner, channelType, 'default');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the legacy channel-wide fallback so newly added non-default
|
||||||
|
// accounts do not silently inherit default-agent routing.
|
||||||
|
await clearChannelBinding(channelType);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readChannelBindingOwner(channelType: string, accountId?: string): Promise<string | null> {
|
||||||
|
const config = await readOpenClawConfig();
|
||||||
|
const bindings = Array.isArray((config as { bindings?: unknown }).bindings)
|
||||||
|
? (config as { bindings: unknown[] }).bindings
|
||||||
|
: [];
|
||||||
|
|
||||||
|
for (const binding of bindings) {
|
||||||
|
if (!binding || typeof binding !== 'object') continue;
|
||||||
|
const candidate = binding as {
|
||||||
|
agentId?: unknown;
|
||||||
|
match?: { channel?: unknown; accountId?: unknown } | unknown;
|
||||||
|
};
|
||||||
|
if (typeof candidate.agentId !== 'string' || !candidate.agentId.trim()) continue;
|
||||||
|
if (!candidate.match || typeof candidate.match !== 'object' || Array.isArray(candidate.match)) continue;
|
||||||
|
const match = candidate.match as { channel?: unknown; accountId?: unknown };
|
||||||
|
if (match.channel !== channelType) continue;
|
||||||
|
const bindingAccountId = typeof match.accountId === 'string' ? match.accountId.trim() : '';
|
||||||
|
if ((accountId?.trim() || '') !== bindingAccountId) continue;
|
||||||
|
return candidate.agentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GatewayChannelStatusPayload {
|
interface GatewayChannelStatusPayload {
|
||||||
@@ -1145,11 +1201,19 @@ export async function handleChannelRoutes(
|
|||||||
if (url.pathname === '/api/channels/binding' && req.method === 'PUT') {
|
if (url.pathname === '/api/channels/binding' && req.method === 'PUT') {
|
||||||
try {
|
try {
|
||||||
const body = await parseJsonBody<{ channelType: string; accountId: string; agentId: string }>(req);
|
const body = await parseJsonBody<{ channelType: string; accountId: string; agentId: string }>(req);
|
||||||
const validAccountId = await validateAccountIdOrReply(res, body.channelType, body.accountId);
|
const validAccountId = await validateAccountIdOrReply(res, body.channelType, body.accountId, { required: true });
|
||||||
if (!validAccountId) {
|
if (!validAccountId) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
await assignChannelAccountToAgent(body.agentId, resolveStoredChannelType(body.channelType), body.accountId);
|
const agents = await listAgentsSnapshot();
|
||||||
|
if (!agents.agents.some((entry) => entry.id === body.agentId)) {
|
||||||
|
throw new Error(`Agent "${body.agentId}" not found`);
|
||||||
|
}
|
||||||
|
const storedChannelType = resolveStoredChannelType(body.channelType);
|
||||||
|
if (body.accountId !== 'default') {
|
||||||
|
await migrateLegacyChannelWideBinding(storedChannelType);
|
||||||
|
}
|
||||||
|
await assignChannelAccountToAgent(body.agentId, storedChannelType, body.accountId);
|
||||||
scheduleGatewayChannelSaveRefresh(ctx, body.channelType, `channel:setBinding:${body.channelType}`);
|
scheduleGatewayChannelSaveRefresh(ctx, body.channelType, `channel:setBinding:${body.channelType}`);
|
||||||
sendJson(res, 200, { success: true });
|
sendJson(res, 200, { success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -280,29 +280,33 @@ function upsertBindingsForChannel(
|
|||||||
agentId: string | null,
|
agentId: string | null,
|
||||||
accountId?: string,
|
accountId?: string,
|
||||||
): BindingConfig[] | undefined {
|
): BindingConfig[] | undefined {
|
||||||
const normalizedAgentId = agentId ? normalizeAgentIdForBinding(agentId) : '';
|
const normalizedAccountId = accountId?.trim() || '';
|
||||||
const nextBindings = Array.isArray(bindings)
|
const nextBindings = Array.isArray(bindings)
|
||||||
? [...bindings as BindingConfig[]].filter((binding) => {
|
? [...bindings as BindingConfig[]].filter((binding) => {
|
||||||
if (!isChannelBinding(binding)) return true;
|
if (!isChannelBinding(binding)) return true;
|
||||||
if (binding.match?.channel !== channelType) return true;
|
if (binding.match?.channel !== channelType) return true;
|
||||||
// Keep a single account binding per (agent, channelType). Rebinding to
|
|
||||||
// another account should replace the previous one.
|
const bindingAccountId = typeof binding.match?.accountId === 'string'
|
||||||
if (normalizedAgentId && normalizeAgentIdForBinding(binding.agentId || '') === normalizedAgentId) {
|
? binding.match.accountId.trim()
|
||||||
return false;
|
: '';
|
||||||
}
|
|
||||||
// Only remove binding that matches the exact accountId scope
|
// Account-scoped updates must only replace the exact account owner.
|
||||||
if (accountId) {
|
// Otherwise rebinding one Feishu/Lark account can silently drop a
|
||||||
return binding.match?.accountId !== accountId;
|
// sibling account binding on the same agent, which looks like routing
|
||||||
|
// or model config "drift" in multi-account setups.
|
||||||
|
if (normalizedAccountId) {
|
||||||
|
return bindingAccountId !== normalizedAccountId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No accountId: remove channel-wide binding (legacy)
|
// No accountId: remove channel-wide binding (legacy)
|
||||||
return Boolean(binding.match?.accountId);
|
return Boolean(bindingAccountId);
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
if (agentId) {
|
if (agentId) {
|
||||||
const match: BindingMatch = { channel: channelType };
|
const match: BindingMatch = { channel: channelType };
|
||||||
if (accountId) {
|
if (normalizedAccountId) {
|
||||||
match.accountId = accountId;
|
match.accountId = normalizedAccountId;
|
||||||
}
|
}
|
||||||
nextBindings.push({ agentId, match });
|
nextBindings.push({ agentId, match });
|
||||||
}
|
}
|
||||||
|
|||||||
130
tests/e2e/channels-binding-regression.spec.ts
Normal file
130
tests/e2e/channels-binding-regression.spec.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { completeSetup, expect, test } from './fixtures/electron';
|
||||||
|
|
||||||
|
test.describe('Channels binding regression', () => {
|
||||||
|
test('keeps newly added non-default Feishu accounts unassigned until the user binds an agent', async ({ electronApp, page }) => {
|
||||||
|
await electronApp.evaluate(({ ipcMain }) => {
|
||||||
|
const state = {
|
||||||
|
nextAccountId: 'feishu-a1b2c3d4',
|
||||||
|
saveCount: 0,
|
||||||
|
bindingCount: 0,
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
channelType: 'feishu',
|
||||||
|
defaultAccountId: 'default',
|
||||||
|
status: 'connected',
|
||||||
|
accounts: [
|
||||||
|
{
|
||||||
|
accountId: 'default',
|
||||||
|
name: 'Primary Account',
|
||||||
|
configured: true,
|
||||||
|
status: 'connected',
|
||||||
|
isDefault: true,
|
||||||
|
agentId: 'main',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
agents: [
|
||||||
|
{ id: 'main', name: 'Main Agent' },
|
||||||
|
{ id: 'code', name: 'Code Agent' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(globalThis as any).__clawxE2eBindingRegression = state;
|
||||||
|
|
||||||
|
ipcMain.removeHandler('hostapi:fetch');
|
||||||
|
ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string; method?: string; body?: string }) => {
|
||||||
|
const method = request?.method ?? 'GET';
|
||||||
|
const path = request?.path ?? '';
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const current = (globalThis as any).__clawxE2eBindingRegression as typeof state;
|
||||||
|
|
||||||
|
if (path === '/api/channels/accounts' && method === 'GET') {
|
||||||
|
return { ok: true, data: { status: 200, ok: true, json: { success: true, channels: current.channels } } };
|
||||||
|
}
|
||||||
|
if (path === '/api/agents' && method === 'GET') {
|
||||||
|
return { ok: true, data: { status: 200, ok: true, json: { success: true, agents: current.agents } } };
|
||||||
|
}
|
||||||
|
if (path === '/api/channels/credentials/validate' && method === 'POST') {
|
||||||
|
return { ok: true, data: { status: 200, ok: true, json: { success: true, valid: true, warnings: [] } } };
|
||||||
|
}
|
||||||
|
if (path === '/api/channels/config' && method === 'POST') {
|
||||||
|
current.saveCount += 1;
|
||||||
|
const body = JSON.parse(request?.body ?? '{}') as { accountId?: string };
|
||||||
|
const accountId = body.accountId || current.nextAccountId;
|
||||||
|
const feishu = current.channels[0];
|
||||||
|
if (!feishu.accounts.some((account) => account.accountId === accountId)) {
|
||||||
|
feishu.accounts.push({
|
||||||
|
accountId,
|
||||||
|
name: accountId,
|
||||||
|
configured: true,
|
||||||
|
status: 'connected',
|
||||||
|
isDefault: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { ok: true, data: { status: 200, ok: true, json: { success: true } } };
|
||||||
|
}
|
||||||
|
if (path === '/api/channels/binding' && method === 'PUT') {
|
||||||
|
current.bindingCount += 1;
|
||||||
|
const body = JSON.parse(request?.body ?? '{}') as { channelType?: string; accountId?: string; agentId?: string };
|
||||||
|
if (body.channelType === 'feishu' && body.accountId) {
|
||||||
|
const feishu = current.channels[0];
|
||||||
|
const account = feishu.accounts.find((entry) => entry.accountId === body.accountId);
|
||||||
|
if (account) {
|
||||||
|
account.agentId = body.agentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true, data: { status: 200, ok: true, json: { success: true } } };
|
||||||
|
}
|
||||||
|
if (path === '/api/channels/binding' && method === 'DELETE') {
|
||||||
|
current.bindingCount += 1;
|
||||||
|
return { ok: true, data: { status: 200, ok: true, json: { success: true } } };
|
||||||
|
}
|
||||||
|
if (path.startsWith('/api/channels/config/') && method === 'GET') {
|
||||||
|
return { ok: true, data: { status: 200, ok: true, json: { success: true, values: {} } } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: { message: `Unexpected hostapi:fetch request: ${method} ${path}` },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await completeSetup(page);
|
||||||
|
|
||||||
|
await page.getByTestId('sidebar-nav-channels').click();
|
||||||
|
await expect(page.getByTestId('channels-page')).toBeVisible();
|
||||||
|
await expect(page.getByText('Feishu / Lark')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Add Account|添加账号|アカウントを追加/ }).click();
|
||||||
|
await expect(page.getByText(/Configure Feishu \/ Lark|dialog\.configureTitle/)).toBeVisible();
|
||||||
|
|
||||||
|
const accountIdInput = page.locator('#account-id');
|
||||||
|
const newAccountId = await accountIdInput.inputValue();
|
||||||
|
await expect(accountIdInput).toHaveValue(/feishu-/);
|
||||||
|
await page.locator('#appId').fill('cli_test');
|
||||||
|
await page.locator('#appSecret').fill('secret_test');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /Save & Connect|dialog\.saveAndConnect/ }).click();
|
||||||
|
await expect(page.getByText(/Configure Feishu \/ Lark|dialog\.configureTitle/)).toBeHidden();
|
||||||
|
|
||||||
|
const newAccountRow = page.locator('div.rounded-xl').filter({ hasText: newAccountId }).first();
|
||||||
|
await expect(newAccountRow).toBeVisible();
|
||||||
|
const bindingSelect = newAccountRow.locator('select');
|
||||||
|
await expect(bindingSelect).toHaveValue('');
|
||||||
|
|
||||||
|
await bindingSelect.selectOption('code');
|
||||||
|
await expect(bindingSelect).toHaveValue('code');
|
||||||
|
|
||||||
|
const counters = await electronApp.evaluate(() => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const state = (globalThis as any).__clawxE2eBindingRegression as { saveCount: number; bindingCount: number };
|
||||||
|
return { saveCount: state.saveCount, bindingCount: state.bindingCount };
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(counters.saveCount).toBe(1);
|
||||||
|
expect(counters.bindingCount).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -379,7 +379,7 @@ describe('agent config lifecycle', () => {
|
|||||||
expect(snapshot.channelAccountOwners['telegram:default']).toBe('main');
|
expect(snapshot.channelAccountOwners['telegram:default']).toBe('main');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('replaces previous account binding for the same agent and channel', async () => {
|
it('keeps sibling account bindings for the same agent and channel', async () => {
|
||||||
await writeOpenClawJson({
|
await writeOpenClawJson({
|
||||||
agents: {
|
agents: {
|
||||||
list: [
|
list: [
|
||||||
@@ -404,10 +404,40 @@ describe('agent config lifecycle', () => {
|
|||||||
await assignChannelAccountToAgent('main', 'feishu', 'alt');
|
await assignChannelAccountToAgent('main', 'feishu', 'alt');
|
||||||
|
|
||||||
const snapshot = await listAgentsSnapshot();
|
const snapshot = await listAgentsSnapshot();
|
||||||
expect(snapshot.channelAccountOwners['feishu:default']).toBeUndefined();
|
expect(snapshot.channelAccountOwners['feishu:default']).toBe('main');
|
||||||
expect(snapshot.channelAccountOwners['feishu:alt']).toBe('main');
|
expect(snapshot.channelAccountOwners['feishu:alt']).toBe('main');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves original agentId casing when persisting bindings', async () => {
|
||||||
|
await writeOpenClawJson({
|
||||||
|
agents: {
|
||||||
|
list: [
|
||||||
|
{ id: 'MainAgent', name: 'Main Agent', default: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
channels: {
|
||||||
|
feishu: {
|
||||||
|
enabled: true,
|
||||||
|
accounts: {
|
||||||
|
default: { enabled: true, appId: 'main-app' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { assignChannelAccountToAgent } = await import('@electron/utils/agent-config');
|
||||||
|
|
||||||
|
await assignChannelAccountToAgent('MainAgent', 'feishu', 'default');
|
||||||
|
|
||||||
|
const config = await readOpenClawJson();
|
||||||
|
expect(config.bindings).toEqual([
|
||||||
|
{
|
||||||
|
agentId: 'MainAgent',
|
||||||
|
match: { channel: 'feishu', accountId: 'default' },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps a single owner for the same channel account', async () => {
|
it('keeps a single owner for the same channel account', async () => {
|
||||||
await writeOpenClawJson({
|
await writeOpenClawJson({
|
||||||
agents: {
|
agents: {
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ describe('handleChannelRoutes', () => {
|
|||||||
listConfiguredChannelAccountsMock.mockReturnValue({});
|
listConfiguredChannelAccountsMock.mockReturnValue({});
|
||||||
listAgentsSnapshotMock.mockResolvedValue({
|
listAgentsSnapshotMock.mockResolvedValue({
|
||||||
agents: [],
|
agents: [],
|
||||||
|
channelOwners: {},
|
||||||
channelAccountOwners: {},
|
channelAccountOwners: {},
|
||||||
});
|
});
|
||||||
readOpenClawConfigMock.mockResolvedValue({
|
readOpenClawConfigMock.mockResolvedValue({
|
||||||
@@ -367,6 +368,11 @@ describe('handleChannelRoutes', () => {
|
|||||||
accountIds: ['default', 'Legacy_Account'],
|
accountIds: ['default', 'Legacy_Account'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
listAgentsSnapshotMock.mockResolvedValue({
|
||||||
|
agents: [{ id: 'main', name: 'Main Agent' }],
|
||||||
|
channelOwners: {},
|
||||||
|
channelAccountOwners: {},
|
||||||
|
});
|
||||||
|
|
||||||
parseJsonBodyMock.mockResolvedValue({
|
parseJsonBodyMock.mockResolvedValue({
|
||||||
channelType: 'feishu',
|
channelType: 'feishu',
|
||||||
@@ -409,6 +415,353 @@ describe('handleChannelRoutes', () => {
|
|||||||
expect(assignChannelAccountToAgentMock).toHaveBeenCalledWith('main', 'feishu', 'Legacy_Account');
|
expect(assignChannelAccountToAgentMock).toHaveBeenCalledWith('main', 'feishu', 'Legacy_Account');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('migrates legacy channel-wide fallback before manually binding a non-default account', async () => {
|
||||||
|
listConfiguredChannelAccountsMock.mockReturnValue({
|
||||||
|
telegram: {
|
||||||
|
defaultAccountId: 'default',
|
||||||
|
accountIds: ['default', 'telegram-a1b2c3d4'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
listAgentsSnapshotMock.mockResolvedValue({
|
||||||
|
agents: [{ id: 'main', name: 'Main' }, { id: 'code', name: 'Code Agent' }],
|
||||||
|
channelOwners: { telegram: 'main' },
|
||||||
|
channelAccountOwners: {},
|
||||||
|
});
|
||||||
|
readOpenClawConfigMock.mockResolvedValue({
|
||||||
|
bindings: [
|
||||||
|
{ agentId: 'main', match: { channel: 'telegram' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
parseJsonBodyMock.mockResolvedValue({
|
||||||
|
channelType: 'telegram',
|
||||||
|
accountId: 'telegram-a1b2c3d4',
|
||||||
|
agentId: 'code',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { handleChannelRoutes } = await import('@electron/api/routes/channels');
|
||||||
|
await handleChannelRoutes(
|
||||||
|
{ method: 'PUT' } as IncomingMessage,
|
||||||
|
{} as ServerResponse,
|
||||||
|
new URL('http://127.0.0.1:13210/api/channels/binding'),
|
||||||
|
{
|
||||||
|
gatewayManager: {
|
||||||
|
rpc: vi.fn(),
|
||||||
|
getStatus: () => ({ state: 'running' }),
|
||||||
|
debouncedReload: vi.fn(),
|
||||||
|
debouncedRestart: vi.fn(),
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(assignChannelAccountToAgentMock).toHaveBeenNthCalledWith(1, 'main', 'telegram', 'default');
|
||||||
|
expect(clearChannelBindingMock).toHaveBeenCalledWith('telegram');
|
||||||
|
expect(assignChannelAccountToAgentMock).toHaveBeenNthCalledWith(2, 'code', 'telegram', 'telegram-a1b2c3d4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not synthesize a default binding when no legacy channel-wide binding exists', async () => {
|
||||||
|
listConfiguredChannelAccountsMock.mockReturnValue({
|
||||||
|
telegram: {
|
||||||
|
defaultAccountId: 'default',
|
||||||
|
accountIds: ['default', 'telegram-a1b2c3d4'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
listAgentsSnapshotMock.mockResolvedValue({
|
||||||
|
agents: [{ id: 'main', name: 'Main' }, { id: 'code', name: 'Code Agent' }],
|
||||||
|
channelOwners: { telegram: 'code' },
|
||||||
|
channelAccountOwners: {
|
||||||
|
'telegram:telegram-a1b2c3d4': 'code',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
readOpenClawConfigMock.mockResolvedValue({
|
||||||
|
bindings: [
|
||||||
|
{ agentId: 'code', match: { channel: 'telegram', accountId: 'telegram-a1b2c3d4' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
parseJsonBodyMock.mockResolvedValue({
|
||||||
|
channelType: 'telegram',
|
||||||
|
accountId: 'telegram-b2c3d4e5',
|
||||||
|
agentId: 'code',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { handleChannelRoutes } = await import('@electron/api/routes/channels');
|
||||||
|
await handleChannelRoutes(
|
||||||
|
{ method: 'PUT' } as IncomingMessage,
|
||||||
|
{} as ServerResponse,
|
||||||
|
new URL('http://127.0.0.1:13210/api/channels/binding'),
|
||||||
|
{
|
||||||
|
gatewayManager: {
|
||||||
|
rpc: vi.fn(),
|
||||||
|
getStatus: () => ({ state: 'running' }),
|
||||||
|
debouncedReload: vi.fn(),
|
||||||
|
debouncedRestart: vi.fn(),
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(clearChannelBindingMock).not.toHaveBeenCalled();
|
||||||
|
expect(assignChannelAccountToAgentMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(assignChannelAccountToAgentMock).toHaveBeenCalledWith('code', 'telegram', 'telegram-b2c3d4e5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves mixed-case agent ids when migrating a legacy channel-wide binding', async () => {
|
||||||
|
listConfiguredChannelAccountsMock.mockReturnValue({
|
||||||
|
telegram: {
|
||||||
|
defaultAccountId: 'default',
|
||||||
|
accountIds: ['default', 'telegram-a1b2c3d4'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
listAgentsSnapshotMock.mockResolvedValue({
|
||||||
|
agents: [{ id: 'MainAgent', name: 'Main Agent' }, { id: 'code', name: 'Code Agent' }],
|
||||||
|
channelOwners: { telegram: 'mainagent' },
|
||||||
|
channelAccountOwners: {},
|
||||||
|
});
|
||||||
|
readOpenClawConfigMock.mockResolvedValue({
|
||||||
|
bindings: [
|
||||||
|
{ agentId: 'MainAgent', match: { channel: 'telegram' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
parseJsonBodyMock.mockResolvedValue({
|
||||||
|
channelType: 'telegram',
|
||||||
|
accountId: 'telegram-a1b2c3d4',
|
||||||
|
agentId: 'code',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { handleChannelRoutes } = await import('@electron/api/routes/channels');
|
||||||
|
await handleChannelRoutes(
|
||||||
|
{ method: 'PUT' } as IncomingMessage,
|
||||||
|
{} as ServerResponse,
|
||||||
|
new URL('http://127.0.0.1:13210/api/channels/binding'),
|
||||||
|
{
|
||||||
|
gatewayManager: {
|
||||||
|
rpc: vi.fn(),
|
||||||
|
getStatus: () => ({ state: 'running' }),
|
||||||
|
debouncedReload: vi.fn(),
|
||||||
|
debouncedRestart: vi.fn(),
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(assignChannelAccountToAgentMock).toHaveBeenNthCalledWith(1, 'MainAgent', 'telegram', 'default');
|
||||||
|
expect(assignChannelAccountToAgentMock).toHaveBeenNthCalledWith(2, 'code', 'telegram', 'telegram-a1b2c3d4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mutate legacy bindings when the requested agent does not exist', async () => {
|
||||||
|
listConfiguredChannelAccountsMock.mockReturnValue({
|
||||||
|
telegram: {
|
||||||
|
defaultAccountId: 'default',
|
||||||
|
accountIds: ['default', 'telegram-a1b2c3d4'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
listAgentsSnapshotMock.mockResolvedValue({
|
||||||
|
agents: [{ id: 'main', name: 'Main Agent' }],
|
||||||
|
channelOwners: { telegram: 'main' },
|
||||||
|
channelAccountOwners: {},
|
||||||
|
});
|
||||||
|
readOpenClawConfigMock.mockResolvedValue({
|
||||||
|
bindings: [
|
||||||
|
{ agentId: 'main', match: { channel: 'telegram' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
parseJsonBodyMock.mockResolvedValue({
|
||||||
|
channelType: 'telegram',
|
||||||
|
accountId: 'telegram-a1b2c3d4',
|
||||||
|
agentId: 'missing-agent',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { handleChannelRoutes } = await import('@electron/api/routes/channels');
|
||||||
|
await handleChannelRoutes(
|
||||||
|
{ method: 'PUT' } as IncomingMessage,
|
||||||
|
{} as ServerResponse,
|
||||||
|
new URL('http://127.0.0.1:13210/api/channels/binding'),
|
||||||
|
{
|
||||||
|
gatewayManager: {
|
||||||
|
rpc: vi.fn(),
|
||||||
|
getStatus: () => ({ state: 'running' }),
|
||||||
|
debouncedReload: vi.fn(),
|
||||||
|
debouncedRestart: vi.fn(),
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(clearChannelBindingMock).not.toHaveBeenCalled();
|
||||||
|
expect(assignChannelAccountToAgentMock).not.toHaveBeenCalled();
|
||||||
|
expect(sendJsonMock).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
500,
|
||||||
|
expect.objectContaining({
|
||||||
|
success: false,
|
||||||
|
error: expect.stringContaining('Agent "missing-agent" not found'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects binding requests without accountId before legacy migration runs', async () => {
|
||||||
|
listAgentsSnapshotMock.mockResolvedValue({
|
||||||
|
agents: [{ id: 'main', name: 'Main Agent' }],
|
||||||
|
channelOwners: {},
|
||||||
|
channelAccountOwners: {},
|
||||||
|
});
|
||||||
|
parseJsonBodyMock.mockResolvedValue({
|
||||||
|
channelType: 'telegram',
|
||||||
|
agentId: 'main',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { handleChannelRoutes } = await import('@electron/api/routes/channels');
|
||||||
|
await handleChannelRoutes(
|
||||||
|
{ method: 'PUT' } as IncomingMessage,
|
||||||
|
{} as ServerResponse,
|
||||||
|
new URL('http://127.0.0.1:13210/api/channels/binding'),
|
||||||
|
{
|
||||||
|
gatewayManager: {
|
||||||
|
rpc: vi.fn(),
|
||||||
|
getStatus: () => ({ state: 'running' }),
|
||||||
|
debouncedReload: vi.fn(),
|
||||||
|
debouncedRestart: vi.fn(),
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(clearChannelBindingMock).not.toHaveBeenCalled();
|
||||||
|
expect(assignChannelAccountToAgentMock).not.toHaveBeenCalled();
|
||||||
|
expect(sendJsonMock).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
400,
|
||||||
|
expect.objectContaining({
|
||||||
|
success: false,
|
||||||
|
error: 'accountId is required',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the legacy owner when explicit default owner is stale', async () => {
|
||||||
|
listConfiguredChannelAccountsMock.mockReturnValue({
|
||||||
|
telegram: {
|
||||||
|
defaultAccountId: 'default',
|
||||||
|
accountIds: ['default', 'telegram-a1b2c3d4'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
listAgentsSnapshotMock.mockResolvedValue({
|
||||||
|
agents: [{ id: 'MainAgent', name: 'Main Agent' }, { id: 'code', name: 'Code Agent' }],
|
||||||
|
channelOwners: {},
|
||||||
|
channelAccountOwners: {},
|
||||||
|
});
|
||||||
|
readOpenClawConfigMock.mockResolvedValue({
|
||||||
|
bindings: [
|
||||||
|
{ agentId: 'MissingAgent', match: { channel: 'telegram', accountId: 'default' } },
|
||||||
|
{ agentId: 'MainAgent', match: { channel: 'telegram' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
parseJsonBodyMock.mockResolvedValue({
|
||||||
|
channelType: 'telegram',
|
||||||
|
accountId: 'telegram-a1b2c3d4',
|
||||||
|
agentId: 'code',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { handleChannelRoutes } = await import('@electron/api/routes/channels');
|
||||||
|
await handleChannelRoutes(
|
||||||
|
{ method: 'PUT' } as IncomingMessage,
|
||||||
|
{} as ServerResponse,
|
||||||
|
new URL('http://127.0.0.1:13210/api/channels/binding'),
|
||||||
|
{
|
||||||
|
gatewayManager: {
|
||||||
|
rpc: vi.fn(),
|
||||||
|
getStatus: () => ({ state: 'running' }),
|
||||||
|
debouncedReload: vi.fn(),
|
||||||
|
debouncedRestart: vi.fn(),
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(assignChannelAccountToAgentMock).toHaveBeenNthCalledWith(1, 'MainAgent', 'telegram', 'default');
|
||||||
|
expect(assignChannelAccountToAgentMock).toHaveBeenNthCalledWith(2, 'code', 'telegram', 'telegram-a1b2c3d4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips default binding migration when both explicit and legacy owners are stale', async () => {
|
||||||
|
listConfiguredChannelAccountsMock.mockReturnValue({
|
||||||
|
telegram: {
|
||||||
|
defaultAccountId: 'default',
|
||||||
|
accountIds: ['default', 'telegram-a1b2c3d4'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
listAgentsSnapshotMock.mockResolvedValue({
|
||||||
|
agents: [{ id: 'code', name: 'Code Agent' }],
|
||||||
|
channelOwners: {},
|
||||||
|
channelAccountOwners: {},
|
||||||
|
});
|
||||||
|
readOpenClawConfigMock.mockResolvedValue({
|
||||||
|
bindings: [
|
||||||
|
{ agentId: 'MissingDefault', match: { channel: 'telegram', accountId: 'default' } },
|
||||||
|
{ agentId: 'MissingLegacy', match: { channel: 'telegram' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
parseJsonBodyMock.mockResolvedValue({
|
||||||
|
channelType: 'telegram',
|
||||||
|
accountId: 'telegram-a1b2c3d4',
|
||||||
|
agentId: 'code',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { handleChannelRoutes } = await import('@electron/api/routes/channels');
|
||||||
|
await handleChannelRoutes(
|
||||||
|
{ method: 'PUT' } as IncomingMessage,
|
||||||
|
{} as ServerResponse,
|
||||||
|
new URL('http://127.0.0.1:13210/api/channels/binding'),
|
||||||
|
{
|
||||||
|
gatewayManager: {
|
||||||
|
rpc: vi.fn(),
|
||||||
|
getStatus: () => ({ state: 'running' }),
|
||||||
|
debouncedReload: vi.fn(),
|
||||||
|
debouncedRestart: vi.fn(),
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(clearChannelBindingMock).toHaveBeenCalledWith('telegram');
|
||||||
|
expect(assignChannelAccountToAgentMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(assignChannelAccountToAgentMock).toHaveBeenCalledWith('code', 'telegram', 'telegram-a1b2c3d4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts legacy channel-wide fallback into an explicit default binding when saving a non-default account', async () => {
|
||||||
|
parseJsonBodyMock.mockResolvedValue({
|
||||||
|
channelType: 'telegram',
|
||||||
|
accountId: 'telegram-a1b2c3d4',
|
||||||
|
config: { botToken: 'token', allowedUsers: '123456' },
|
||||||
|
});
|
||||||
|
listAgentsSnapshotMock.mockResolvedValue({
|
||||||
|
agents: [{ id: 'main', name: 'Main' }],
|
||||||
|
channelOwners: { telegram: 'main' },
|
||||||
|
channelAccountOwners: {},
|
||||||
|
});
|
||||||
|
readOpenClawConfigMock.mockResolvedValue({
|
||||||
|
bindings: [
|
||||||
|
{ agentId: 'main', match: { channel: 'telegram' } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { handleChannelRoutes } = await import('@electron/api/routes/channels');
|
||||||
|
await handleChannelRoutes(
|
||||||
|
{ method: 'POST' } as IncomingMessage,
|
||||||
|
{} as ServerResponse,
|
||||||
|
new URL('http://127.0.0.1:13210/api/channels/config'),
|
||||||
|
{
|
||||||
|
gatewayManager: {
|
||||||
|
rpc: vi.fn(),
|
||||||
|
getStatus: () => ({ state: 'running' }),
|
||||||
|
debouncedReload: vi.fn(),
|
||||||
|
debouncedRestart: vi.fn(),
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(saveChannelConfigMock).toHaveBeenCalledWith(
|
||||||
|
'telegram',
|
||||||
|
{ botToken: 'token', allowedUsers: '123456' },
|
||||||
|
'telegram-a1b2c3d4',
|
||||||
|
);
|
||||||
|
expect(assignChannelAccountToAgentMock).toHaveBeenCalledWith('main', 'telegram', 'default');
|
||||||
|
expect(clearChannelBindingMock).toHaveBeenCalledWith('telegram');
|
||||||
|
expect(assignChannelAccountToAgentMock).not.toHaveBeenCalledWith('main', 'telegram', 'telegram-a1b2c3d4');
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps channel connected when one account is healthy and another errors', async () => {
|
it('keeps channel connected when one account is healthy and another errors', async () => {
|
||||||
listConfiguredChannelsMock.mockResolvedValue(['telegram']);
|
listConfiguredChannelsMock.mockResolvedValue(['telegram']);
|
||||||
listConfiguredChannelAccountsMock.mockResolvedValue({
|
listConfiguredChannelAccountsMock.mockResolvedValue({
|
||||||
|
|||||||
Reference in New Issue
Block a user