Refactor channel account management: move binding/editing to Channels, align Agents display, and simplify UX (#523)

This commit is contained in:
Felix
2026-03-16 18:20:11 +08:00
committed by GitHub
Unverified
parent db480dff17
commit 4be679ac56
20 changed files with 1192 additions and 346 deletions

View File

@@ -16,8 +16,7 @@ import { withConfigLock } from './config-mutex';
const OPENCLAW_DIR = join(homedir(), '.openclaw');
const CONFIG_FILE = join(OPENCLAW_DIR, 'openclaw.json');
const WECOM_PLUGIN_ID = 'wecom';
const FEISHU_PLUGIN_ID = 'openclaw-lark';
const LEGACY_FEISHU_PLUGIN_ID = 'feishu-openclaw-plugin';
const FEISHU_PLUGIN_ID_CANDIDATES = ['openclaw-lark', 'feishu-openclaw-plugin'] as const;
const DEFAULT_ACCOUNT_ID = 'default';
const CHANNEL_TOP_LEVEL_KEYS_TO_KEEP = new Set(['accounts', 'defaultAccount', 'enabled']);
@@ -53,6 +52,24 @@ function normalizeCredentialValue(value: string): string {
return value.trim();
}
async function resolveFeishuPluginId(): Promise<string> {
const extensionRoot = join(homedir(), '.openclaw', 'extensions');
for (const dirName of FEISHU_PLUGIN_ID_CANDIDATES) {
const manifestPath = join(extensionRoot, dirName, 'openclaw.plugin.json');
try {
const raw = await readFile(manifestPath, 'utf-8');
const parsed = JSON.parse(raw) as { id?: unknown };
if (typeof parsed.id === 'string' && parsed.id.trim()) {
return parsed.id.trim();
}
} catch {
// ignore and try next candidate
}
}
// Fallback to the modern id when extension manifests are not available yet.
return FEISHU_PLUGIN_ID_CANDIDATES[0];
}
// ── Types ────────────────────────────────────────────────────────
export interface ChannelConfigData {
@@ -121,14 +138,15 @@ export async function writeOpenClawConfig(config: OpenClawConfig): Promise<void>
// ── Channel operations ───────────────────────────────────────────
function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType: string): void {
async function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType: string): Promise<void> {
if (channelType === 'feishu') {
const feishuPluginId = await resolveFeishuPluginId();
if (!currentConfig.plugins) {
currentConfig.plugins = {
allow: [FEISHU_PLUGIN_ID],
allow: [feishuPluginId],
enabled: true,
entries: {
[FEISHU_PLUGIN_ID]: { enabled: true }
[feishuPluginId]: { enabled: true }
}
};
} else {
@@ -136,12 +154,12 @@ function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType: strin
const allow: string[] = Array.isArray(currentConfig.plugins.allow)
? (currentConfig.plugins.allow as string[])
: [];
// Remove legacy IDs: 'feishu' (built-in) and old 'feishu-openclaw-plugin'
// Keep only one active feishu plugin id to avoid doctor validation conflicts.
const normalizedAllow = allow.filter(
(pluginId) => pluginId !== 'feishu' && pluginId !== LEGACY_FEISHU_PLUGIN_ID
(pluginId) => pluginId !== 'feishu' && !FEISHU_PLUGIN_ID_CANDIDATES.includes(pluginId as typeof FEISHU_PLUGIN_ID_CANDIDATES[number])
);
if (!normalizedAllow.includes(FEISHU_PLUGIN_ID)) {
currentConfig.plugins.allow = [...normalizedAllow, FEISHU_PLUGIN_ID];
if (!normalizedAllow.includes(feishuPluginId)) {
currentConfig.plugins.allow = [...normalizedAllow, feishuPluginId];
} else if (normalizedAllow.length !== allow.length) {
currentConfig.plugins.allow = normalizedAllow;
}
@@ -149,14 +167,18 @@ function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType: strin
if (!currentConfig.plugins.entries) {
currentConfig.plugins.entries = {};
}
// Remove legacy entries that would conflict with the current plugin ID
// Remove conflicting feishu entries; keep only the resolved plugin id.
delete currentConfig.plugins.entries['feishu'];
delete currentConfig.plugins.entries[LEGACY_FEISHU_PLUGIN_ID];
if (!currentConfig.plugins.entries[FEISHU_PLUGIN_ID]) {
currentConfig.plugins.entries[FEISHU_PLUGIN_ID] = {};
for (const candidateId of FEISHU_PLUGIN_ID_CANDIDATES) {
if (candidateId !== feishuPluginId) {
delete currentConfig.plugins.entries[candidateId];
}
}
currentConfig.plugins.entries[FEISHU_PLUGIN_ID].enabled = true;
if (!currentConfig.plugins.entries[feishuPluginId]) {
currentConfig.plugins.entries[feishuPluginId] = {};
}
currentConfig.plugins.entries[feishuPluginId].enabled = true;
}
}
@@ -407,7 +429,7 @@ export async function saveChannelConfig(
const currentConfig = await readOpenClawConfig();
const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID;
ensurePluginAllowlist(currentConfig, channelType);
await ensurePluginAllowlist(currentConfig, channelType);
// Plugin-based channels (e.g. WhatsApp) go under plugins.entries, not channels
if (PLUGIN_CHANNELS.includes(channelType)) {
@@ -587,9 +609,23 @@ export async function deleteChannelAccountConfig(channelType: string, accountId:
if (Object.keys(accounts).length === 0) {
delete currentConfig.channels![channelType];
} else {
if (channelSection.defaultAccount === accountId) {
const nextDefaultAccountId = Object.keys(accounts).sort((a, b) => {
if (a === DEFAULT_ACCOUNT_ID) return -1;
if (b === DEFAULT_ACCOUNT_ID) return 1;
return a.localeCompare(b);
})[0];
if (nextDefaultAccountId) {
channelSection.defaultAccount = nextDefaultAccountId;
}
}
// Re-mirror default account credentials to top level after migration
// stripped them (same rationale as saveChannelConfig).
const defaultAccountData = accounts[DEFAULT_ACCOUNT_ID];
const mirroredAccountId =
typeof channelSection.defaultAccount === 'string' && channelSection.defaultAccount.trim()
? channelSection.defaultAccount
: DEFAULT_ACCOUNT_ID;
const defaultAccountData = accounts[mirroredAccountId] ?? accounts[DEFAULT_ACCOUNT_ID];
if (defaultAccountData) {
for (const [key, value] of Object.entries(defaultAccountData)) {
channelSection[key] = value;
@@ -686,7 +722,85 @@ export async function listConfiguredChannels(): Promise<string[]> {
return channels;
}
export async function deleteAgentChannelAccounts(agentId: string): Promise<void> {
export interface ConfiguredChannelAccounts {
defaultAccountId: string;
accountIds: string[];
}
export async function listConfiguredChannelAccounts(): Promise<Record<string, ConfiguredChannelAccounts>> {
const config = await readOpenClawConfig();
const result: Record<string, ConfiguredChannelAccounts> = {};
if (!config.channels) {
return result;
}
for (const [channelType, section] of Object.entries(config.channels)) {
if (!section || section.enabled === false) continue;
const accountIds = section.accounts && typeof section.accounts === 'object'
? Object.keys(section.accounts).filter(Boolean)
: [];
const defaultAccountId = typeof section.defaultAccount === 'string' && section.defaultAccount.trim()
? section.defaultAccount
: DEFAULT_ACCOUNT_ID;
if (accountIds.length === 0) {
const hasAnyPayload = Object.keys(section).some((key) => !CHANNEL_TOP_LEVEL_KEYS_TO_KEEP.has(key));
if (!hasAnyPayload) continue;
result[channelType] = {
defaultAccountId,
accountIds: [DEFAULT_ACCOUNT_ID],
};
continue;
}
result[channelType] = {
defaultAccountId,
accountIds: accountIds.sort((a, b) => {
if (a === DEFAULT_ACCOUNT_ID) return -1;
if (b === DEFAULT_ACCOUNT_ID) return 1;
return a.localeCompare(b);
}),
};
}
return result;
}
export async function setChannelDefaultAccount(channelType: string, accountId: string): Promise<void> {
return withConfigLock(async () => {
const trimmedAccountId = accountId.trim();
if (!trimmedAccountId) {
throw new Error('accountId is required');
}
const currentConfig = await readOpenClawConfig();
const channelSection = currentConfig.channels?.[channelType];
if (!channelSection) {
throw new Error(`Channel "${channelType}" is not configured`);
}
migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID);
const accounts = channelSection.accounts as Record<string, ChannelConfigData> | undefined;
if (!accounts || !accounts[trimmedAccountId]) {
throw new Error(`Account "${trimmedAccountId}" is not configured for channel "${channelType}"`);
}
channelSection.defaultAccount = trimmedAccountId;
const defaultAccountData = accounts[trimmedAccountId];
for (const [key, value] of Object.entries(defaultAccountData)) {
channelSection[key] = value;
}
await writeOpenClawConfig(currentConfig);
logger.info('Set channel default account', { channelType, accountId: trimmedAccountId });
});
}
export async function deleteAgentChannelAccounts(agentId: string, ownedChannelAccounts?: Set<string>): Promise<void> {
return withConfigLock(async () => {
const currentConfig = await readOpenClawConfig();
if (!currentConfig.channels) return;
@@ -699,14 +813,31 @@ export async function deleteAgentChannelAccounts(agentId: string): Promise<void>
migrateLegacyChannelConfigToAccounts(section, DEFAULT_ACCOUNT_ID);
const accounts = section.accounts as Record<string, ChannelConfigData> | undefined;
if (!accounts?.[accountId]) continue;
if (ownedChannelAccounts && !ownedChannelAccounts.has(`${channelType}:${accountId}`)) {
continue;
}
delete accounts[accountId];
if (Object.keys(accounts).length === 0) {
delete currentConfig.channels[channelType];
} else {
if (section.defaultAccount === accountId) {
const nextDefaultAccountId = Object.keys(accounts).sort((a, b) => {
if (a === DEFAULT_ACCOUNT_ID) return -1;
if (b === DEFAULT_ACCOUNT_ID) return 1;
return a.localeCompare(b);
})[0];
if (nextDefaultAccountId) {
section.defaultAccount = nextDefaultAccountId;
}
}
// Re-mirror default account credentials to top level after migration
// stripped them (same rationale as saveChannelConfig).
const defaultAccountData = accounts[DEFAULT_ACCOUNT_ID];
const mirroredAccountId =
typeof section.defaultAccount === 'string' && section.defaultAccount.trim()
? section.defaultAccount
: DEFAULT_ACCOUNT_ID;
const defaultAccountData = accounts[mirroredAccountId] ?? accounts[DEFAULT_ACCOUNT_ID];
if (defaultAccountData) {
for (const [key, value] of Object.entries(defaultAccountData)) {
section[key] = value;