Files

1724 lines
68 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Channel Configuration Utilities
* Manages channel configuration in OpenClaw config files.
*
* All file I/O uses async fs/promises to avoid blocking the main thread.
*/
import { access, mkdir, readFile, writeFile, readdir, stat, rm } from 'fs/promises';
import { constants } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { mutateOpenClawConfig, readOpenClawConfigSnapshot } from '../gateway/config-delivery';
import { getOpenClawResolvedDir, resolveOpenClawConfigPath } from './paths';
import * as logger from './logger';
import { proxyAwareFetch } from './proxy-fetch';
import {
OPENCLAW_WECHAT_CHANNEL_TYPE,
isWechatChannelType,
normalizeOpenClawAccountId,
toOpenClawChannelType,
} from './channel-alias';
const OPENCLAW_DIR = join(homedir(), '.openclaw');
const WECOM_PLUGIN_ID = 'wecom';
const WECHAT_PLUGIN_ID = OPENCLAW_WECHAT_CHANNEL_TYPE;
const FEISHU_PLUGIN_ID_CANDIDATES = ['openclaw-lark', 'feishu-openclaw-plugin'] as const;
const DEFAULT_ACCOUNT_ID = 'default';
// Channels whose top-level schema (additionalProperties:false) does NOT
// include `defaultAccount`. We still use the multi-account `accounts`
// map, but strip `defaultAccount` before persisting to avoid plugin
// schema validation errors. ClawX falls back to DEFAULT_ACCOUNT_ID
// when `defaultAccount` is absent.
const CHANNELS_OMIT_DEFAULT_ACCOUNT_KEY = new Set(['dingtalk']);
// Channels whose schema accepts a top-level default account and account map,
// but whose account payload contains nested strict-schema objects that ClawX
// can accidentally make invalid by adding UI convenience fields. Keep this
// sanitization narrowly scoped to known nested maps so local config remains
// OpenClaw-compatible after a save.
const DISCORD_GUILD_CHANNEL_KEYS_TO_KEEP = new Set([
'autoArchiveDuration',
'autoThread',
'autoThreadName',
'enabled',
'ignoreOtherMentions',
'includeThreadStarter',
'requireMention',
'roles',
'skills',
'systemPrompt',
'tools',
'toolsBySender',
'users',
]);
const DISCORD_CHANNEL_ALLOW_FLAG_KEYS = new Set(['allow']);
const CHANNEL_TOP_LEVEL_KEYS_TO_KEEP = new Set(['accounts', 'defaultAccount', 'enabled']);
const WECHAT_STATE_DIR = join(OPENCLAW_DIR, WECHAT_PLUGIN_ID);
const WECHAT_ACCOUNT_INDEX_FILE = join(WECHAT_STATE_DIR, 'accounts.json');
const WECHAT_ACCOUNTS_DIR = join(WECHAT_STATE_DIR, 'accounts');
const LEGACY_WECHAT_CREDENTIALS_DIR = join(OPENCLAW_DIR, 'credentials', WECHAT_PLUGIN_ID);
const LEGACY_WECHAT_SYNC_DIR = join(OPENCLAW_DIR, 'agents', 'default', 'sessions', '.openclaw-weixin-sync');
// External plugins whose activation lives in plugins.entries while account
// configuration remains exclusively under channels.<id>.
const PLUGIN_CHANNELS: string[] = ['discord', 'qqbot', 'whatsapp'];
const LEGACY_BUILTIN_CHANNEL_PLUGIN_IDS = new Set<string>();
// OpenClaw 2026.7.1 bundles only these channel extensions. All other ClawX
// channels must retain their explicit external plugin allowlist entries.
const BUILTIN_CHANNEL_IDS = new Set(['telegram', 'imessage']);
// Unique credential key per channel type used for duplicate bot detection.
// Maps each channel type to the field that uniquely identifies a bot/account.
// When two agents try to use the same value for this field, the save is rejected.
const CHANNEL_UNIQUE_CREDENTIAL_KEY: Record<string, string> = {
feishu: 'appId',
wecom: 'botId',
dingtalk: 'clientId',
telegram: 'botToken',
discord: 'token',
qqbot: 'appId',
signal: 'phoneNumber',
imessage: 'serverUrl',
matrix: 'accessToken',
line: 'channelAccessToken',
msteams: 'appId',
googlechat: 'serviceAccountKey',
mattermost: 'botToken',
};
// ── Helpers ──────────────────────────────────────────────────────
function sanitizeDiscordGuildChannelConfig(channelConfig: unknown): void {
if (!channelConfig || typeof channelConfig !== 'object' || Array.isArray(channelConfig)) {
return;
}
const record = channelConfig as Record<string, unknown>;
// Backward compatibility for the older ClawX-generated shape:
// channels: { "123": { allow: true, requireMention: true } }
// OpenClaw's current DiscordGuildChannelConfig does not include `allow`;
// represent deny/allow using `enabled` instead.
if (record.allow === false && record.enabled === undefined) {
record.enabled = false;
}
for (const key of Object.keys(record)) {
if (DISCORD_CHANNEL_ALLOW_FLAG_KEYS.has(key)) {
delete record[key];
continue;
}
if (!DISCORD_GUILD_CHANNEL_KEYS_TO_KEEP.has(key)) {
delete record[key];
}
}
}
function sanitizeDiscordGuilds(config: unknown): void {
if (!config || typeof config !== 'object' || Array.isArray(config)) {
return;
}
const record = config as Record<string, unknown>;
const guilds = record.guilds;
if (!guilds || typeof guilds !== 'object' || Array.isArray(guilds)) {
return;
}
for (const guildConfig of Object.values(guilds as Record<string, unknown>)) {
if (!guildConfig || typeof guildConfig !== 'object' || Array.isArray(guildConfig)) {
continue;
}
const channels = (guildConfig as Record<string, unknown>).channels;
if (!channels || typeof channels !== 'object' || Array.isArray(channels)) {
continue;
}
for (const channelConfig of Object.values(channels as Record<string, unknown>)) {
sanitizeDiscordGuildChannelConfig(channelConfig);
}
}
}
/**
* Strip `defaultAccount` from channel sections whose plugin schema
* declares additionalProperties:false without listing `defaultAccount`.
* Call before committing channel-config mutations.
*/
function sanitizeChannelSectionsBeforeWrite(config: OpenClawConfig): void {
for (const pluginId of PLUGIN_CHANNELS) {
const pluginEntry = config.plugins?.entries?.[pluginId];
if (!pluginEntry) continue;
delete pluginEntry.accounts;
delete pluginEntry.defaultAccount;
}
if (!config.channels) return;
for (const channelType of CHANNELS_OMIT_DEFAULT_ACCOUNT_KEY) {
const section = config.channels[channelType];
if (section) {
delete section.defaultAccount;
}
}
const discordSection = config.channels.discord;
if (discordSection) {
sanitizeDiscordGuilds(discordSection);
const accounts = getChannelAccountsMap(discordSection);
if (accounts) {
for (const accountConfig of Object.values(accounts)) {
sanitizeDiscordGuilds(accountConfig);
}
}
}
}
async function fileExists(p: string): Promise<boolean> {
try { await access(p, constants.F_OK); return true; } catch { return false; }
}
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];
}
function resolveStoredChannelType(channelType: string): string {
return toOpenClawChannelType(channelType);
}
function deriveLegacyWeChatRawAccountId(normalizedId: string): string | undefined {
if (normalizedId.endsWith('-im-bot')) {
return `${normalizedId.slice(0, -7)}@im.bot`;
}
if (normalizedId.endsWith('-im-wechat')) {
return `${normalizedId.slice(0, -10)}@im.wechat`;
}
return undefined;
}
async function readWeChatAccountIndex(): Promise<string[]> {
try {
const raw = await readFile(WECHAT_ACCOUNT_INDEX_FILE, 'utf-8');
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0);
} catch {
return [];
}
}
async function writeWeChatAccountIndex(accountIds: string[]): Promise<void> {
await mkdir(WECHAT_STATE_DIR, { recursive: true });
await writeFile(WECHAT_ACCOUNT_INDEX_FILE, JSON.stringify(accountIds, null, 2), 'utf-8');
}
async function deleteWeChatAccountState(accountId: string): Promise<void> {
const normalizedAccountId = normalizeOpenClawAccountId(accountId);
const legacyRawAccountId = deriveLegacyWeChatRawAccountId(normalizedAccountId);
const candidateIds = new Set<string>([normalizedAccountId]);
if (legacyRawAccountId) {
candidateIds.add(legacyRawAccountId);
}
if (accountId.trim()) {
candidateIds.add(accountId.trim());
}
for (const candidateId of candidateIds) {
await rm(join(WECHAT_ACCOUNTS_DIR, `${candidateId}.json`), { force: true });
}
const existingAccountIds = await readWeChatAccountIndex();
const nextAccountIds = existingAccountIds.filter((entry) => !candidateIds.has(entry));
if (nextAccountIds.length !== existingAccountIds.length) {
if (nextAccountIds.length === 0) {
await rm(WECHAT_ACCOUNT_INDEX_FILE, { force: true });
} else {
await writeWeChatAccountIndex(nextAccountIds);
}
}
}
async function deleteWeChatState(): Promise<void> {
await rm(WECHAT_STATE_DIR, { recursive: true, force: true });
await rm(LEGACY_WECHAT_CREDENTIALS_DIR, { recursive: true, force: true });
await rm(LEGACY_WECHAT_SYNC_DIR, { recursive: true, force: true });
}
function removePluginRegistration(currentConfig: OpenClawConfig, pluginId: string): boolean {
if (!currentConfig.plugins) return false;
let modified = false;
if (Array.isArray(currentConfig.plugins.allow)) {
const nextAllow = currentConfig.plugins.allow.filter((entry) => entry !== pluginId);
if (nextAllow.length !== currentConfig.plugins.allow.length) {
currentConfig.plugins.allow = nextAllow;
modified = true;
}
if (nextAllow.length === 0) {
delete currentConfig.plugins.allow;
}
}
if (currentConfig.plugins.entries && currentConfig.plugins.entries[pluginId]) {
delete currentConfig.plugins.entries[pluginId];
modified = true;
if (Object.keys(currentConfig.plugins.entries).length === 0) {
delete currentConfig.plugins.entries;
}
}
if (
currentConfig.plugins.enabled !== undefined
&& !currentConfig.plugins.allow?.length
&& !currentConfig.plugins.entries
) {
delete currentConfig.plugins.enabled;
modified = true;
}
if (Object.keys(currentConfig.plugins).length === 0) {
delete currentConfig.plugins;
modified = true;
}
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 {
if (!channelSection || typeof channelSection !== 'object') return false;
const accounts = getChannelAccountsMap(channelSection);
if (accounts) {
return Object.keys(accounts).some((accountId) => accountId.trim().length > 0);
}
return Object.keys(channelSection).some((key) => !CHANNEL_TOP_LEVEL_KEYS_TO_KEEP.has(key));
}
function ensurePluginRegistration(currentConfig: OpenClawConfig, pluginId: string): void {
if (!currentConfig.plugins) {
currentConfig.plugins = {
allow: [pluginId],
enabled: true,
entries: {
[pluginId]: { enabled: true },
},
};
return;
}
currentConfig.plugins.enabled = true;
const allow = Array.isArray(currentConfig.plugins.allow)
? currentConfig.plugins.allow as string[]
: [];
if (!allow.includes(pluginId)) {
currentConfig.plugins.allow = [...allow, pluginId];
}
if (!currentConfig.plugins.entries) {
currentConfig.plugins.entries = {};
}
if (!currentConfig.plugins.entries[pluginId]) {
currentConfig.plugins.entries[pluginId] = {};
}
const pluginEntry = currentConfig.plugins.entries[pluginId];
// PluginEntryConfig contains plugin activation/config metadata, not channel
// accounts. Older ClawX versions mirrored credentials here, which OpenClaw
// 2026.7.1 rejects as an invalid plugins.entries.<id> shape.
delete pluginEntry.accounts;
delete pluginEntry.defaultAccount;
pluginEntry.enabled = true;
}
function syncPluginChannelRegistration(currentConfig: OpenClawConfig, channelType: string): void {
if (!PLUGIN_CHANNELS.includes(channelType)) return;
const channelSection = currentConfig.channels?.[channelType];
if (!channelSection) {
removePluginRegistration(currentConfig, channelType);
return;
}
ensurePluginRegistration(currentConfig, channelType);
currentConfig.plugins!.entries![channelType].enabled = channelSection.enabled !== false;
}
function cleanupLegacyBuiltInChannelPluginRegistration(
currentConfig: OpenClawConfig,
channelType: string,
): boolean {
if (!LEGACY_BUILTIN_CHANNEL_PLUGIN_IDS.has(channelType)) {
return false;
}
return removePluginRegistration(currentConfig, channelType);
}
function isBuiltinChannelId(channelId: string): boolean {
return BUILTIN_CHANNEL_IDS.has(channelId);
}
function listConfiguredBuiltinChannels(
currentConfig: OpenClawConfig,
additionalChannelIds: string[] = [],
): string[] {
const configured = new Set<string>();
const channels = currentConfig.channels ?? {};
for (const [channelId, section] of Object.entries(channels)) {
if (!isBuiltinChannelId(channelId)) continue;
if (!section || section.enabled === false) continue;
if (channelHasAnyAccount(section) || Object.keys(section).length > 0) {
configured.add(channelId);
}
}
for (const channelId of additionalChannelIds) {
if (isBuiltinChannelId(channelId)) {
configured.add(channelId);
}
}
return Array.from(configured);
}
function syncBuiltinChannelsWithPluginAllowlist(
currentConfig: OpenClawConfig,
additionalBuiltinChannelIds: string[] = [],
): void {
const plugins = currentConfig.plugins;
if (!plugins || !Array.isArray(plugins.allow)) {
return;
}
const configuredBuiltins = new Set(listConfiguredBuiltinChannels(currentConfig, additionalBuiltinChannelIds));
const existingAllow = plugins.allow as string[];
const externalPluginIds = existingAllow.filter((pluginId) => !isBuiltinChannelId(pluginId));
let nextAllow = [...externalPluginIds];
if (externalPluginIds.length > 0) {
nextAllow = [
...nextAllow,
...Array.from(configuredBuiltins).filter((channelId) => !nextAllow.includes(channelId)),
];
}
if (nextAllow.length > 0) {
plugins.allow = nextAllow;
} else {
delete plugins.allow;
}
}
// ── Types ────────────────────────────────────────────────────────
export interface ChannelConfigData {
enabled?: boolean;
[key: string]: unknown;
}
export interface PluginsConfig {
entries?: Record<string, ChannelConfigData>;
allow?: string[];
enabled?: boolean;
[key: string]: unknown;
}
export interface OpenClawConfig {
channels?: Record<string, ChannelConfigData>;
plugins?: PluginsConfig;
commands?: Record<string, unknown>;
[key: string]: unknown;
}
// ── Config I/O ───────────────────────────────────────────────────
export async function readOpenClawConfig(): Promise<OpenClawConfig> {
try {
const snapshot = await readOpenClawConfigSnapshot();
return snapshot.config as OpenClawConfig;
} catch (error) {
logger.error('Failed to read OpenClaw config', error);
console.error('Failed to read OpenClaw config:', error);
return {};
}
}
// ── Channel operations ───────────────────────────────────────────
async function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType: string): Promise<void> {
if (PLUGIN_CHANNELS.includes(channelType)) {
ensurePluginRegistration(currentConfig, channelType);
}
if (channelType === 'feishu') {
const feishuPluginId = await resolveFeishuPluginId();
if (!currentConfig.plugins) {
currentConfig.plugins = {
allow: [feishuPluginId],
enabled: true,
entries: {
[feishuPluginId]: { enabled: true },
}
};
} else {
currentConfig.plugins.enabled = true;
const allow: string[] = Array.isArray(currentConfig.plugins.allow)
? (currentConfig.plugins.allow as string[])
: [];
// Keep only one active feishu plugin id to avoid doctor validation conflicts.
const normalizedAllow = allow.filter(
(pluginId) => pluginId !== 'feishu' && !FEISHU_PLUGIN_ID_CANDIDATES.includes(pluginId as typeof FEISHU_PLUGIN_ID_CANDIDATES[number])
);
if (!normalizedAllow.includes(feishuPluginId)) {
currentConfig.plugins.allow = [...normalizedAllow, feishuPluginId];
} else if (normalizedAllow.length !== allow.length) {
currentConfig.plugins.allow = normalizedAllow;
}
if (!currentConfig.plugins.entries) {
currentConfig.plugins.entries = {};
}
// Remove conflicting feishu plugin entries; keep only the resolved
// external plugin id. A disabled plugins.entries.feishu record
// blocks openclaw-lark in OpenClaw's gateway startup planner.
delete currentConfig.plugins.entries['feishu'];
for (const candidateId of FEISHU_PLUGIN_ID_CANDIDATES) {
if (candidateId !== feishuPluginId) {
delete currentConfig.plugins.entries[candidateId];
}
}
if (!currentConfig.plugins.entries[feishuPluginId]) {
currentConfig.plugins.entries[feishuPluginId] = {};
}
currentConfig.plugins.entries[feishuPluginId].enabled = true;
}
}
if (channelType === 'dingtalk') {
if (!currentConfig.plugins) {
currentConfig.plugins = { allow: ['dingtalk'], enabled: true };
} else {
currentConfig.plugins.enabled = true;
const allow: string[] = Array.isArray(currentConfig.plugins.allow)
? (currentConfig.plugins.allow as string[])
: [];
if (!allow.includes('dingtalk')) {
currentConfig.plugins.allow = [...allow, 'dingtalk'];
}
}
}
if (channelType === 'wecom') {
if (!currentConfig.plugins) {
currentConfig.plugins = {
allow: [WECOM_PLUGIN_ID],
enabled: true,
entries: {
[WECOM_PLUGIN_ID]: { enabled: true }
}
};
} else {
currentConfig.plugins.enabled = true;
const allow: string[] = Array.isArray(currentConfig.plugins.allow)
? (currentConfig.plugins.allow as string[])
: [];
const normalizedAllow = allow.filter((pluginId) => pluginId !== 'wecom');
if (!normalizedAllow.includes(WECOM_PLUGIN_ID)) {
currentConfig.plugins.allow = [...normalizedAllow, WECOM_PLUGIN_ID];
} else if (normalizedAllow.length !== allow.length) {
currentConfig.plugins.allow = normalizedAllow;
}
if (!currentConfig.plugins.entries) {
currentConfig.plugins.entries = {};
}
if (!currentConfig.plugins.entries[WECOM_PLUGIN_ID]) {
currentConfig.plugins.entries[WECOM_PLUGIN_ID] = {};
}
currentConfig.plugins.entries[WECOM_PLUGIN_ID].enabled = true;
}
}
if (channelType === WECHAT_PLUGIN_ID) {
if (!currentConfig.plugins) {
currentConfig.plugins = {
allow: [WECHAT_PLUGIN_ID],
enabled: true,
entries: {
[WECHAT_PLUGIN_ID]: { enabled: true },
},
};
return;
}
currentConfig.plugins.enabled = true;
const allow = Array.isArray(currentConfig.plugins.allow)
? currentConfig.plugins.allow as string[]
: [];
if (!allow.includes(WECHAT_PLUGIN_ID)) {
currentConfig.plugins.allow = [...allow, WECHAT_PLUGIN_ID];
}
if (!currentConfig.plugins.entries) {
currentConfig.plugins.entries = {};
}
if (!currentConfig.plugins.entries[WECHAT_PLUGIN_ID]) {
currentConfig.plugins.entries[WECHAT_PLUGIN_ID] = {};
}
currentConfig.plugins.entries[WECHAT_PLUGIN_ID].enabled = true;
}
}
function transformChannelConfig(
channelType: string,
config: ChannelConfigData,
existingAccountConfig: ChannelConfigData,
): ChannelConfigData {
let transformedConfig: ChannelConfigData = { ...config };
if (channelType === 'discord') {
const { guildId, channelId, ...restConfig } = config;
transformedConfig = { ...restConfig };
transformedConfig.groupPolicy = 'allowlist';
transformedConfig.dm = { enabled: false };
transformedConfig.retry = {
attempts: 3,
minDelayMs: 500,
maxDelayMs: 30000,
jitter: 0.1,
};
if (guildId && typeof guildId === 'string' && guildId.trim()) {
const guildConfig: Record<string, unknown> = {
users: ['*'],
requireMention: true,
};
if (channelId && typeof channelId === 'string' && channelId.trim()) {
guildConfig.channels = {
[channelId.trim()]: { requireMention: true }
};
} else {
guildConfig.channels = {
'*': { requireMention: true }
};
}
transformedConfig.guilds = {
[guildId.trim()]: guildConfig
};
}
}
if (channelType === 'telegram') {
const { allowedUsers, ...restConfig } = config;
transformedConfig = { ...restConfig };
if (allowedUsers && typeof allowedUsers === 'string') {
const users = allowedUsers.split(',')
.map(u => u.trim())
.filter(u => u.length > 0);
if (users.length > 0) {
transformedConfig.allowFrom = users;
}
}
}
if (channelType === 'feishu' || channelType === 'wecom') {
const existingDmPolicy = existingAccountConfig.dmPolicy === 'pairing' ? 'open' : existingAccountConfig.dmPolicy;
transformedConfig.dmPolicy = transformedConfig.dmPolicy ?? existingDmPolicy ?? 'open';
let allowFrom = (transformedConfig.allowFrom ?? existingAccountConfig.allowFrom ?? ['*']) as string[];
if (!Array.isArray(allowFrom)) {
allowFrom = [allowFrom] as string[];
}
if (transformedConfig.dmPolicy === 'open' && !allowFrom.includes('*')) {
allowFrom = [...allowFrom, '*'];
}
transformedConfig.allowFrom = allowFrom;
}
if (channelType === 'whatsapp') {
// The WhatsApp plugin stores QR/session state on disk and does not
// require static credentials, but the runtime still needs an enabled
// plugin config entry for the channel account to appear in status.
transformedConfig = {
...transformedConfig,
enabled: transformedConfig.enabled ?? true,
};
}
if (channelType === 'dingtalk') {
// The per-account schema uses additionalProperties:false and does
// NOT include these legacy/obsolete fields. Strip them before
// writing to accounts.<id> to avoid schema validation errors.
// robotCode never existed in the plugin schema; clientId IS the robot code
// corpId top-level only, legacy compat, runtime ignores it
// agentId top-level only, legacy compat, runtime ignores it
delete transformedConfig.robotCode;
delete transformedConfig.corpId;
delete transformedConfig.agentId;
}
return transformedConfig;
}
function resolveAccountConfig(
channelSection: ChannelConfigData | undefined,
accountId: string,
): ChannelConfigData {
if (!channelSection) return {};
const accounts = getChannelAccountsMap(channelSection);
return accounts?.[accountId] ?? {};
}
function getLegacyChannelPayload(channelSection: ChannelConfigData): ChannelConfigData {
const payload: ChannelConfigData = {};
for (const [key, value] of Object.entries(channelSection)) {
if (CHANNEL_TOP_LEVEL_KEYS_TO_KEEP.has(key)) continue;
payload[key] = value;
}
return payload;
}
function migrateLegacyChannelConfigToAccounts(
channelSection: ChannelConfigData,
defaultAccountId: string = DEFAULT_ACCOUNT_ID,
): void {
const legacyPayload = getLegacyChannelPayload(channelSection);
const legacyKeys = Object.keys(legacyPayload);
const existingAccounts = getChannelAccountsMap(channelSection);
const hasAccounts = existingAccounts ? Object.keys(existingAccounts).length > 0 : false;
if (legacyKeys.length === 0) {
if (hasAccounts && typeof channelSection.defaultAccount !== 'string') {
channelSection.defaultAccount = defaultAccountId;
}
return;
}
const accounts = ensureChannelAccountsMap(channelSection);
const existingDefaultAccount = accounts[defaultAccountId] ?? {};
accounts[defaultAccountId] = {
...(channelSection.enabled !== undefined ? { enabled: channelSection.enabled } : {}),
...legacyPayload,
...existingDefaultAccount,
};
channelSection.defaultAccount =
typeof channelSection.defaultAccount === 'string' && channelSection.defaultAccount.trim()
? channelSection.defaultAccount
: defaultAccountId;
for (const key of legacyKeys) {
delete channelSection[key];
}
}
/**
* Throws if the unique credential (e.g. appId for Feishu) in `config` is
* already registered under a *different* account in the same channel section.
* This prevents two agents from silently sharing the same bot connection.
*/
function assertNoDuplicateCredential(
channelType: string,
config: ChannelConfigData,
channelSection: ChannelConfigData,
resolvedAccountId: string,
): void {
const uniqueKey = CHANNEL_UNIQUE_CREDENTIAL_KEY[channelType];
if (!uniqueKey) return;
const incomingValue = config[uniqueKey];
if (typeof incomingValue !== 'string') return;
const normalizedIncomingValue = normalizeCredentialValue(incomingValue);
if (!normalizedIncomingValue) return;
if (normalizedIncomingValue !== incomingValue) {
logger.warn('Normalized channel credential value for duplicate check', {
channelType,
accountId: resolvedAccountId,
key: uniqueKey,
});
}
const accounts = getChannelAccountsMap(channelSection);
if (!accounts) return;
for (const [existingAccountId, accountCfg] of Object.entries(accounts)) {
if (existingAccountId === resolvedAccountId) continue;
if (!accountCfg || typeof accountCfg !== 'object') continue;
const existingValue = accountCfg[uniqueKey];
if (
typeof existingValue === 'string'
&& normalizeCredentialValue(existingValue) === normalizedIncomingValue
) {
throw new Error(
`The ${channelType} bot (${uniqueKey}: ${normalizedIncomingValue}) is already bound to another agent (account: ${existingAccountId}). ` +
`Each agent must use a unique bot.`,
);
}
}
}
export async function saveChannelConfig(
channelType: string,
config: ChannelConfigData,
accountId?: string,
): Promise<void> {
const resolvedChannelType = resolveStoredChannelType(channelType);
const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID;
let transformedKeys: string[] = [];
await mutateOpenClawConfig(async (snapshot) => {
const currentConfig = snapshot as OpenClawConfig;
cleanupLegacyBuiltInChannelPluginRegistration(currentConfig, resolvedChannelType);
await ensurePluginAllowlist(currentConfig, resolvedChannelType);
syncBuiltinChannelsWithPluginAllowlist(currentConfig, [resolvedChannelType]);
// Channel credentials always live under channels.<id>. External plugin
// entries carry activation metadata only.
if (!currentConfig.channels) {
currentConfig.channels = {};
}
if (!currentConfig.channels[resolvedChannelType]) {
currentConfig.channels[resolvedChannelType] = {};
}
const channelSection = currentConfig.channels[resolvedChannelType];
migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID);
// Guard: reject if this bot/app credential is already used by another account.
assertNoDuplicateCredential(resolvedChannelType, config, channelSection, resolvedAccountId);
const existingAccountConfig = resolveAccountConfig(channelSection, resolvedAccountId);
const transformedConfig = transformChannelConfig(resolvedChannelType, config, existingAccountConfig);
transformedKeys = Object.keys(transformedConfig);
const uniqueKey = CHANNEL_UNIQUE_CREDENTIAL_KEY[resolvedChannelType];
if (uniqueKey && typeof transformedConfig[uniqueKey] === 'string') {
const rawCredentialValue = transformedConfig[uniqueKey] as string;
const normalizedCredentialValue = normalizeCredentialValue(rawCredentialValue);
if (normalizedCredentialValue !== rawCredentialValue) {
logger.warn('Normalizing channel credential value before save', {
channelType: resolvedChannelType,
accountId: resolvedAccountId,
key: uniqueKey,
});
transformedConfig[uniqueKey] = normalizedCredentialValue;
}
}
// ── Write into accounts.<accountId> (multi-account support) ───
const accounts = ensureChannelAccountsMap(channelSection);
channelSection.defaultAccount =
typeof channelSection.defaultAccount === 'string' && channelSection.defaultAccount.trim()
? channelSection.defaultAccount
: resolvedAccountId;
accounts[resolvedAccountId] = {
...accounts[resolvedAccountId],
...transformedConfig,
enabled: transformedConfig.enabled ?? true,
};
// Keep channel-level enabled explicit so callers/tests that
// read channels.<type>.enabled still work.
channelSection.enabled = transformedConfig.enabled ?? channelSection.enabled ?? true;
syncPluginChannelRegistration(currentConfig, resolvedChannelType);
// Most OpenClaw channel plugins/built-ins also read the default
// account's credentials from the top level of `channels.<type>`
// (e.g. channels.feishu.appId). Mirror them there so the
// runtime can discover them.
const mirroredAccountId =
typeof channelSection.defaultAccount === 'string' && channelSection.defaultAccount.trim()
? channelSection.defaultAccount
: resolvedAccountId;
const defaultAccountData = accounts[mirroredAccountId] ?? accounts[resolvedAccountId] ?? accounts[DEFAULT_ACCOUNT_ID];
if (defaultAccountData) {
for (const [key, value] of Object.entries(defaultAccountData)) {
channelSection[key] = value;
}
}
sanitizeChannelSectionsBeforeWrite(currentConfig);
});
logger.info('Channel config saved', {
channelType: resolvedChannelType,
accountId: resolvedAccountId,
configFile: resolveOpenClawConfigPath(),
rawKeys: Object.keys(config),
transformedKeys,
});
console.log(`Saved channel config for ${resolvedChannelType} account ${resolvedAccountId}`);
}
export async function getChannelConfig(channelType: string, accountId?: string): Promise<ChannelConfigData | undefined> {
const resolvedChannelType = resolveStoredChannelType(channelType);
const config = await readOpenClawConfig();
const channelSection = config.channels?.[resolvedChannelType];
if (!channelSection) return undefined;
const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID;
const accounts = getChannelAccountsMap(channelSection);
if (accounts?.[resolvedAccountId]) {
return accounts[resolvedAccountId];
}
// Backward compat: fall back to flat top-level config (legacy format without accounts)
if (!accounts || Object.keys(accounts).length === 0) {
return channelSection;
}
return undefined;
}
function extractFormValues(channelType: string, saved: ChannelConfigData): Record<string, string> {
const values: Record<string, string> = {};
if (channelType === 'discord') {
if (saved.token && typeof saved.token === 'string') {
values.token = saved.token;
}
const guilds = saved.guilds as Record<string, Record<string, unknown>> | undefined;
if (guilds) {
const guildIds = Object.keys(guilds);
if (guildIds.length > 0) {
values.guildId = guildIds[0];
const guildConfig = guilds[guildIds[0]];
const channels = guildConfig?.channels as Record<string, unknown> | undefined;
if (channels) {
const channelIds = Object.keys(channels).filter((id) => id !== '*');
if (channelIds.length > 0) {
values.channelId = channelIds[0];
}
}
}
}
} else if (channelType === 'telegram') {
if (Array.isArray(saved.allowFrom)) {
values.allowedUsers = saved.allowFrom.join(', ');
}
for (const [key, value] of Object.entries(saved)) {
if (typeof value === 'string' && key !== 'enabled') {
values[key] = value;
}
}
} else {
for (const [key, value] of Object.entries(saved)) {
if (typeof value === 'string' && key !== 'enabled') {
values[key] = value;
}
}
}
return values;
}
export async function getChannelFormValues(channelType: string, accountId?: string): Promise<Record<string, string> | undefined> {
const saved = await getChannelConfig(channelType, accountId);
if (!saved) return undefined;
const values = extractFormValues(channelType, saved);
return Object.keys(values).length > 0 ? values : undefined;
}
export async function deleteChannelAccountConfig(channelType: string, accountId: string): Promise<void> {
const resolvedChannelType = resolveStoredChannelType(channelType);
let deleteWeChatAccount = false;
let deletedAccount = false;
await mutateOpenClawConfig((snapshot) => {
deleteWeChatAccount = false;
deletedAccount = false;
const currentConfig = snapshot as OpenClawConfig;
const channelSection = currentConfig.channels?.[resolvedChannelType];
if (!channelSection) {
if (isWechatChannelType(resolvedChannelType)) {
removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID);
deleteWeChatAccount = true;
} else if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
removePluginRegistration(currentConfig, resolvedChannelType);
}
return;
}
const existingAccounts = getChannelAccountsMap(channelSection);
const targetsLegacyDefault = accountId === DEFAULT_ACCOUNT_ID
&& Object.keys(getLegacyChannelPayload(channelSection)).length > 0;
if (!existingAccounts?.[accountId] && !targetsLegacyDefault) return;
const currentDefaultAccountId = typeof channelSection.defaultAccount === 'string'
&& channelSection.defaultAccount.trim()
? channelSection.defaultAccount.trim()
: DEFAULT_ACCOUNT_ID;
migrateLegacyChannelConfigToAccounts(channelSection, currentDefaultAccountId);
const accounts = getChannelAccountsMap(channelSection);
if (!accounts?.[accountId]) return;
delete accounts[accountId];
deletedAccount = true;
if (Object.keys(accounts).length === 0) {
delete currentConfig.channels![resolvedChannelType];
if (isWechatChannelType(resolvedChannelType)) {
removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID);
} else if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
removePluginRegistration(currentConfig, resolvedChannelType);
}
} 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).
// (Strict-schema channels already returned above, so this is safe.)
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;
}
}
}
syncPluginChannelRegistration(currentConfig, resolvedChannelType);
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
sanitizeChannelSectionsBeforeWrite(currentConfig);
if (isWechatChannelType(resolvedChannelType)) {
deleteWeChatAccount = true;
}
});
if (deleteWeChatAccount) {
await deleteWeChatAccountState(accountId);
}
if (deletedAccount) {
logger.info('Deleted channel account config', { channelType: resolvedChannelType, accountId });
console.log(`Deleted channel account config for ${resolvedChannelType}/${accountId}`);
}
}
export async function deleteChannelConfig(channelType: string): Promise<void> {
const resolvedChannelType = resolveStoredChannelType(channelType);
let deleteWeChat = false;
let deletedConfig: 'channel' | 'plugin' | undefined;
await mutateOpenClawConfig((snapshot) => {
deleteWeChat = false;
deletedConfig = undefined;
const currentConfig = snapshot as OpenClawConfig;
cleanupLegacyBuiltInChannelPluginRegistration(currentConfig, resolvedChannelType);
if (currentConfig.channels?.[resolvedChannelType]) {
delete currentConfig.channels[resolvedChannelType];
if (isWechatChannelType(resolvedChannelType)) {
removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID);
}
// Clean up third-party plugin registrations when their channel is removed.
if (resolvedChannelType === 'feishu') {
for (const candidateId of FEISHU_PLUGIN_ID_CANDIDATES) {
removePluginRegistration(currentConfig, candidateId);
}
// Also remove the built-in feishu disable entry since it's no longer needed
if (currentConfig.plugins?.entries?.feishu) {
delete currentConfig.plugins.entries.feishu;
}
}
if (resolvedChannelType === 'dingtalk') {
removePluginRegistration(currentConfig, 'dingtalk');
}
if (resolvedChannelType === 'wecom') {
removePluginRegistration(currentConfig, WECOM_PLUGIN_ID);
}
if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
removePluginRegistration(currentConfig, resolvedChannelType);
}
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
if (isWechatChannelType(resolvedChannelType)) {
deleteWeChat = true;
}
deletedConfig = 'channel';
} else if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
if (currentConfig.plugins?.entries?.[resolvedChannelType] || currentConfig.plugins?.allow?.includes(resolvedChannelType)) {
removePluginRegistration(currentConfig, resolvedChannelType);
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
deletedConfig = 'plugin';
}
} else if (isWechatChannelType(resolvedChannelType)) {
removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID);
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
deleteWeChat = true;
}
});
if (deleteWeChat) {
await deleteWeChatState();
}
if (deletedConfig === 'channel') {
console.log(`Deleted channel config for ${resolvedChannelType}`);
} else if (deletedConfig === 'plugin') {
console.log(`Deleted plugin channel config for ${resolvedChannelType}`);
}
if (resolvedChannelType === 'whatsapp') {
try {
const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp');
if (await fileExists(whatsappDir)) {
await rm(whatsappDir, { recursive: true, force: true });
console.log('Deleted WhatsApp credentials directory');
}
} catch (error) {
console.error('Failed to delete WhatsApp credentials:', error);
}
}
}
function channelHasAnyAccount(channelSection: ChannelConfigData): boolean {
const accounts = getChannelAccountsMap(channelSection);
if (accounts) {
return Object.values(accounts).some((acc) => acc.enabled !== false);
}
return false;
}
export async function listConfiguredChannelsFromConfig(config: OpenClawConfig): Promise<string[]> {
const channels: string[] = [];
if (config.channels) {
for (const channelType of Object.keys(config.channels)) {
const section = config.channels[channelType];
if (section.enabled === false) continue;
if (channelHasAnyAccount(section) || Object.keys(section).length > 0) {
channels.push(channelType);
}
}
}
try {
const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp');
if (await fileExists(whatsappDir)) {
const entries = await readdir(whatsappDir);
const hasSession = await (async () => {
for (const entry of entries) {
try {
const s = await stat(join(whatsappDir, entry));
if (s.isDirectory()) return true;
} catch { /* ignore */ }
}
return false;
})();
if (hasSession && !channels.includes('whatsapp')) {
channels.push('whatsapp');
}
}
} catch {
// Ignore errors checking whatsapp dir
}
return channels;
}
export async function listConfiguredChannels(): Promise<string[]> {
const config = await readOpenClawConfig();
return listConfiguredChannelsFromConfig(config);
}
export interface ConfiguredChannelAccounts {
defaultAccountId: string;
accountIds: string[];
}
export function listConfiguredChannelAccountsFromConfig(config: OpenClawConfig): Record<string, ConfiguredChannelAccounts> {
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 accounts = getChannelAccountsMap(section);
const accountIds = accounts
? Object.keys(accounts).filter((accountId) => accountId.trim().length > 0)
: [];
let defaultAccountId = typeof section.defaultAccount === 'string' && section.defaultAccount.trim()
? section.defaultAccount
: DEFAULT_ACCOUNT_ID;
if (accountIds.length > 0 && !accountIds.includes(defaultAccountId)) {
defaultAccountId = accountIds.sort((a, b) => {
if (a === DEFAULT_ACCOUNT_ID) return -1;
if (b === DEFAULT_ACCOUNT_ID) return 1;
return a.localeCompare(b);
})[0];
}
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 listConfiguredChannelAccounts(): Promise<Record<string, ConfiguredChannelAccounts>> {
const config = await readOpenClawConfig();
return listConfiguredChannelAccountsFromConfig(config);
}
export async function setChannelDefaultAccount(channelType: string, accountId: string): Promise<void> {
const resolvedChannelType = resolveStoredChannelType(channelType);
const trimmedAccountId = accountId.trim();
if (!trimmedAccountId) {
throw new Error('accountId is required');
}
await mutateOpenClawConfig((snapshot) => {
const currentConfig = snapshot as OpenClawConfig;
const channelSection = currentConfig.channels?.[resolvedChannelType];
if (!channelSection) {
throw new Error(`Channel "${resolvedChannelType}" is not configured`);
}
migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID);
const accounts = getChannelAccountsMap(channelSection);
if (!accounts || !accounts[trimmedAccountId]) {
throw new Error(`Account "${trimmedAccountId}" is not configured for channel "${resolvedChannelType}"`);
}
channelSection.defaultAccount = trimmedAccountId;
// Strict-schema channels don't use defaultAccount — always mirror for others
const defaultAccountData = accounts[trimmedAccountId];
for (const [key, value] of Object.entries(defaultAccountData)) {
channelSection[key] = value;
}
sanitizeChannelSectionsBeforeWrite(currentConfig);
});
logger.info('Set channel default account', { channelType: resolvedChannelType, accountId: trimmedAccountId });
}
export async function deleteAgentChannelAccounts(agentId: string, ownedChannelAccounts?: Set<string>): Promise<void> {
let modified = false;
const accountId = agentId === 'main' ? DEFAULT_ACCOUNT_ID : agentId;
await mutateOpenClawConfig((snapshot) => {
modified = false;
const currentConfig = snapshot as OpenClawConfig;
const channels = currentConfig.channels ?? {};
// Older ClawX releases could leave the only copy of Discord, QQBot,
// or WhatsApp account credentials under plugins.entries.<id>. Migrate
// that invalid legacy shape into channels.<id> before deleting the
// owned account, so sibling accounts survive while PluginEntryConfig
// is normalized back to activation metadata only.
const legacyPluginChannelTypes = ownedChannelAccounts
? [...ownedChannelAccounts]
.filter((channelAccountKey) => channelAccountKey.endsWith(`:${accountId}`))
.map((channelAccountKey) => channelAccountKey.slice(0, -accountId.length - 1))
: PLUGIN_CHANNELS;
for (const channelType of legacyPluginChannelTypes) {
if (!PLUGIN_CHANNELS.includes(channelType)) continue;
const pluginEntry = currentConfig.plugins?.entries?.[channelType];
const pluginAccounts = pluginEntry ? getChannelAccountsMap(pluginEntry) : undefined;
if (!pluginEntry || !pluginAccounts?.[accountId]) continue;
const section = channels[channelType] ?? {
enabled: pluginEntry.enabled !== false,
};
const channelAccounts = ensureChannelAccountsMap(section);
for (const [legacyAccountId, legacyAccountConfig] of Object.entries(pluginAccounts)) {
if (!channelAccounts[legacyAccountId]) {
channelAccounts[legacyAccountId] = structuredClone(legacyAccountConfig);
}
}
if (typeof section.defaultAccount !== 'string' || !section.defaultAccount.trim()) {
section.defaultAccount = typeof pluginEntry.defaultAccount === 'string'
? pluginEntry.defaultAccount
: DEFAULT_ACCOUNT_ID;
}
channels[channelType] = section;
currentConfig.channels = channels;
}
for (const channelType of Object.keys(channels)) {
if (ownedChannelAccounts && !ownedChannelAccounts.has(`${channelType}:${accountId}`)) continue;
const section = channels[channelType];
const existingAccounts = getChannelAccountsMap(section);
const targetsLegacyDefault = accountId === DEFAULT_ACCOUNT_ID
&& Object.keys(getLegacyChannelPayload(section)).length > 0;
if (!existingAccounts?.[accountId] && !targetsLegacyDefault) continue;
const currentDefaultAccountId = typeof section.defaultAccount === 'string'
&& section.defaultAccount.trim()
? section.defaultAccount.trim()
: DEFAULT_ACCOUNT_ID;
migrateLegacyChannelConfigToAccounts(section, currentDefaultAccountId);
const accounts = getChannelAccountsMap(section);
if (!accounts?.[accountId]) continue;
delete accounts[accountId];
if (Object.keys(accounts).length === 0) {
delete 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 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;
}
}
}
syncPluginChannelRegistration(currentConfig, channelType);
modified = true;
}
if (modified) {
sanitizeChannelSectionsBeforeWrite(currentConfig);
}
});
if (modified) {
logger.info('Deleted all channel accounts for agent', { agentId, accountId });
}
}
export async function setChannelEnabled(channelType: string, enabled: boolean): Promise<void> {
const resolvedChannelType = resolveStoredChannelType(channelType);
let pluginChannel = false;
await mutateOpenClawConfig(async (snapshot) => {
pluginChannel = false;
const currentConfig = snapshot as OpenClawConfig;
cleanupLegacyBuiltInChannelPluginRegistration(currentConfig, resolvedChannelType);
if (isWechatChannelType(resolvedChannelType)) {
if (enabled) {
await ensurePluginAllowlist(currentConfig, WECHAT_PLUGIN_ID);
} else {
removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID);
}
}
if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
pluginChannel = true;
ensurePluginRegistration(currentConfig, resolvedChannelType);
currentConfig.plugins!.entries![resolvedChannelType].enabled = enabled;
}
if (!currentConfig.channels) currentConfig.channels = {};
if (!currentConfig.channels[resolvedChannelType]) currentConfig.channels[resolvedChannelType] = {};
currentConfig.channels[resolvedChannelType].enabled = enabled;
syncBuiltinChannelsWithPluginAllowlist(currentConfig, enabled ? [resolvedChannelType] : []);
});
console.log(`Set ${pluginChannel ? 'plugin channel' : 'channel'} ${resolvedChannelType} enabled: ${enabled}`);
}
export async function cleanupDanglingWeChatPluginState(): Promise<{ cleanedDanglingState: boolean }> {
let cleanedDanglingState = false;
let hasConfiguredWeChatAccounts = false;
await mutateOpenClawConfig((snapshot) => {
cleanedDanglingState = false;
hasConfiguredWeChatAccounts = false;
const currentConfig = snapshot as OpenClawConfig;
const channelSection = currentConfig.channels?.[WECHAT_PLUGIN_ID];
hasConfiguredWeChatAccounts = channelHasConfiguredAccounts(channelSection);
const hadPluginRegistration = Boolean(
currentConfig.plugins?.entries?.[WECHAT_PLUGIN_ID]
|| currentConfig.plugins?.allow?.includes(WECHAT_PLUGIN_ID),
);
if (hasConfiguredWeChatAccounts) {
return;
}
const modified = removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID);
cleanedDanglingState = hadPluginRegistration || modified;
});
if (!hasConfiguredWeChatAccounts) {
await deleteWeChatState();
}
return { cleanedDanglingState };
}
// ── Validation ───────────────────────────────────────────────────
export interface ValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
}
const DOCTOR_PARSER_FALLBACK_HINT =
'Doctor output could not be confidently interpreted; falling back to local channel config checks.';
type DoctorValidationParseResult = {
errors: string[];
warnings: string[];
undetermined: boolean;
};
export function parseDoctorValidationOutput(channelType: string, output: string): DoctorValidationParseResult {
const errors: string[] = [];
const warnings: string[] = [];
const normalizedChannelType = channelType.toLowerCase();
const normalizedOutput = output.trim();
if (!normalizedOutput) {
return {
errors,
warnings: [DOCTOR_PARSER_FALLBACK_HINT],
undetermined: true,
};
}
const lines = output
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
const channelLines = lines.filter((line) => line.toLowerCase().includes(normalizedChannelType));
let classifiedCount = 0;
for (const line of channelLines) {
const lowerLine = line.toLowerCase();
if (lowerLine.includes('error') || lowerLine.includes('unrecognized key')) {
errors.push(line);
classifiedCount += 1;
continue;
}
if (lowerLine.includes('warning')) {
warnings.push(line);
classifiedCount += 1;
}
}
if (channelLines.length === 0 || classifiedCount === 0) {
warnings.push(DOCTOR_PARSER_FALLBACK_HINT);
return {
errors,
warnings,
undetermined: true,
};
}
return {
errors,
warnings,
undetermined: false,
};
}
export interface CredentialValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
details?: Record<string, string>;
}
export async function validateChannelCredentials(
channelType: string,
config: Record<string, string>
): Promise<CredentialValidationResult> {
switch (resolveStoredChannelType(channelType)) {
case 'discord':
return validateDiscordCredentials(config);
case 'telegram':
return validateTelegramCredentials(config);
default:
return { valid: true, errors: [], warnings: ['No online validation available for this channel type.'] };
}
}
async function validateDiscordCredentials(
config: Record<string, string>
): Promise<CredentialValidationResult> {
const result: CredentialValidationResult = { valid: true, errors: [], warnings: [], details: {} };
const token = config.token?.trim();
if (!token) {
return { valid: false, errors: ['Bot token is required'], warnings: [] };
}
try {
const meResponse = await fetch('https://discord.com/api/v10/users/@me', {
headers: { Authorization: `Bot ${token}` },
});
if (!meResponse.ok) {
if (meResponse.status === 401) {
return { valid: false, errors: ['Invalid bot token. Please check and try again.'], warnings: [] };
}
const errorData = await meResponse.json().catch(() => ({}));
const msg = (errorData as { message?: string }).message || `Discord API error: ${meResponse.status}`;
return { valid: false, errors: [msg], warnings: [] };
}
const meData = (await meResponse.json()) as { username?: string; id?: string; bot?: boolean };
if (!meData.bot) {
return { valid: false, errors: ['The provided token belongs to a user account, not a bot. Please use a bot token.'], warnings: [] };
}
result.details!.botUsername = meData.username || 'Unknown';
result.details!.botId = meData.id || '';
} catch (error) {
return { valid: false, errors: [`Connection error when validating bot token: ${error instanceof Error ? error.message : String(error)}`], warnings: [] };
}
const guildId = config.guildId?.trim();
if (guildId) {
try {
const guildResponse = await fetch(`https://discord.com/api/v10/guilds/${guildId}`, {
headers: { Authorization: `Bot ${token}` },
});
if (!guildResponse.ok) {
if (guildResponse.status === 403 || guildResponse.status === 404) {
result.errors.push(`Cannot access guild (server) with ID "${guildId}". Make sure the bot has been invited to this server.`);
result.valid = false;
} else {
result.errors.push(`Failed to verify guild ID: Discord API returned ${guildResponse.status}`);
result.valid = false;
}
} else {
const guildData = (await guildResponse.json()) as { name?: string };
result.details!.guildName = guildData.name || 'Unknown';
}
} catch (error) {
result.warnings.push(`Could not verify guild ID: ${error instanceof Error ? error.message : String(error)}`);
}
}
const channelId = config.channelId?.trim();
if (channelId) {
try {
const channelResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}`, {
headers: { Authorization: `Bot ${token}` },
});
if (!channelResponse.ok) {
if (channelResponse.status === 403 || channelResponse.status === 404) {
result.errors.push(`Cannot access channel with ID "${channelId}". Make sure the bot has permission to view this channel.`);
result.valid = false;
} else {
result.errors.push(`Failed to verify channel ID: Discord API returned ${channelResponse.status}`);
result.valid = false;
}
} else {
const channelData = (await channelResponse.json()) as { name?: string; guild_id?: string };
result.details!.channelName = channelData.name || 'Unknown';
if (guildId && channelData.guild_id && channelData.guild_id !== guildId) {
result.errors.push(`Channel "${channelData.name}" does not belong to the specified guild. It belongs to a different server.`);
result.valid = false;
}
}
} catch (error) {
result.warnings.push(`Could not verify channel ID: ${error instanceof Error ? error.message : String(error)}`);
}
}
return result;
}
async function validateTelegramCredentials(
config: Record<string, string>
): Promise<CredentialValidationResult> {
const botToken = config.botToken?.trim();
const allowedUsers = config.allowedUsers?.trim();
if (!botToken) return { valid: false, errors: ['Bot token is required'], warnings: [] };
if (!allowedUsers) return { valid: false, errors: ['At least one allowed user ID is required'], warnings: [] };
try {
const response = await proxyAwareFetch(`https://api.telegram.org/bot${botToken}/getMe`);
const data = (await response.json()) as { ok?: boolean; description?: string; result?: { username?: string } };
if (data.ok) {
return { valid: true, errors: [], warnings: [], details: { botUsername: data.result?.username || 'Unknown' } };
}
return { valid: false, errors: [data.description || 'Invalid bot token'], warnings: [] };
} catch (error) {
return { valid: false, errors: [`Connection error: ${error instanceof Error ? error.message : String(error)}`], warnings: [] };
}
}
export async function validateChannelConfig(channelType: string): Promise<ValidationResult> {
const { exec } = await import('child_process');
const resolvedChannelType = resolveStoredChannelType(channelType);
const result: ValidationResult = { valid: true, errors: [], warnings: [] };
try {
const openclawPath = getOpenClawResolvedDir();
// Run openclaw doctor command to validate config (async to avoid
// blocking the main thread).
const runDoctor = async (command: string): Promise<string> =>
await new Promise<string>((resolve, reject) => {
exec(
command,
{
cwd: openclawPath,
encoding: 'utf-8',
timeout: 30000,
windowsHide: true,
},
(err, stdout, stderr) => {
const combined = `${stdout || ''}${stderr || ''}`;
if (err) {
const next = new Error(combined || err.message);
reject(next);
return;
}
resolve(combined);
},
);
});
const output = await runDoctor(`node openclaw.mjs doctor 2>&1`);
const parsedDoctor = parseDoctorValidationOutput(resolvedChannelType, output);
result.errors.push(...parsedDoctor.errors);
result.warnings.push(...parsedDoctor.warnings);
if (parsedDoctor.errors.length > 0) {
result.valid = false;
}
if (parsedDoctor.undetermined) {
logger.warn('Doctor output parsing fell back to local channel checks', {
channelType: resolvedChannelType,
hint: DOCTOR_PARSER_FALLBACK_HINT,
});
}
const config = await readOpenClawConfig();
const savedChannelConfig = await getChannelConfig(resolvedChannelType, DEFAULT_ACCOUNT_ID);
if (!config.channels?.[resolvedChannelType] || !savedChannelConfig) {
result.errors.push(`Channel ${resolvedChannelType} is not configured`);
result.valid = false;
} else if (config.channels[resolvedChannelType].enabled === false) {
result.warnings.push(`Channel ${resolvedChannelType} is disabled`);
}
if (resolvedChannelType === 'discord') {
const discordConfig = savedChannelConfig;
if (!discordConfig?.token) {
result.errors.push('Discord: Bot token is required');
result.valid = false;
}
} else if (resolvedChannelType === 'telegram') {
const telegramConfig = savedChannelConfig;
if (!telegramConfig?.botToken) {
result.errors.push('Telegram: Bot token is required');
result.valid = false;
}
const allowedUsers = telegramConfig?.allowFrom as string[] | undefined;
if (!allowedUsers || allowedUsers.length === 0) {
result.errors.push('Telegram: Allowed User IDs are required');
result.valid = false;
}
}
if (result.errors.length === 0 && result.warnings.length === 0) {
result.valid = true;
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes('Unrecognized key') || errorMessage.includes('invalid config')) {
result.errors.push(errorMessage);
result.valid = false;
} else if (errorMessage.includes('ENOENT')) {
result.errors.push('OpenClaw not found. Please ensure OpenClaw is installed.');
result.valid = false;
} else {
console.warn('Doctor command failed:', errorMessage);
const config = await readOpenClawConfig();
if (config.channels?.[resolvedChannelType]) {
result.valid = true;
} else {
result.errors.push(`Channel ${resolvedChannelType} is not configured`);
result.valid = false;
}
}
}
return result;
}