Files
DeskClaw/electron/utils/channel-config.ts

853 lines
34 KiB
TypeScript

/**
* 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 { getOpenClawResolvedDir } from './paths';
import * as logger from './logger';
import { proxyAwareFetch } from './proxy-fetch';
import { withConfigLock } from './config-mutex';
const OPENCLAW_DIR = join(homedir(), '.openclaw');
const CONFIG_FILE = join(OPENCLAW_DIR, 'openclaw.json');
const WECOM_PLUGIN_ID = 'wecom-openclaw-plugin';
const FEISHU_PLUGIN_ID = 'feishu-openclaw-plugin';
const DEFAULT_ACCOUNT_ID = 'default';
const CHANNEL_TOP_LEVEL_KEYS_TO_KEEP = new Set(['accounts', 'defaultAccount', 'enabled']);
// Channels that are managed as plugins (config goes under plugins.entries, not channels)
const PLUGIN_CHANNELS = ['whatsapp'];
// ── Helpers ──────────────────────────────────────────────────────
async function fileExists(p: string): Promise<boolean> {
try { await access(p, constants.F_OK); return true; } catch { return false; }
}
// ── 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 ───────────────────────────────────────────────────
async function ensureConfigDir(): Promise<void> {
if (!(await fileExists(OPENCLAW_DIR))) {
await mkdir(OPENCLAW_DIR, { recursive: true });
}
}
export async function readOpenClawConfig(): Promise<OpenClawConfig> {
await ensureConfigDir();
if (!(await fileExists(CONFIG_FILE))) {
return {};
}
try {
const content = await readFile(CONFIG_FILE, 'utf-8');
return JSON.parse(content) as OpenClawConfig;
} catch (error) {
logger.error('Failed to read OpenClaw config', error);
console.error('Failed to read OpenClaw config:', error);
return {};
}
}
export async function writeOpenClawConfig(config: OpenClawConfig): Promise<void> {
await ensureConfigDir();
try {
// Enable graceful in-process reload authorization for SIGUSR1 flows.
const commands =
config.commands && typeof config.commands === 'object'
? { ...(config.commands as Record<string, unknown>) }
: {};
commands.restart = true;
config.commands = commands;
await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
} catch (error) {
logger.error('Failed to write OpenClaw config', error);
console.error('Failed to write OpenClaw config:', error);
throw error;
}
}
// ── Channel operations ───────────────────────────────────────────
function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType: string): void {
if (channelType === 'feishu') {
if (!currentConfig.plugins) {
currentConfig.plugins = {
allow: [FEISHU_PLUGIN_ID],
enabled: true,
entries: {
[FEISHU_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 !== 'feishu');
if (!normalizedAllow.includes(FEISHU_PLUGIN_ID)) {
currentConfig.plugins.allow = [...normalizedAllow, FEISHU_PLUGIN_ID];
} else if (normalizedAllow.length !== allow.length) {
currentConfig.plugins.allow = normalizedAllow;
}
if (!currentConfig.plugins.entries) {
currentConfig.plugins.entries = {};
}
// Remove legacy 'feishu' entry — the official plugin registers its
// channel AS 'feishu' via openclaw.plugin.json, so an explicit
// entries.feishu.enabled=false would block the official plugin's channel.
delete currentConfig.plugins.entries['feishu'];
if (!currentConfig.plugins.entries[FEISHU_PLUGIN_ID]) {
currentConfig.plugins.entries[FEISHU_PLUGIN_ID] = {};
}
currentConfig.plugins.entries[FEISHU_PLUGIN_ID].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 };
} 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 (channelType === 'qqbot') {
if (!currentConfig.plugins) {
currentConfig.plugins = {};
}
currentConfig.plugins.enabled = true;
const allow = Array.isArray(currentConfig.plugins.allow)
? currentConfig.plugins.allow as string[]
: [];
if (!allow.includes('qqbot')) {
currentConfig.plugins.allow = [...allow, 'qqbot'];
}
}
}
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()]: { allow: true, requireMention: true }
};
} else {
guildConfig.channels = {
'*': { allow: true, 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;
}
return transformedConfig;
}
function resolveAccountConfig(
channelSection: ChannelConfigData | undefined,
accountId: string,
): ChannelConfigData {
if (!channelSection) return {};
const accounts = channelSection.accounts as Record<string, ChannelConfigData> | undefined;
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 hasAccounts =
Boolean(channelSection.accounts) &&
typeof channelSection.accounts === 'object' &&
Object.keys(channelSection.accounts as Record<string, ChannelConfigData>).length > 0;
if (legacyKeys.length === 0) {
if (hasAccounts && typeof channelSection.defaultAccount !== 'string') {
channelSection.defaultAccount = defaultAccountId;
}
return;
}
if (!channelSection.accounts || typeof channelSection.accounts !== 'object') {
channelSection.accounts = {};
}
const accounts = channelSection.accounts as Record<string, ChannelConfigData>;
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];
}
}
export async function saveChannelConfig(
channelType: string,
config: ChannelConfigData,
accountId?: string,
): Promise<void> {
return withConfigLock(async () => {
const currentConfig = await readOpenClawConfig();
const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID;
ensurePluginAllowlist(currentConfig, channelType);
// Plugin-based channels (e.g. WhatsApp) go under plugins.entries, not channels
if (PLUGIN_CHANNELS.includes(channelType)) {
if (!currentConfig.plugins) {
currentConfig.plugins = {};
}
if (!currentConfig.plugins.entries) {
currentConfig.plugins.entries = {};
}
currentConfig.plugins.entries[channelType] = {
...currentConfig.plugins.entries[channelType],
enabled: config.enabled ?? true,
};
await writeOpenClawConfig(currentConfig);
logger.info('Plugin channel config saved', {
channelType,
configFile: CONFIG_FILE,
path: `plugins.entries.${channelType}`,
});
console.log(`Saved plugin channel config for ${channelType}`);
return;
}
if (!currentConfig.channels) {
currentConfig.channels = {};
}
if (!currentConfig.channels[channelType]) {
currentConfig.channels[channelType] = {};
}
const channelSection = currentConfig.channels[channelType];
migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID);
const existingAccountConfig = resolveAccountConfig(channelSection, resolvedAccountId);
const transformedConfig = transformChannelConfig(channelType, config, existingAccountConfig);
// Write credentials into accounts.<accountId>
if (!channelSection.accounts || typeof channelSection.accounts !== 'object') {
channelSection.accounts = {};
}
const accounts = channelSection.accounts as Record<string, ChannelConfigData>;
channelSection.defaultAccount =
typeof channelSection.defaultAccount === 'string' && channelSection.defaultAccount.trim()
? channelSection.defaultAccount
: DEFAULT_ACCOUNT_ID;
accounts[resolvedAccountId] = {
...accounts[resolvedAccountId],
...transformedConfig,
enabled: transformedConfig.enabled ?? true,
};
// Most OpenClaw channel plugins read the default account's credentials
// from the top level of `channels.<type>` (e.g. channels.feishu.appId),
// not from `accounts.default`. Mirror them there so plugins can discover
// the credentials correctly. We use the final account entry (not
// transformedConfig) because `enabled` is only added at the account level.
if (resolvedAccountId === DEFAULT_ACCOUNT_ID) {
for (const [key, value] of Object.entries(accounts[resolvedAccountId])) {
channelSection[key] = value;
}
}
await writeOpenClawConfig(currentConfig);
logger.info('Channel config saved', {
channelType,
accountId: resolvedAccountId,
configFile: CONFIG_FILE,
rawKeys: Object.keys(config),
transformedKeys: Object.keys(transformedConfig),
});
console.log(`Saved channel config for ${channelType} account ${resolvedAccountId}`);
});
}
export async function getChannelConfig(channelType: string, accountId?: string): Promise<ChannelConfigData | undefined> {
const config = await readOpenClawConfig();
const channelSection = config.channels?.[channelType];
if (!channelSection) return undefined;
const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID;
const accounts = channelSection.accounts as Record<string, ChannelConfigData> | undefined;
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> {
return withConfigLock(async () => {
const currentConfig = await readOpenClawConfig();
const channelSection = currentConfig.channels?.[channelType];
if (!channelSection) return;
migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID);
const accounts = channelSection.accounts as Record<string, ChannelConfigData> | undefined;
if (!accounts?.[accountId]) return;
delete accounts[accountId];
if (Object.keys(accounts).length === 0) {
delete currentConfig.channels![channelType];
}
await writeOpenClawConfig(currentConfig);
logger.info('Deleted channel account config', { channelType, accountId });
console.log(`Deleted channel account config for ${channelType}/${accountId}`);
});
}
export async function deleteChannelConfig(channelType: string): Promise<void> {
return withConfigLock(async () => {
const currentConfig = await readOpenClawConfig();
if (currentConfig.channels?.[channelType]) {
delete currentConfig.channels[channelType];
await writeOpenClawConfig(currentConfig);
console.log(`Deleted channel config for ${channelType}`);
} else if (PLUGIN_CHANNELS.includes(channelType)) {
if (currentConfig.plugins?.entries?.[channelType]) {
delete currentConfig.plugins.entries[channelType];
if (Object.keys(currentConfig.plugins.entries).length === 0) {
delete currentConfig.plugins.entries;
}
if (currentConfig.plugins && Object.keys(currentConfig.plugins).length === 0) {
delete currentConfig.plugins;
}
await writeOpenClawConfig(currentConfig);
console.log(`Deleted plugin channel config for ${channelType}`);
}
}
if (channelType === '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 = channelSection.accounts as Record<string, ChannelConfigData> | undefined;
if (accounts && typeof accounts === 'object') {
return Object.values(accounts).some((acc) => acc.enabled !== false);
}
return false;
}
export async function listConfiguredChannels(): Promise<string[]> {
const config = await readOpenClawConfig();
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 deleteAgentChannelAccounts(agentId: string): Promise<void> {
return withConfigLock(async () => {
const currentConfig = await readOpenClawConfig();
if (!currentConfig.channels) return;
const accountId = agentId === 'main' ? DEFAULT_ACCOUNT_ID : agentId;
let modified = false;
for (const channelType of Object.keys(currentConfig.channels)) {
const section = currentConfig.channels[channelType];
migrateLegacyChannelConfigToAccounts(section, DEFAULT_ACCOUNT_ID);
const accounts = section.accounts as Record<string, ChannelConfigData> | undefined;
if (!accounts?.[accountId]) continue;
delete accounts[accountId];
if (Object.keys(accounts).length === 0) {
delete currentConfig.channels[channelType];
}
modified = true;
}
if (modified) {
await writeOpenClawConfig(currentConfig);
logger.info('Deleted all channel accounts for agent', { agentId, accountId });
}
});
}
export async function setChannelEnabled(channelType: string, enabled: boolean): Promise<void> {
return withConfigLock(async () => {
const currentConfig = await readOpenClawConfig();
if (PLUGIN_CHANNELS.includes(channelType)) {
if (!currentConfig.plugins) currentConfig.plugins = {};
if (!currentConfig.plugins.entries) currentConfig.plugins.entries = {};
if (!currentConfig.plugins.entries[channelType]) currentConfig.plugins.entries[channelType] = {};
currentConfig.plugins.entries[channelType].enabled = enabled;
await writeOpenClawConfig(currentConfig);
console.log(`Set plugin channel ${channelType} enabled: ${enabled}`);
return;
}
if (!currentConfig.channels) currentConfig.channels = {};
if (!currentConfig.channels[channelType]) currentConfig.channels[channelType] = {};
currentConfig.channels[channelType].enabled = enabled;
await writeOpenClawConfig(currentConfig);
console.log(`Set channel ${channelType} enabled: ${enabled}`);
});
}
// ── Validation ───────────────────────────────────────────────────
export interface ValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
}
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 (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 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 output = await new Promise<string>((resolve, reject) => {
exec(
`node openclaw.mjs doctor --json 2>&1`,
{
cwd: openclawPath,
encoding: 'utf-8',
timeout: 30000,
windowsHide: true,
},
(err, stdout) => {
if (err) reject(err);
else resolve(stdout);
},
);
});
const lines = output.split('\n');
for (const line of lines) {
const lowerLine = line.toLowerCase();
if (lowerLine.includes(channelType) && lowerLine.includes('error')) {
result.errors.push(line.trim());
result.valid = false;
} else if (lowerLine.includes(channelType) && lowerLine.includes('warning')) {
result.warnings.push(line.trim());
} else if (lowerLine.includes('unrecognized key') && lowerLine.includes(channelType)) {
result.errors.push(line.trim());
result.valid = false;
}
}
const config = await readOpenClawConfig();
const savedChannelConfig = await getChannelConfig(channelType, DEFAULT_ACCOUNT_ID);
if (!config.channels?.[channelType] || !savedChannelConfig) {
result.errors.push(`Channel ${channelType} is not configured`);
result.valid = false;
} else if (config.channels[channelType].enabled === false) {
result.warnings.push(`Channel ${channelType} is disabled`);
}
if (channelType === 'discord') {
const discordConfig = savedChannelConfig;
if (!discordConfig?.token) {
result.errors.push('Discord: Bot token is required');
result.valid = false;
}
} else if (channelType === '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?.[channelType]) {
result.valid = true;
} else {
result.errors.push(`Channel ${channelType} is not configured`);
result.valid = false;
}
}
}
return result;
}