fix(channel): support channel names that include numbers; legacy test names containing numbers may still appear (#796)
Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Haze <hazeone@users.noreply.github.com>
This commit is contained in:
@@ -317,6 +317,20 @@ interface ChannelAccountsView {
|
|||||||
accounts: ChannelAccountView[];
|
accounts: ChannelAccountView[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldIncludeRuntimeAccountId(
|
||||||
|
accountId: string,
|
||||||
|
configuredAccountIds: Set<string>,
|
||||||
|
runtimeAccount: { configured?: boolean },
|
||||||
|
): boolean {
|
||||||
|
if (configuredAccountIds.has(accountId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Defensive filtering: channels.status can occasionally expose transient
|
||||||
|
// runtime rows for stale sessions. Only include runtime-only accounts when
|
||||||
|
// gateway marks them as configured.
|
||||||
|
return runtimeAccount.configured === true;
|
||||||
|
}
|
||||||
|
|
||||||
interface ChannelTargetOptionView {
|
interface ChannelTargetOptionView {
|
||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -375,6 +389,7 @@ async function buildChannelAccountsView(ctx: HostApiContext): Promise<ChannelAcc
|
|||||||
for (const rawChannelType of channelTypes) {
|
for (const rawChannelType of channelTypes) {
|
||||||
const uiChannelType = toUiChannelType(rawChannelType);
|
const uiChannelType = toUiChannelType(rawChannelType);
|
||||||
const channelAccountsFromConfig = configuredAccounts[rawChannelType]?.accountIds ?? [];
|
const channelAccountsFromConfig = configuredAccounts[rawChannelType]?.accountIds ?? [];
|
||||||
|
const configuredAccountIdSet = new Set(channelAccountsFromConfig);
|
||||||
const hasLocalConfig = configuredChannels.includes(rawChannelType) || Boolean(configuredAccounts[rawChannelType]);
|
const hasLocalConfig = configuredChannels.includes(rawChannelType) || Boolean(configuredAccounts[rawChannelType]);
|
||||||
const channelSection = openClawConfig.channels?.[rawChannelType];
|
const channelSection = openClawConfig.channels?.[rawChannelType];
|
||||||
const channelSummary =
|
const channelSummary =
|
||||||
@@ -396,9 +411,17 @@ async function buildChannelAccountsView(ctx: HostApiContext): Promise<ChannelAcc
|
|||||||
if (!hasLocalConfig && !hasRuntimeConfigured) {
|
if (!hasLocalConfig && !hasRuntimeConfigured) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const runtimeAccountIds = runtimeAccounts
|
const runtimeAccountIds = runtimeAccounts.reduce<string[]>((acc, account) => {
|
||||||
.map((account) => account.accountId)
|
const accountId = typeof account.accountId === 'string' ? account.accountId.trim() : '';
|
||||||
.filter((accountId): accountId is string => typeof accountId === 'string' && accountId.trim().length > 0);
|
if (!accountId) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
if (!shouldIncludeRuntimeAccountId(accountId, configuredAccountIdSet, account)) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
acc.push(accountId);
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
const accountIds = Array.from(new Set([...channelAccountsFromConfig, ...runtimeAccountIds, defaultAccountId]));
|
const accountIds = Array.from(new Set([...channelAccountsFromConfig, ...runtimeAccountIds, defaultAccountId]));
|
||||||
|
|
||||||
const accounts: ChannelAccountView[] = accountIds.map((accountId) => {
|
const accounts: ChannelAccountView[] = accountIds.map((accountId) => {
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ function slugifyAgentId(name: string): string {
|
|||||||
.replace(/-+/g, '-')
|
.replace(/-+/g, '-')
|
||||||
.replace(/^-|-$/g, '');
|
.replace(/^-|-$/g, '');
|
||||||
|
|
||||||
if (!normalized) return 'agent';
|
if (!normalized || /^\d+$/.test(normalized)) return 'agent';
|
||||||
if (normalized === MAIN_AGENT_ID) return 'agent';
|
if (normalized === MAIN_AGENT_ID) return 'agent';
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,11 +200,33 @@ function removePluginRegistration(currentConfig: OpenClawConfig, pluginId: strin
|
|||||||
return modified;
|
return modified;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getChannelAccountsMap(
|
||||||
|
channelSection: ChannelConfigData | undefined,
|
||||||
|
): Record<string, ChannelConfigData> | undefined {
|
||||||
|
if (!channelSection || typeof channelSection !== 'object') return undefined;
|
||||||
|
const accounts = channelSection.accounts;
|
||||||
|
if (!accounts || typeof accounts !== 'object' || Array.isArray(accounts)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return accounts as Record<string, ChannelConfigData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureChannelAccountsMap(
|
||||||
|
channelSection: ChannelConfigData,
|
||||||
|
): Record<string, ChannelConfigData> {
|
||||||
|
const accounts = getChannelAccountsMap(channelSection);
|
||||||
|
if (accounts) {
|
||||||
|
return accounts;
|
||||||
|
}
|
||||||
|
channelSection.accounts = {};
|
||||||
|
return channelSection.accounts as Record<string, ChannelConfigData>;
|
||||||
|
}
|
||||||
|
|
||||||
function channelHasConfiguredAccounts(channelSection: ChannelConfigData | undefined): boolean {
|
function channelHasConfiguredAccounts(channelSection: ChannelConfigData | undefined): boolean {
|
||||||
if (!channelSection || typeof channelSection !== 'object') return false;
|
if (!channelSection || typeof channelSection !== 'object') return false;
|
||||||
const accounts = channelSection.accounts as Record<string, ChannelConfigData> | undefined;
|
const accounts = getChannelAccountsMap(channelSection);
|
||||||
if (accounts && typeof accounts === 'object') {
|
if (accounts) {
|
||||||
return Object.keys(accounts).length > 0;
|
return Object.keys(accounts).some((accountId) => accountId.trim().length > 0);
|
||||||
}
|
}
|
||||||
return Object.keys(channelSection).some((key) => !CHANNEL_TOP_LEVEL_KEYS_TO_KEEP.has(key));
|
return Object.keys(channelSection).some((key) => !CHANNEL_TOP_LEVEL_KEYS_TO_KEEP.has(key));
|
||||||
}
|
}
|
||||||
@@ -578,7 +600,7 @@ function resolveAccountConfig(
|
|||||||
accountId: string,
|
accountId: string,
|
||||||
): ChannelConfigData {
|
): ChannelConfigData {
|
||||||
if (!channelSection) return {};
|
if (!channelSection) return {};
|
||||||
const accounts = channelSection.accounts as Record<string, ChannelConfigData> | undefined;
|
const accounts = getChannelAccountsMap(channelSection);
|
||||||
return accounts?.[accountId] ?? {};
|
return accounts?.[accountId] ?? {};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -597,10 +619,8 @@ function migrateLegacyChannelConfigToAccounts(
|
|||||||
): void {
|
): void {
|
||||||
const legacyPayload = getLegacyChannelPayload(channelSection);
|
const legacyPayload = getLegacyChannelPayload(channelSection);
|
||||||
const legacyKeys = Object.keys(legacyPayload);
|
const legacyKeys = Object.keys(legacyPayload);
|
||||||
const hasAccounts =
|
const existingAccounts = getChannelAccountsMap(channelSection);
|
||||||
Boolean(channelSection.accounts) &&
|
const hasAccounts = Boolean(existingAccounts) && Object.keys(existingAccounts).length > 0;
|
||||||
typeof channelSection.accounts === 'object' &&
|
|
||||||
Object.keys(channelSection.accounts as Record<string, ChannelConfigData>).length > 0;
|
|
||||||
|
|
||||||
if (legacyKeys.length === 0) {
|
if (legacyKeys.length === 0) {
|
||||||
if (hasAccounts && typeof channelSection.defaultAccount !== 'string') {
|
if (hasAccounts && typeof channelSection.defaultAccount !== 'string') {
|
||||||
@@ -609,10 +629,7 @@ function migrateLegacyChannelConfigToAccounts(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!channelSection.accounts || typeof channelSection.accounts !== 'object') {
|
const accounts = ensureChannelAccountsMap(channelSection);
|
||||||
channelSection.accounts = {};
|
|
||||||
}
|
|
||||||
const accounts = channelSection.accounts as Record<string, ChannelConfigData>;
|
|
||||||
const existingDefaultAccount = accounts[defaultAccountId] ?? {};
|
const existingDefaultAccount = accounts[defaultAccountId] ?? {};
|
||||||
|
|
||||||
accounts[defaultAccountId] = {
|
accounts[defaultAccountId] = {
|
||||||
@@ -657,7 +674,7 @@ function assertNoDuplicateCredential(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const accounts = channelSection.accounts as Record<string, ChannelConfigData> | undefined;
|
const accounts = getChannelAccountsMap(channelSection);
|
||||||
if (!accounts) return;
|
if (!accounts) return;
|
||||||
|
|
||||||
for (const [existingAccountId, accountCfg] of Object.entries(accounts)) {
|
for (const [existingAccountId, accountCfg] of Object.entries(accounts)) {
|
||||||
@@ -737,10 +754,7 @@ export async function saveChannelConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Write credentials into accounts.<accountId>
|
// Write credentials into accounts.<accountId>
|
||||||
if (!channelSection.accounts || typeof channelSection.accounts !== 'object') {
|
const accounts = ensureChannelAccountsMap(channelSection);
|
||||||
channelSection.accounts = {};
|
|
||||||
}
|
|
||||||
const accounts = channelSection.accounts as Record<string, ChannelConfigData>;
|
|
||||||
channelSection.defaultAccount =
|
channelSection.defaultAccount =
|
||||||
typeof channelSection.defaultAccount === 'string' && channelSection.defaultAccount.trim()
|
typeof channelSection.defaultAccount === 'string' && channelSection.defaultAccount.trim()
|
||||||
? channelSection.defaultAccount
|
? channelSection.defaultAccount
|
||||||
@@ -790,7 +804,7 @@ export async function getChannelConfig(channelType: string, accountId?: string):
|
|||||||
if (!channelSection) return undefined;
|
if (!channelSection) return undefined;
|
||||||
|
|
||||||
const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID;
|
const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID;
|
||||||
const accounts = channelSection.accounts as Record<string, ChannelConfigData> | undefined;
|
const accounts = getChannelAccountsMap(channelSection);
|
||||||
if (accounts?.[resolvedAccountId]) {
|
if (accounts?.[resolvedAccountId]) {
|
||||||
return accounts[resolvedAccountId];
|
return accounts[resolvedAccountId];
|
||||||
}
|
}
|
||||||
@@ -868,7 +882,7 @@ export async function deleteChannelAccountConfig(channelType: string, accountId:
|
|||||||
}
|
}
|
||||||
|
|
||||||
migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID);
|
migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID);
|
||||||
const accounts = channelSection.accounts as Record<string, ChannelConfigData> | undefined;
|
const accounts = getChannelAccountsMap(channelSection);
|
||||||
if (!accounts?.[accountId]) return;
|
if (!accounts?.[accountId]) return;
|
||||||
|
|
||||||
delete accounts[accountId];
|
delete accounts[accountId];
|
||||||
@@ -959,8 +973,8 @@ export async function deleteChannelConfig(channelType: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function channelHasAnyAccount(channelSection: ChannelConfigData): boolean {
|
function channelHasAnyAccount(channelSection: ChannelConfigData): boolean {
|
||||||
const accounts = channelSection.accounts as Record<string, ChannelConfigData> | undefined;
|
const accounts = getChannelAccountsMap(channelSection);
|
||||||
if (accounts && typeof accounts === 'object') {
|
if (accounts) {
|
||||||
return Object.values(accounts).some((acc) => acc.enabled !== false);
|
return Object.values(accounts).some((acc) => acc.enabled !== false);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -1024,8 +1038,9 @@ export function listConfiguredChannelAccountsFromConfig(config: OpenClawConfig):
|
|||||||
for (const [channelType, section] of Object.entries(config.channels)) {
|
for (const [channelType, section] of Object.entries(config.channels)) {
|
||||||
if (!section || section.enabled === false) continue;
|
if (!section || section.enabled === false) continue;
|
||||||
|
|
||||||
const accountIds = section.accounts && typeof section.accounts === 'object'
|
const accounts = getChannelAccountsMap(section);
|
||||||
? Object.keys(section.accounts).filter(Boolean)
|
const accountIds = accounts
|
||||||
|
? Object.keys(accounts).filter((accountId) => accountId.trim().length > 0)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
let defaultAccountId = typeof section.defaultAccount === 'string' && section.defaultAccount.trim()
|
let defaultAccountId = typeof section.defaultAccount === 'string' && section.defaultAccount.trim()
|
||||||
@@ -1082,7 +1097,7 @@ export async function setChannelDefaultAccount(channelType: string, accountId: s
|
|||||||
}
|
}
|
||||||
|
|
||||||
migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID);
|
migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID);
|
||||||
const accounts = channelSection.accounts as Record<string, ChannelConfigData> | undefined;
|
const accounts = getChannelAccountsMap(channelSection);
|
||||||
if (!accounts || !accounts[trimmedAccountId]) {
|
if (!accounts || !accounts[trimmedAccountId]) {
|
||||||
throw new Error(`Account "${trimmedAccountId}" is not configured for channel "${resolvedChannelType}"`);
|
throw new Error(`Account "${trimmedAccountId}" is not configured for channel "${resolvedChannelType}"`);
|
||||||
}
|
}
|
||||||
@@ -1110,7 +1125,7 @@ export async function deleteAgentChannelAccounts(agentId: string, ownedChannelAc
|
|||||||
for (const channelType of Object.keys(currentConfig.channels)) {
|
for (const channelType of Object.keys(currentConfig.channels)) {
|
||||||
const section = currentConfig.channels[channelType];
|
const section = currentConfig.channels[channelType];
|
||||||
migrateLegacyChannelConfigToAccounts(section, DEFAULT_ACCOUNT_ID);
|
migrateLegacyChannelConfigToAccounts(section, DEFAULT_ACCOUNT_ID);
|
||||||
const accounts = section.accounts as Record<string, ChannelConfigData> | undefined;
|
const accounts = getChannelAccountsMap(section);
|
||||||
if (!accounts?.[accountId]) continue;
|
if (!accounts?.[accountId]) continue;
|
||||||
if (ownedChannelAccounts && !ownedChannelAccounts.has(`${channelType}:${accountId}`)) {
|
if (ownedChannelAccounts && !ownedChannelAccounts.has(`${channelType}:${accountId}`)) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -458,4 +458,25 @@ describe('agent config lifecycle', () => {
|
|||||||
expect(snapshot.channelAccountOwners['feishu:default']).toBeUndefined();
|
expect(snapshot.channelAccountOwners['feishu:default']).toBeUndefined();
|
||||||
expect(snapshot.channelAccountOwners['telegram:default']).toBe('main');
|
expect(snapshot.channelAccountOwners['telegram:default']).toBe('main');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('avoids numeric-only ids when creating agents from CJK names', async () => {
|
||||||
|
await writeOpenClawJson({
|
||||||
|
agents: {
|
||||||
|
list: [{ id: 'main', name: 'Main', default: true }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { createAgent, listAgentsSnapshot } = await import('@electron/utils/agent-config');
|
||||||
|
|
||||||
|
await createAgent('测试2');
|
||||||
|
await createAgent('测试1');
|
||||||
|
|
||||||
|
const snapshot = await listAgentsSnapshot();
|
||||||
|
const agentIds = snapshot.agents.map((agent) => agent.id);
|
||||||
|
|
||||||
|
expect(agentIds).toContain('agent');
|
||||||
|
expect(agentIds).toContain('agent-2');
|
||||||
|
expect(agentIds).not.toContain('2');
|
||||||
|
expect(agentIds).not.toContain('1');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -250,3 +250,55 @@ describe('WeChat dangling plugin cleanup', () => {
|
|||||||
expect(existsSync(join(testHome, '.openclaw', 'openclaw-weixin'))).toBe(false);
|
expect(existsSync(join(testHome, '.openclaw', 'openclaw-weixin'))).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('configured channel account extraction', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
vi.resetModules();
|
||||||
|
await rm(testHome, { recursive: true, force: true });
|
||||||
|
await rm(testUserData, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores malformed array-shaped accounts and falls back to default account', async () => {
|
||||||
|
const { listConfiguredChannelAccountsFromConfig } = await import('@electron/utils/channel-config');
|
||||||
|
|
||||||
|
const result = listConfiguredChannelAccountsFromConfig({
|
||||||
|
channels: {
|
||||||
|
feishu: {
|
||||||
|
enabled: true,
|
||||||
|
defaultAccount: 'default',
|
||||||
|
accounts: [null, null, { appId: 'ghost-account' }],
|
||||||
|
appId: 'cli_real_app',
|
||||||
|
appSecret: 'real_secret',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.feishu).toEqual({
|
||||||
|
defaultAccountId: 'default',
|
||||||
|
accountIds: ['default'],
|
||||||
|
});
|
||||||
|
expect(result.feishu.accountIds).not.toContain('2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps intentionally configured numeric account ids from object-shaped accounts', async () => {
|
||||||
|
const { listConfiguredChannelAccountsFromConfig } = await import('@electron/utils/channel-config');
|
||||||
|
|
||||||
|
const result = listConfiguredChannelAccountsFromConfig({
|
||||||
|
channels: {
|
||||||
|
feishu: {
|
||||||
|
enabled: true,
|
||||||
|
defaultAccount: '2',
|
||||||
|
accounts: {
|
||||||
|
'2': { enabled: true, appId: 'cli_numeric' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.feishu).toEqual({
|
||||||
|
defaultAccountId: '2',
|
||||||
|
accountIds: ['2'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -274,6 +274,85 @@ describe('handleChannelRoutes', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('filters runtime-only stale accounts when not configured locally', async () => {
|
||||||
|
listConfiguredChannelsMock.mockResolvedValue(['feishu']);
|
||||||
|
listConfiguredChannelAccountsMock.mockResolvedValue({
|
||||||
|
feishu: {
|
||||||
|
defaultAccountId: 'default',
|
||||||
|
accountIds: ['default'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
readOpenClawConfigMock.mockResolvedValue({
|
||||||
|
channels: {
|
||||||
|
feishu: {
|
||||||
|
defaultAccount: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rpc = vi.fn().mockResolvedValue({
|
||||||
|
channels: {
|
||||||
|
feishu: {
|
||||||
|
configured: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
channelAccounts: {
|
||||||
|
feishu: [
|
||||||
|
{
|
||||||
|
accountId: 'default',
|
||||||
|
configured: true,
|
||||||
|
connected: true,
|
||||||
|
running: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountId: '2',
|
||||||
|
configured: false,
|
||||||
|
connected: false,
|
||||||
|
running: false,
|
||||||
|
lastError: 'stale runtime session',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
channelDefaultAccountId: {
|
||||||
|
feishu: 'default',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { handleChannelRoutes } = await import('@electron/api/routes/channels');
|
||||||
|
await handleChannelRoutes(
|
||||||
|
{ method: 'GET' } as IncomingMessage,
|
||||||
|
{} as ServerResponse,
|
||||||
|
new URL('http://127.0.0.1:13210/api/channels/accounts'),
|
||||||
|
{
|
||||||
|
gatewayManager: {
|
||||||
|
rpc,
|
||||||
|
getStatus: () => ({ state: 'running' }),
|
||||||
|
debouncedReload: vi.fn(),
|
||||||
|
debouncedRestart: vi.fn(),
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sendJsonMock).toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
200,
|
||||||
|
expect.objectContaining({
|
||||||
|
success: true,
|
||||||
|
channels: [
|
||||||
|
expect.objectContaining({
|
||||||
|
channelType: 'feishu',
|
||||||
|
accounts: [expect.objectContaining({ accountId: 'default' })],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const payload = sendJsonMock.mock.calls.at(-1)?.[2] as {
|
||||||
|
channels?: Array<{ channelType: string; accounts: Array<{ accountId: string }> }>;
|
||||||
|
};
|
||||||
|
const feishu = payload.channels?.find((entry) => entry.channelType === 'feishu');
|
||||||
|
expect(feishu?.accounts.map((entry) => entry.accountId)).toEqual(['default']);
|
||||||
|
});
|
||||||
|
|
||||||
it('lists known QQ Bot targets for a configured account', async () => {
|
it('lists known QQ Bot targets for a configured account', async () => {
|
||||||
const knownUsersPath = join(testOpenClawConfigDir, 'qqbot', 'data');
|
const knownUsersPath = join(testOpenClawConfigDir, 'qqbot', 'data');
|
||||||
mkdirSync(knownUsersPath, { recursive: true });
|
mkdirSync(knownUsersPath, { recursive: true });
|
||||||
|
|||||||
Reference in New Issue
Block a user