feat(channel): support weichat channel (#620)

This commit is contained in:
Haze
2026-03-22 17:08:02 +08:00
committed by GitHub
Unverified
parent f12f4a74df
commit 1e7b40a486
32 changed files with 1610 additions and 156 deletions

View File

@@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from 'http';
import {
deleteChannelAccountConfig,
deleteChannelConfig,
cleanupDanglingWeChatPluginState,
getChannelFormValues,
listConfiguredChannelAccounts,
listConfiguredChannels,
@@ -22,17 +23,144 @@ import {
ensureDingTalkPluginInstalled,
ensureFeishuPluginInstalled,
ensureQQBotPluginInstalled,
ensureWeChatPluginInstalled,
ensureWeComPluginInstalled,
} from '../../utils/plugin-install';
import {
computeChannelRuntimeStatus,
pickChannelRuntimeStatus,
type ChannelRuntimeAccountSnapshot,
} from '../../../src/lib/channel-status';
} from '../../utils/channel-status';
import {
OPENCLAW_WECHAT_CHANNEL_TYPE,
UI_WECHAT_CHANNEL_TYPE,
buildQrChannelEventName,
toOpenClawChannelType,
toUiChannelType,
} from '../../utils/channel-alias';
import {
cancelWeChatLoginSession,
saveWeChatAccountState,
startWeChatLoginSession,
waitForWeChatLoginSession,
} from '../../utils/wechat-login';
import { whatsAppLoginManager } from '../../utils/whatsapp-login';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson } from '../route-utils';
const WECHAT_QR_TIMEOUT_MS = 8 * 60 * 1000;
const activeQrLogins = new Map<string, string>();
interface WebLoginStartResult {
qrcodeUrl?: string;
message?: string;
sessionKey?: string;
}
function resolveStoredChannelType(channelType: string): string {
return toOpenClawChannelType(channelType);
}
function buildQrLoginKey(channelType: string, accountId?: string): string {
return `${toUiChannelType(channelType)}:${accountId?.trim() || '__new__'}`;
}
function setActiveQrLogin(channelType: string, sessionKey: string, accountId?: string): string {
const loginKey = buildQrLoginKey(channelType, accountId);
activeQrLogins.set(loginKey, sessionKey);
return loginKey;
}
function isActiveQrLogin(loginKey: string, sessionKey: string): boolean {
return activeQrLogins.get(loginKey) === sessionKey;
}
function clearActiveQrLogin(channelType: string, accountId?: string): void {
activeQrLogins.delete(buildQrLoginKey(channelType, accountId));
}
function emitChannelEvent(
ctx: HostApiContext,
channelType: string,
event: 'qr' | 'success' | 'error',
payload: unknown,
): void {
const eventName = buildQrChannelEventName(channelType, event);
ctx.eventBus.emit(eventName, payload);
if (ctx.mainWindow && !ctx.mainWindow.isDestroyed()) {
ctx.mainWindow.webContents.send(eventName, payload);
}
}
async function startWeChatQrLogin(ctx: HostApiContext, accountId?: string): Promise<WebLoginStartResult> {
void ctx;
return await startWeChatLoginSession({
...(accountId ? { accountId } : {}),
force: true,
});
}
async function awaitWeChatQrLogin(
ctx: HostApiContext,
sessionKey: string,
loginKey: string,
): Promise<void> {
try {
const result = await waitForWeChatLoginSession({
sessionKey,
timeoutMs: WECHAT_QR_TIMEOUT_MS,
onQrRefresh: async ({ qrcodeUrl }) => {
if (!isActiveQrLogin(loginKey, sessionKey)) {
return;
}
emitChannelEvent(ctx, UI_WECHAT_CHANNEL_TYPE, 'qr', {
qr: qrcodeUrl,
raw: qrcodeUrl,
sessionKey,
});
},
});
if (!isActiveQrLogin(loginKey, sessionKey)) {
return;
}
if (!result.connected || !result.accountId || !result.botToken) {
emitChannelEvent(ctx, UI_WECHAT_CHANNEL_TYPE, 'error', result.message || 'WeChat login did not complete');
return;
}
const normalizedAccountId = await saveWeChatAccountState(result.accountId, {
token: result.botToken,
baseUrl: result.baseUrl,
userId: result.userId,
});
await saveChannelConfig(UI_WECHAT_CHANNEL_TYPE, { enabled: true }, normalizedAccountId);
await ensureScopedChannelBinding(UI_WECHAT_CHANNEL_TYPE, normalizedAccountId);
scheduleGatewayChannelSaveRefresh(ctx, OPENCLAW_WECHAT_CHANNEL_TYPE, `wechat:loginSuccess:${normalizedAccountId}`);
if (!isActiveQrLogin(loginKey, sessionKey)) {
return;
}
emitChannelEvent(ctx, UI_WECHAT_CHANNEL_TYPE, 'success', {
accountId: normalizedAccountId,
rawAccountId: result.accountId,
message: result.message,
});
} catch (error) {
if (!isActiveQrLogin(loginKey, sessionKey)) {
return;
}
emitChannelEvent(ctx, UI_WECHAT_CHANNEL_TYPE, 'error', String(error));
} finally {
if (isActiveQrLogin(loginKey, sessionKey)) {
activeQrLogins.delete(loginKey);
}
await cancelWeChatLoginSession(sessionKey);
}
}
function scheduleGatewayChannelRestart(ctx: HostApiContext, reason: string): void {
if (ctx.gatewayManager.getStatus().state === 'stopped') {
return;
@@ -44,17 +172,18 @@ function scheduleGatewayChannelRestart(ctx: HostApiContext, reason: string): voi
// Plugin-based channels require a full Gateway process restart to properly
// initialize / tear-down plugin connections. SIGUSR1 in-process reload is
// not sufficient for channel plugins (see restartGatewayForAgentDeletion).
const FORCE_RESTART_CHANNELS = new Set(['dingtalk', 'wecom', 'whatsapp', 'feishu', 'qqbot']);
const FORCE_RESTART_CHANNELS = new Set(['dingtalk', 'wecom', 'whatsapp', 'feishu', 'qqbot', OPENCLAW_WECHAT_CHANNEL_TYPE]);
function scheduleGatewayChannelSaveRefresh(
ctx: HostApiContext,
channelType: string,
reason: string,
): void {
const storedChannelType = resolveStoredChannelType(channelType);
if (ctx.gatewayManager.getStatus().state === 'stopped') {
return;
}
if (FORCE_RESTART_CHANNELS.has(channelType)) {
if (FORCE_RESTART_CHANNELS.has(storedChannelType)) {
ctx.gatewayManager.debouncedRestart();
void reason;
return;
@@ -95,23 +224,24 @@ function isSameConfigValues(
}
async function ensureScopedChannelBinding(channelType: string, accountId?: string): Promise<void> {
const storedChannelType = resolveStoredChannelType(channelType);
// Multi-agent safety: only bind when the caller explicitly scopes the account.
// Global channel saves (no accountId) must not override routing to "main".
if (!accountId) return;
const agents = await listAgentsSnapshot();
if (!agents.entries || agents.entries.length === 0) return;
if (!agents.agents || agents.agents.length === 0) return;
// Keep backward compatibility for the legacy default account.
if (accountId === 'default') {
if (agents.entries.some((entry) => entry.id === 'main')) {
await assignChannelAccountToAgent('main', channelType, 'default');
if (agents.agents.some((entry) => entry.id === 'main')) {
await assignChannelAccountToAgent('main', storedChannelType, 'default');
}
return;
}
// Legacy compatibility: if accountId matches an existing agentId, keep auto-binding.
if (agents.entries.some((entry) => entry.id === accountId)) {
await assignChannelAccountToAgent(accountId, channelType, accountId);
if (agents.agents.some((entry) => entry.id === accountId)) {
await assignChannelAccountToAgent(accountId, storedChannelType, accountId);
}
}
@@ -179,20 +309,26 @@ async function buildChannelAccountsView(ctx: HostApiContext): Promise<ChannelAcc
]);
const channels: ChannelAccountsView[] = [];
for (const channelType of channelTypes) {
const channelAccountsFromConfig = configuredAccounts[channelType]?.accountIds ?? [];
const hasLocalConfig = configuredChannels.includes(channelType) || Boolean(configuredAccounts[channelType]);
const channelSection = openClawConfig.channels?.[channelType];
for (const rawChannelType of channelTypes) {
const uiChannelType = toUiChannelType(rawChannelType);
const channelAccountsFromConfig = configuredAccounts[rawChannelType]?.accountIds ?? [];
const hasLocalConfig = configuredChannels.includes(rawChannelType) || Boolean(configuredAccounts[rawChannelType]);
const channelSection = openClawConfig.channels?.[rawChannelType];
const channelSummary =
(gatewayStatus?.channels?.[channelType] as { error?: string; lastError?: string } | undefined) ?? undefined;
(gatewayStatus?.channels?.[rawChannelType] as { error?: string; lastError?: string } | undefined) ?? undefined;
const sortedConfigAccountIds = [...channelAccountsFromConfig].sort((left, right) => {
if (left === 'default') return -1;
if (right === 'default') return 1;
return left.localeCompare(right);
});
const fallbackDefault =
typeof channelSection?.defaultAccount === 'string' && channelSection.defaultAccount.trim()
? channelSection.defaultAccount
: 'default';
const defaultAccountId = configuredAccounts[channelType]?.defaultAccountId
?? gatewayStatus?.channelDefaultAccountId?.[channelType]
: (sortedConfigAccountIds[0] || 'default');
const defaultAccountId = configuredAccounts[rawChannelType]?.defaultAccountId
?? gatewayStatus?.channelDefaultAccountId?.[rawChannelType]
?? fallbackDefault;
const runtimeAccounts = gatewayStatus?.channelAccounts?.[channelType] ?? [];
const runtimeAccounts = gatewayStatus?.channelAccounts?.[rawChannelType] ?? [];
const hasRuntimeConfigured = runtimeAccounts.some((account) => account.configured === true);
if (!hasLocalConfig && !hasRuntimeConfigured) {
continue;
@@ -216,7 +352,7 @@ async function buildChannelAccountsView(ctx: HostApiContext): Promise<ChannelAcc
lastError: typeof runtime?.lastError === 'string' ? runtime.lastError : undefined,
status,
isDefault: accountId === defaultAccountId,
agentId: agentsSnapshot.channelAccountOwners[`${channelType}:${accountId}`],
agentId: agentsSnapshot.channelAccountOwners[`${rawChannelType}:${accountId}`],
};
}).sort((left, right) => {
if (left.accountId === defaultAccountId) return -1;
@@ -225,7 +361,7 @@ async function buildChannelAccountsView(ctx: HostApiContext): Promise<ChannelAcc
});
channels.push({
channelType,
channelType: uiChannelType,
defaultAccountId,
status: pickChannelRuntimeStatus(runtimeAccounts, channelSummary),
accounts,
@@ -242,7 +378,8 @@ export async function handleChannelRoutes(
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/channels/configured' && req.method === 'GET') {
sendJson(res, 200, { success: true, channels: await listConfiguredChannels() });
const channels = await listConfiguredChannels();
sendJson(res, 200, { success: true, channels: Array.from(new Set(channels.map((channel) => toUiChannelType(channel)))) });
return true;
}
@@ -271,7 +408,7 @@ export async function handleChannelRoutes(
if (url.pathname === '/api/channels/binding' && req.method === 'PUT') {
try {
const body = await parseJsonBody<{ channelType: string; accountId: string; agentId: string }>(req);
await assignChannelAccountToAgent(body.agentId, body.channelType, body.accountId);
await assignChannelAccountToAgent(body.agentId, resolveStoredChannelType(body.channelType), body.accountId);
scheduleGatewayChannelSaveRefresh(ctx, body.channelType, `channel:setBinding:${body.channelType}`);
sendJson(res, 200, { success: true });
} catch (error) {
@@ -283,7 +420,7 @@ export async function handleChannelRoutes(
if (url.pathname === '/api/channels/binding' && req.method === 'DELETE') {
try {
const body = await parseJsonBody<{ channelType: string; accountId: string }>(req);
await clearChannelBinding(body.channelType, body.accountId);
await clearChannelBinding(resolveStoredChannelType(body.channelType), body.accountId);
scheduleGatewayChannelSaveRefresh(ctx, body.channelType, `channel:clearBinding:${body.channelType}`);
sendJson(res, 200, { success: true });
} catch (error) {
@@ -333,37 +470,93 @@ export async function handleChannelRoutes(
return true;
}
if (url.pathname === '/api/channels/wechat/start' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ accountId?: string }>(req);
const requestedAccountId = body.accountId?.trim() || undefined;
const installResult = await ensureWeChatPluginInstalled();
if (!installResult.installed) {
sendJson(res, 500, { success: false, error: installResult.warning || 'WeChat plugin install failed' });
return true;
}
await cleanupDanglingWeChatPluginState();
const startResult = await startWeChatQrLogin(ctx, requestedAccountId);
if (!startResult.qrcodeUrl || !startResult.sessionKey) {
throw new Error(startResult.message || 'Failed to generate WeChat QR code');
}
const loginKey = setActiveQrLogin(UI_WECHAT_CHANNEL_TYPE, startResult.sessionKey, requestedAccountId);
emitChannelEvent(ctx, UI_WECHAT_CHANNEL_TYPE, 'qr', {
qr: startResult.qrcodeUrl,
raw: startResult.qrcodeUrl,
sessionKey: startResult.sessionKey,
});
void awaitWeChatQrLogin(ctx, startResult.sessionKey, loginKey);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/channels/wechat/cancel' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ accountId?: string }>(req);
const accountId = body.accountId?.trim() || undefined;
const loginKey = buildQrLoginKey(UI_WECHAT_CHANNEL_TYPE, accountId);
const sessionKey = activeQrLogins.get(loginKey);
clearActiveQrLogin(UI_WECHAT_CHANNEL_TYPE, accountId);
if (sessionKey) {
await cancelWeChatLoginSession(sessionKey);
}
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/channels/config' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ channelType: string; config: Record<string, unknown>; accountId?: string }>(req);
if (body.channelType === 'dingtalk') {
const storedChannelType = resolveStoredChannelType(body.channelType);
if (storedChannelType === 'dingtalk') {
const installResult = await ensureDingTalkPluginInstalled();
if (!installResult.installed) {
sendJson(res, 500, { success: false, error: installResult.warning || 'DingTalk plugin install failed' });
return true;
}
}
if (body.channelType === 'wecom') {
if (storedChannelType === 'wecom') {
const installResult = await ensureWeComPluginInstalled();
if (!installResult.installed) {
sendJson(res, 500, { success: false, error: installResult.warning || 'WeCom plugin install failed' });
return true;
}
}
if (body.channelType === 'qqbot') {
if (storedChannelType === 'qqbot') {
const installResult = await ensureQQBotPluginInstalled();
if (!installResult.installed) {
sendJson(res, 500, { success: false, error: installResult.warning || 'QQ Bot plugin install failed' });
return true;
}
}
if (body.channelType === 'feishu') {
if (storedChannelType === 'feishu') {
const installResult = await ensureFeishuPluginInstalled();
if (!installResult.installed) {
sendJson(res, 500, { success: false, error: installResult.warning || 'Feishu plugin install failed' });
return true;
}
}
if (storedChannelType === OPENCLAW_WECHAT_CHANNEL_TYPE) {
const installResult = await ensureWeChatPluginInstalled();
if (!installResult.installed) {
sendJson(res, 500, { success: false, error: installResult.warning || 'WeChat plugin install failed' });
return true;
}
}
const existingValues = await getChannelFormValues(body.channelType, body.accountId);
if (isSameConfigValues(existingValues, body.config)) {
await ensureScopedChannelBinding(body.channelType, body.accountId);
@@ -372,7 +565,7 @@ export async function handleChannelRoutes(
}
await saveChannelConfig(body.channelType, body.config, body.accountId);
await ensureScopedChannelBinding(body.channelType, body.accountId);
scheduleGatewayChannelSaveRefresh(ctx, body.channelType, `channel:saveConfig:${body.channelType}`);
scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:saveConfig:${storedChannelType}`);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
@@ -384,7 +577,7 @@ export async function handleChannelRoutes(
try {
const body = await parseJsonBody<{ channelType: string; enabled: boolean }>(req);
await setChannelEnabled(body.channelType, body.enabled);
scheduleGatewayChannelRestart(ctx, `channel:setEnabled:${body.channelType}`);
scheduleGatewayChannelRestart(ctx, `channel:setEnabled:${resolveStoredChannelType(body.channelType)}`);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
@@ -410,14 +603,15 @@ export async function handleChannelRoutes(
try {
const channelType = decodeURIComponent(url.pathname.slice('/api/channels/config/'.length));
const accountId = url.searchParams.get('accountId') || undefined;
const storedChannelType = resolveStoredChannelType(channelType);
if (accountId) {
await deleteChannelAccountConfig(channelType, accountId);
await clearChannelBinding(channelType, accountId);
scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:deleteAccount:${channelType}`);
await clearChannelBinding(storedChannelType, accountId);
scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:deleteAccount:${storedChannelType}`);
} else {
await deleteChannelConfig(channelType);
await clearAllBindingsForChannel(channelType);
scheduleGatewayChannelRestart(ctx, `channel:deleteConfig:${channelType}`);
await clearAllBindingsForChannel(storedChannelType);
scheduleGatewayChannelRestart(ctx, `channel:deleteConfig:${storedChannelType}`);
}
sendJson(res, 200, { success: true });
} catch (error) {

View File

@@ -20,7 +20,7 @@ import { getApiKey, getDefaultProvider, getProvider } from '../utils/secure-stor
import { getProviderEnvVar, getKeyableProviderTypes } from '../utils/provider-registry';
import { getOpenClawDir, getOpenClawEntryPath, isOpenClawPresent } from '../utils/paths';
import { getUvMirrorEnv } from '../utils/uv-env';
import { listConfiguredChannels } from '../utils/channel-config';
import { cleanupDanglingWeChatPluginState, listConfiguredChannels } from '../utils/channel-config';
import { syncGatewayTokenToConfig, syncBrowserConfigToOpenClaw, syncSessionIdleMinutesToOpenClaw, sanitizeOpenClawConfig } from '../utils/openclaw-auth';
import { buildProxyEnv, resolveProxySettings } from '../utils/proxy';
import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy';
@@ -48,6 +48,7 @@ const CHANNEL_PLUGIN_MAP: Record<string, { dirName: string; npmName: string }> =
wecom: { dirName: 'wecom', npmName: '@wecom/wecom-openclaw-plugin' },
feishu: { dirName: 'feishu-openclaw-plugin', npmName: '@larksuite/openclaw-lark' },
qqbot: { dirName: 'qqbot', npmName: '@sliverp/qqbot' },
'openclaw-weixin': { dirName: 'openclaw-weixin', npmName: '@tencent-weixin/openclaw-weixin' },
};
function readPluginVersion(pkgJsonPath: string): string | null {
@@ -144,6 +145,12 @@ export async function syncGatewayConfigBeforeLaunch(
logger.warn('Failed to sanitize openclaw.json:', err);
}
try {
await cleanupDanglingWeChatPluginState();
} catch (err) {
logger.warn('Failed to clean dangling WeChat plugin state before launch:', err);
}
// Auto-upgrade installed plugins before Gateway starts so that
// the plugin manifest ID matches what sanitize wrote to the config.
try {

View File

@@ -332,7 +332,7 @@ async function initialize(): Promise<void> {
logger.warn('Failed to install preinstalled skills:', error);
});
// Pre-deploy/upgrade bundled OpenClaw plugins (dingtalk, wecom, qqbot, feishu)
// Pre-deploy/upgrade bundled OpenClaw plugins (dingtalk, wecom, qqbot, feishu, wechat)
// to ~/.openclaw/extensions/ so they are always up-to-date after an app update.
void ensureAllBundledPluginsInstalled().catch((error) => {
logger.warn('Failed to install/upgrade bundled plugins:', error);

View File

@@ -159,6 +159,9 @@ const electronAPI = {
'channel:whatsapp-qr',
'channel:whatsapp-success',
'channel:whatsapp-error',
'channel:wechat-qr',
'channel:wechat-success',
'channel:wechat-error',
'gateway:exit',
'gateway:error',
'navigate',
@@ -203,6 +206,12 @@ const electronAPI = {
'gateway:notification',
'gateway:channel-status',
'gateway:chat-message',
'channel:whatsapp-qr',
'channel:whatsapp-success',
'channel:whatsapp-error',
'channel:wechat-qr',
'channel:wechat-success',
'channel:wechat-error',
'gateway:exit',
'gateway:error',
'navigate',

View File

@@ -5,6 +5,7 @@ import { deleteAgentChannelAccounts, listConfiguredChannels, readOpenClawConfig,
import { withConfigLock } from './config-mutex';
import { expandPath, getOpenClawConfigDir } from './paths';
import * as logger from './logger';
import { toUiChannelType } from './channel-alias';
const MAIN_AGENT_ID = 'main';
const MAIN_AGENT_NAME = 'Main Agent';
@@ -493,14 +494,16 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument): Promise<Age
workspace: entry.workspace || (entry.id === MAIN_AGENT_ID ? getDefaultWorkspacePath(config) : `~/.openclaw/workspace-${entry.id}`),
agentDir: entry.agentDir || getDefaultAgentDirPath(entry.id),
mainSessionKey: buildAgentMainSessionKey(config, entry.id),
channelTypes: configuredChannels.filter((ct) => ownedChannels.has(ct)),
channelTypes: configuredChannels
.filter((ct) => ownedChannels.has(ct))
.map((channelType) => toUiChannelType(channelType)),
};
});
return {
agents,
defaultAgentId,
configuredChannelTypes: configuredChannels,
configuredChannelTypes: configuredChannels.map((channelType) => toUiChannelType(channelType)),
channelOwners,
channelAccountOwners,
};

View File

@@ -0,0 +1,46 @@
const BLOCKED_OBJECT_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
const VALID_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
const INVALID_CHARS_RE = /[^a-z0-9_-]+/g;
const LEADING_DASH_RE = /^-+/;
const TRAILING_DASH_RE = /-+$/;
export const UI_WECHAT_CHANNEL_TYPE = 'wechat';
export const OPENCLAW_WECHAT_CHANNEL_TYPE = 'openclaw-weixin';
export type QrChannelEvent = 'qr' | 'success' | 'error';
export function toOpenClawChannelType(channelType: string): string {
return channelType === UI_WECHAT_CHANNEL_TYPE ? OPENCLAW_WECHAT_CHANNEL_TYPE : channelType;
}
export function toUiChannelType(channelType: string): string {
return channelType === OPENCLAW_WECHAT_CHANNEL_TYPE ? UI_WECHAT_CHANNEL_TYPE : channelType;
}
export function isWechatChannelType(channelType: string | null | undefined): boolean {
return channelType === UI_WECHAT_CHANNEL_TYPE || channelType === OPENCLAW_WECHAT_CHANNEL_TYPE;
}
export function buildQrChannelEventName(channelType: string, event: QrChannelEvent): string {
return `channel:${toUiChannelType(channelType)}-${event}`;
}
function canonicalizeAccountId(value: string): string {
if (VALID_ID_RE.test(value)) return value.toLowerCase();
return value
.toLowerCase()
.replace(INVALID_CHARS_RE, '-')
.replace(LEADING_DASH_RE, '')
.replace(TRAILING_DASH_RE, '')
.slice(0, 64);
}
export function normalizeOpenClawAccountId(value: string | null | undefined, fallback = 'default'): string {
const trimmed = (value ?? '').trim();
if (!trimmed) return fallback;
const normalized = canonicalizeAccountId(trimmed);
if (!normalized || BLOCKED_OBJECT_KEYS.has(normalized)) {
return fallback;
}
return normalized;
}

View File

@@ -12,13 +12,25 @@ import { getOpenClawResolvedDir } from './paths';
import * as logger from './logger';
import { proxyAwareFetch } from './proxy-fetch';
import { withConfigLock } from './config-mutex';
import {
OPENCLAW_WECHAT_CHANNEL_TYPE,
isWechatChannelType,
normalizeOpenClawAccountId,
toOpenClawChannelType,
} from './channel-alias';
const OPENCLAW_DIR = join(homedir(), '.openclaw');
const CONFIG_FILE = join(OPENCLAW_DIR, 'openclaw.json');
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';
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');
// Channels that are managed as plugins (config goes under plugins.entries, not channels)
const PLUGIN_CHANNELS = ['whatsapp'];
@@ -70,6 +82,117 @@ async function resolveFeishuPluginId(): Promise<string> {
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 channelHasConfiguredAccounts(channelSection: ChannelConfigData | undefined): boolean {
if (!channelSection || typeof channelSection !== 'object') return false;
const accounts = channelSection.accounts as Record<string, ChannelConfigData> | undefined;
if (accounts && typeof accounts === 'object') {
return Object.keys(accounts).length > 0;
}
return Object.keys(channelSection).some((key) => !CHANNEL_TOP_LEVEL_KEYS_TO_KEEP.has(key));
}
// ── Types ────────────────────────────────────────────────────────
export interface ChannelConfigData {
@@ -239,6 +362,35 @@ async function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType:
currentConfig.plugins.allow = [...allow, 'qqbot'];
}
}
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(
@@ -426,55 +578,56 @@ export async function saveChannelConfig(
accountId?: string,
): Promise<void> {
return withConfigLock(async () => {
const resolvedChannelType = resolveStoredChannelType(channelType);
const currentConfig = await readOpenClawConfig();
const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID;
await ensurePluginAllowlist(currentConfig, channelType);
await ensurePluginAllowlist(currentConfig, resolvedChannelType);
// Plugin-based channels (e.g. WhatsApp) go under plugins.entries, not channels
if (PLUGIN_CHANNELS.includes(channelType)) {
if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
if (!currentConfig.plugins) {
currentConfig.plugins = {};
}
if (!currentConfig.plugins.entries) {
currentConfig.plugins.entries = {};
}
currentConfig.plugins.entries[channelType] = {
...currentConfig.plugins.entries[channelType],
currentConfig.plugins.entries[resolvedChannelType] = {
...currentConfig.plugins.entries[resolvedChannelType],
enabled: config.enabled ?? true,
};
await writeOpenClawConfig(currentConfig);
logger.info('Plugin channel config saved', {
channelType,
channelType: resolvedChannelType,
configFile: CONFIG_FILE,
path: `plugins.entries.${channelType}`,
path: `plugins.entries.${resolvedChannelType}`,
});
console.log(`Saved plugin channel config for ${channelType}`);
console.log(`Saved plugin channel config for ${resolvedChannelType}`);
return;
}
if (!currentConfig.channels) {
currentConfig.channels = {};
}
if (!currentConfig.channels[channelType]) {
currentConfig.channels[channelType] = {};
if (!currentConfig.channels[resolvedChannelType]) {
currentConfig.channels[resolvedChannelType] = {};
}
const channelSection = currentConfig.channels[channelType];
const channelSection = currentConfig.channels[resolvedChannelType];
migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID);
// Guard: reject if this bot/app credential is already used by another account.
assertNoDuplicateCredential(channelType, config, channelSection, resolvedAccountId);
assertNoDuplicateCredential(resolvedChannelType, config, channelSection, resolvedAccountId);
const existingAccountConfig = resolveAccountConfig(channelSection, resolvedAccountId);
const transformedConfig = transformChannelConfig(channelType, config, existingAccountConfig);
const uniqueKey = CHANNEL_UNIQUE_CREDENTIAL_KEY[channelType];
const transformedConfig = transformChannelConfig(resolvedChannelType, config, existingAccountConfig);
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,
channelType: resolvedChannelType,
accountId: resolvedAccountId,
key: uniqueKey,
});
@@ -490,7 +643,7 @@ export async function saveChannelConfig(
channelSection.defaultAccount =
typeof channelSection.defaultAccount === 'string' && channelSection.defaultAccount.trim()
? channelSection.defaultAccount
: DEFAULT_ACCOUNT_ID;
: resolvedAccountId;
accounts[resolvedAccountId] = {
...accounts[resolvedAccountId],
...transformedConfig,
@@ -506,7 +659,11 @@ export async function saveChannelConfig(
// credential keys on every invocation. Without this, saving a non-default
// account (e.g. a sub-agent's Feishu bot) leaves the top-level credentials
// missing, breaking plugins that only read from the top level.
const defaultAccountData = accounts[DEFAULT_ACCOUNT_ID];
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;
@@ -515,19 +672,20 @@ export async function saveChannelConfig(
await writeOpenClawConfig(currentConfig);
logger.info('Channel config saved', {
channelType,
channelType: resolvedChannelType,
accountId: resolvedAccountId,
configFile: CONFIG_FILE,
rawKeys: Object.keys(config),
transformedKeys: Object.keys(transformedConfig),
});
console.log(`Saved channel config for ${channelType} account ${resolvedAccountId}`);
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?.[channelType];
const channelSection = config.channels?.[resolvedChannelType];
if (!channelSection) return undefined;
const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID;
@@ -596,9 +754,17 @@ export async function getChannelFormValues(channelType: string, accountId?: stri
export async function deleteChannelAccountConfig(channelType: string, accountId: string): Promise<void> {
return withConfigLock(async () => {
const resolvedChannelType = resolveStoredChannelType(channelType);
const currentConfig = await readOpenClawConfig();
const channelSection = currentConfig.channels?.[channelType];
if (!channelSection) return;
const channelSection = currentConfig.channels?.[resolvedChannelType];
if (!channelSection) {
if (isWechatChannelType(resolvedChannelType)) {
removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID);
await writeOpenClawConfig(currentConfig);
await deleteWeChatAccountState(accountId);
}
return;
}
migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID);
const accounts = channelSection.accounts as Record<string, ChannelConfigData> | undefined;
@@ -607,7 +773,10 @@ export async function deleteChannelAccountConfig(channelType: string, accountId:
delete accounts[accountId];
if (Object.keys(accounts).length === 0) {
delete currentConfig.channels![channelType];
delete currentConfig.channels![resolvedChannelType];
if (isWechatChannelType(resolvedChannelType)) {
removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID);
}
} else {
if (channelSection.defaultAccount === accountId) {
const nextDefaultAccountId = Object.keys(accounts).sort((a, b) => {
@@ -634,22 +803,32 @@ export async function deleteChannelAccountConfig(channelType: string, accountId:
}
await writeOpenClawConfig(currentConfig);
logger.info('Deleted channel account config', { channelType, accountId });
console.log(`Deleted channel account config for ${channelType}/${accountId}`);
if (isWechatChannelType(resolvedChannelType)) {
await deleteWeChatAccountState(accountId);
}
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> {
return withConfigLock(async () => {
const resolvedChannelType = resolveStoredChannelType(channelType);
const currentConfig = await readOpenClawConfig();
if (currentConfig.channels?.[channelType]) {
delete currentConfig.channels[channelType];
if (currentConfig.channels?.[resolvedChannelType]) {
delete currentConfig.channels[resolvedChannelType];
if (isWechatChannelType(resolvedChannelType)) {
removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID);
}
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 (isWechatChannelType(resolvedChannelType)) {
await deleteWeChatState();
}
console.log(`Deleted channel config for ${resolvedChannelType}`);
} else if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
if (currentConfig.plugins?.entries?.[resolvedChannelType]) {
delete currentConfig.plugins.entries[resolvedChannelType];
if (Object.keys(currentConfig.plugins.entries).length === 0) {
delete currentConfig.plugins.entries;
}
@@ -657,11 +836,15 @@ export async function deleteChannelConfig(channelType: string): Promise<void> {
delete currentConfig.plugins;
}
await writeOpenClawConfig(currentConfig);
console.log(`Deleted plugin channel config for ${channelType}`);
console.log(`Deleted plugin channel config for ${resolvedChannelType}`);
}
} else if (isWechatChannelType(resolvedChannelType)) {
removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID);
await writeOpenClawConfig(currentConfig);
await deleteWeChatState();
}
if (channelType === 'whatsapp') {
if (resolvedChannelType === 'whatsapp') {
try {
const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp');
if (await fileExists(whatsappDir)) {
@@ -742,9 +925,16 @@ export async function listConfiguredChannelAccounts(): Promise<Record<string, Co
? Object.keys(section.accounts).filter(Boolean)
: [];
const defaultAccountId = typeof section.defaultAccount === 'string' && section.defaultAccount.trim()
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));
@@ -771,21 +961,22 @@ export async function listConfiguredChannelAccounts(): Promise<Record<string, Co
export async function setChannelDefaultAccount(channelType: string, accountId: string): Promise<void> {
return withConfigLock(async () => {
const resolvedChannelType = resolveStoredChannelType(channelType);
const trimmedAccountId = accountId.trim();
if (!trimmedAccountId) {
throw new Error('accountId is required');
}
const currentConfig = await readOpenClawConfig();
const channelSection = currentConfig.channels?.[channelType];
const channelSection = currentConfig.channels?.[resolvedChannelType];
if (!channelSection) {
throw new Error(`Channel "${channelType}" is not configured`);
throw new Error(`Channel "${resolvedChannelType}" 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}"`);
throw new Error(`Account "${trimmedAccountId}" is not configured for channel "${resolvedChannelType}"`);
}
channelSection.defaultAccount = trimmedAccountId;
@@ -796,7 +987,7 @@ export async function setChannelDefaultAccount(channelType: string, accountId: s
}
await writeOpenClawConfig(currentConfig);
logger.info('Set channel default account', { channelType, accountId: trimmedAccountId });
logger.info('Set channel default account', { channelType: resolvedChannelType, accountId: trimmedAccountId });
});
}
@@ -856,23 +1047,55 @@ export async function deleteAgentChannelAccounts(agentId: string, ownedChannelAc
export async function setChannelEnabled(channelType: string, enabled: boolean): Promise<void> {
return withConfigLock(async () => {
const resolvedChannelType = resolveStoredChannelType(channelType);
const currentConfig = await readOpenClawConfig();
if (PLUGIN_CHANNELS.includes(channelType)) {
if (isWechatChannelType(resolvedChannelType)) {
if (enabled) {
await ensurePluginAllowlist(currentConfig, WECHAT_PLUGIN_ID);
} else {
removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID);
}
}
if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
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;
if (!currentConfig.plugins.entries[resolvedChannelType]) currentConfig.plugins.entries[resolvedChannelType] = {};
currentConfig.plugins.entries[resolvedChannelType].enabled = enabled;
await writeOpenClawConfig(currentConfig);
console.log(`Set plugin channel ${channelType} enabled: ${enabled}`);
console.log(`Set plugin channel ${resolvedChannelType} enabled: ${enabled}`);
return;
}
if (!currentConfig.channels) currentConfig.channels = {};
if (!currentConfig.channels[channelType]) currentConfig.channels[channelType] = {};
currentConfig.channels[channelType].enabled = enabled;
if (!currentConfig.channels[resolvedChannelType]) currentConfig.channels[resolvedChannelType] = {};
currentConfig.channels[resolvedChannelType].enabled = enabled;
await writeOpenClawConfig(currentConfig);
console.log(`Set channel ${channelType} enabled: ${enabled}`);
console.log(`Set channel ${resolvedChannelType} enabled: ${enabled}`);
});
}
export async function cleanupDanglingWeChatPluginState(): Promise<{ cleanedDanglingState: boolean }> {
return withConfigLock(async () => {
const currentConfig = await readOpenClawConfig();
const channelSection = currentConfig.channels?.[WECHAT_PLUGIN_ID];
const hasConfiguredWeChatAccounts = channelHasConfiguredAccounts(channelSection);
const hadPluginRegistration = Boolean(
currentConfig.plugins?.entries?.[WECHAT_PLUGIN_ID]
|| currentConfig.plugins?.allow?.includes(WECHAT_PLUGIN_ID),
);
if (hasConfiguredWeChatAccounts) {
return { cleanedDanglingState: false };
}
const modified = removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID);
if (modified) {
await writeOpenClawConfig(currentConfig);
}
await deleteWeChatState();
return { cleanedDanglingState: hadPluginRegistration || modified };
});
}
@@ -954,7 +1177,7 @@ export async function validateChannelCredentials(
channelType: string,
config: Record<string, string>
): Promise<CredentialValidationResult> {
switch (channelType) {
switch (resolveStoredChannelType(channelType)) {
case 'discord':
return validateDiscordCredentials(config);
case 'telegram':
@@ -1072,6 +1295,7 @@ async function validateTelegramCredentials(
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: [] };
@@ -1104,7 +1328,7 @@ export async function validateChannelConfig(channelType: string): Promise<Valida
const output = await runDoctor(`node openclaw.mjs doctor 2>&1`);
const parsedDoctor = parseDoctorValidationOutput(channelType, output);
const parsedDoctor = parseDoctorValidationOutput(resolvedChannelType, output);
result.errors.push(...parsedDoctor.errors);
result.warnings.push(...parsedDoctor.warnings);
if (parsedDoctor.errors.length > 0) {
@@ -1112,27 +1336,27 @@ export async function validateChannelConfig(channelType: string): Promise<Valida
}
if (parsedDoctor.undetermined) {
logger.warn('Doctor output parsing fell back to local channel checks', {
channelType,
channelType: resolvedChannelType,
hint: DOCTOR_PARSER_FALLBACK_HINT,
});
}
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`);
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[channelType].enabled === false) {
result.warnings.push(`Channel ${channelType} is disabled`);
} else if (config.channels[resolvedChannelType].enabled === false) {
result.warnings.push(`Channel ${resolvedChannelType} is disabled`);
}
if (channelType === 'discord') {
if (resolvedChannelType === 'discord') {
const discordConfig = savedChannelConfig;
if (!discordConfig?.token) {
result.errors.push('Discord: Bot token is required');
result.valid = false;
}
} else if (channelType === 'telegram') {
} else if (resolvedChannelType === 'telegram') {
const telegramConfig = savedChannelConfig;
if (!telegramConfig?.botToken) {
result.errors.push('Telegram: Bot token is required');
@@ -1161,10 +1385,10 @@ export async function validateChannelConfig(channelType: string): Promise<Valida
} else {
console.warn('Doctor command failed:', errorMessage);
const config = await readOpenClawConfig();
if (config.channels?.[channelType]) {
if (config.channels?.[resolvedChannelType]) {
result.valid = true;
} else {
result.errors.push(`Channel ${channelType} is not configured`);
result.errors.push(`Channel ${resolvedChannelType} is not configured`);
result.valid = false;
}
}

View File

@@ -0,0 +1,101 @@
export type ChannelConnectionStatus = 'connected' | 'connecting' | 'disconnected' | 'error';
export interface ChannelRuntimeAccountSnapshot {
connected?: boolean;
linked?: boolean;
running?: boolean;
lastError?: string | null;
lastConnectedAt?: number | null;
lastInboundAt?: number | null;
lastOutboundAt?: number | null;
lastProbeAt?: number | null;
probe?: {
ok?: boolean | null;
} | null;
}
export interface ChannelRuntimeSummarySnapshot {
error?: string | null;
lastError?: string | null;
}
const RECENT_ACTIVITY_MS = 10 * 60 * 1000;
function hasNonEmptyError(value: string | null | undefined): boolean {
return typeof value === 'string' && value.trim().length > 0;
}
export function hasRecentChannelActivity(
account: Pick<ChannelRuntimeAccountSnapshot, 'lastConnectedAt' | 'lastInboundAt' | 'lastOutboundAt'>,
now = Date.now(),
recentMs = RECENT_ACTIVITY_MS,
): boolean {
return (
(typeof account.lastInboundAt === 'number' && now - account.lastInboundAt < recentMs) ||
(typeof account.lastOutboundAt === 'number' && now - account.lastOutboundAt < recentMs) ||
(typeof account.lastConnectedAt === 'number' && now - account.lastConnectedAt < recentMs)
);
}
export function hasSuccessfulChannelProbe(
account: Pick<ChannelRuntimeAccountSnapshot, 'probe'>,
): boolean {
return account.probe?.ok === true;
}
export function hasChannelRuntimeError(
account: Pick<ChannelRuntimeAccountSnapshot, 'lastError'>,
): boolean {
return hasNonEmptyError(account.lastError);
}
export function hasSummaryRuntimeError(
summary: ChannelRuntimeSummarySnapshot | undefined,
): boolean {
if (!summary) return false;
return hasNonEmptyError(summary.error) || hasNonEmptyError(summary.lastError);
}
export function isChannelRuntimeConnected(
account: ChannelRuntimeAccountSnapshot,
): boolean {
if (account.connected === true || account.linked === true) {
return true;
}
if (hasRecentChannelActivity(account) || hasSuccessfulChannelProbe(account)) {
return true;
}
// OpenClaw integrations such as Feishu/WeCom may stay "running" without ever
// setting a durable connected=true flag. Treat healthy running as connected.
return account.running === true && !hasChannelRuntimeError(account);
}
export function computeChannelRuntimeStatus(
account: ChannelRuntimeAccountSnapshot,
): ChannelConnectionStatus {
if (isChannelRuntimeConnected(account)) return 'connected';
if (hasChannelRuntimeError(account)) return 'error';
if (account.running === true) return 'connecting';
return 'disconnected';
}
export function pickChannelRuntimeStatus(
accounts: ChannelRuntimeAccountSnapshot[],
summary?: ChannelRuntimeSummarySnapshot,
): ChannelConnectionStatus {
if (accounts.some((account) => isChannelRuntimeConnected(account))) {
return 'connected';
}
if (accounts.some((account) => hasChannelRuntimeError(account)) || hasSummaryRuntimeError(summary)) {
return 'error';
}
if (accounts.some((account) => account.running === true)) {
return 'connecting';
}
return 'disconnected';
}

View File

@@ -2,7 +2,7 @@
* Shared OpenClaw Plugin Install Utilities
*
* Provides version-aware install/upgrade logic for bundled OpenClaw plugins
* (DingTalk, WeCom, QQBot, Feishu). Used both at app startup (to auto-upgrade
* (DingTalk, WeCom, QQBot, Feishu, WeChat). Used both at app startup (to auto-upgrade
* stale plugins) and when a user configures a channel.
*/
import { app } from 'electron';
@@ -171,6 +171,7 @@ const PLUGIN_NPM_NAMES: Record<string, string> = {
wecom: '@wecom/wecom-openclaw-plugin',
'feishu-openclaw-plugin': '@larksuite/openclaw-lark',
qqbot: '@sliverp/qqbot',
'openclaw-weixin': '@tencent-weixin/openclaw-weixin',
};
// ── Version helper ───────────────────────────────────────────────────────────
@@ -450,6 +451,10 @@ export function ensureQQBotPluginInstalled(): { installed: boolean; warning?: st
return ensurePluginInstalled('qqbot', buildCandidateSources('qqbot'), 'QQ Bot');
}
export function ensureWeChatPluginInstalled(): { installed: boolean; warning?: string } {
return ensurePluginInstalled('openclaw-weixin', buildCandidateSources('openclaw-weixin'), 'WeChat');
}
// ── Bulk startup installer ───────────────────────────────────────────────────
/**
@@ -460,6 +465,7 @@ const ALL_BUNDLED_PLUGINS = [
{ fn: ensureWeComPluginInstalled, label: 'WeCom' },
{ fn: ensureQQBotPluginInstalled, label: 'QQ Bot' },
{ fn: ensureFeishuPluginInstalled, label: 'Feishu' },
{ fn: ensureWeChatPluginInstalled, label: 'WeChat' },
] as const;
/**

View File

@@ -0,0 +1,457 @@
import { createRequire } from 'node:module';
import { randomUUID } from 'node:crypto';
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { deflateSync } from 'node:zlib';
import { normalizeOpenClawAccountId } from './channel-alias';
import { getOpenClawResolvedDir } from './paths';
export const DEFAULT_WECHAT_BASE_URL = 'https://ilinkai.weixin.qq.com';
const DEFAULT_ILINK_BOT_TYPE = '3';
const ACTIVE_LOGIN_TTL_MS = 5 * 60_000;
const QR_POLL_TIMEOUT_MS = 35_000;
const MAX_QR_REFRESH_COUNT = 3;
const OPENCLAW_DIR = join(homedir(), '.openclaw');
const WECHAT_STATE_DIR = join(OPENCLAW_DIR, 'openclaw-weixin');
const WECHAT_ACCOUNT_INDEX_FILE = join(WECHAT_STATE_DIR, 'accounts.json');
const WECHAT_ACCOUNTS_DIR = join(WECHAT_STATE_DIR, 'accounts');
const require = createRequire(import.meta.url);
type QrRenderDeps = {
QRCode: typeof import('qrcode-terminal/vendor/QRCode/index.js');
QRErrorCorrectLevel: typeof import('qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js');
};
let qrRenderDeps: QrRenderDeps | null = null;
function getQrRenderDeps(): QrRenderDeps {
if (qrRenderDeps) {
return qrRenderDeps;
}
const openclawRequire = createRequire(join(getOpenClawResolvedDir(), 'package.json'));
const qrcodeTerminalPath = dirname(openclawRequire.resolve('qrcode-terminal/package.json'));
qrRenderDeps = {
QRCode: require(join(qrcodeTerminalPath, 'vendor', 'QRCode', 'index.js')),
QRErrorCorrectLevel: require(join(qrcodeTerminalPath, 'vendor', 'QRCode', 'QRErrorCorrectLevel.js')),
};
return qrRenderDeps;
}
type ActiveLogin = {
sessionKey: string;
qrcode: string;
qrcodeUrl: string;
startedAt: number;
apiBaseUrl: string;
};
type QrCodeResponse = {
qrcode: string;
qrcode_img_content: string;
};
type QrStatusResponse = {
status: 'wait' | 'scaned' | 'confirmed' | 'expired';
bot_token?: string;
ilink_bot_id?: string;
baseurl?: string;
ilink_user_id?: string;
};
export type WeChatLoginStartResult = {
sessionKey: string;
qrcodeUrl?: string;
message: string;
};
export type WeChatLoginWaitResult = {
connected: boolean;
message: string;
botToken?: string;
accountId?: string;
baseUrl?: string;
userId?: string;
};
const activeLogins = new Map<string, ActiveLogin>();
function createQrMatrix(input: string) {
const { QRCode, QRErrorCorrectLevel } = getQrRenderDeps();
const qr = new QRCode(-1, QRErrorCorrectLevel.L);
qr.addData(input);
qr.make();
return qr;
}
function fillPixel(
buf: Buffer,
x: number,
y: number,
width: number,
r: number,
g: number,
b: number,
a = 255,
) {
const idx = (y * width + x) * 4;
buf[idx] = r;
buf[idx + 1] = g;
buf[idx + 2] = b;
buf[idx + 3] = a;
}
function crcTable() {
const table = new Uint32Array(256);
for (let i = 0; i < 256; i += 1) {
let c = i;
for (let k = 0; k < 8; k += 1) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
table[i] = c >>> 0;
}
return table;
}
const CRC_TABLE = crcTable();
function crc32(buf: Buffer) {
let crc = 0xffffffff;
for (let i = 0; i < buf.length; i += 1) {
crc = CRC_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}
function pngChunk(type: string, data: Buffer) {
const typeBuf = Buffer.from(type, 'ascii');
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
const crc = crc32(Buffer.concat([typeBuf, data]));
const crcBuf = Buffer.alloc(4);
crcBuf.writeUInt32BE(crc, 0);
return Buffer.concat([len, typeBuf, data, crcBuf]);
}
function encodePngRgba(buffer: Buffer, width: number, height: number) {
const stride = width * 4;
const raw = Buffer.alloc((stride + 1) * height);
for (let row = 0; row < height; row += 1) {
const rawOffset = row * (stride + 1);
raw[rawOffset] = 0;
buffer.copy(raw, rawOffset + 1, row * stride, row * stride + stride);
}
const compressed = deflateSync(raw);
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8;
ihdr[9] = 6;
ihdr[10] = 0;
ihdr[11] = 0;
ihdr[12] = 0;
return Buffer.concat([
signature,
pngChunk('IHDR', ihdr),
pngChunk('IDAT', compressed),
pngChunk('IEND', Buffer.alloc(0)),
]);
}
async function renderQrPngDataUrl(
input: string,
opts: { scale?: number; marginModules?: number } = {},
): Promise<string> {
const { scale = 6, marginModules = 4 } = opts;
const qr = createQrMatrix(input);
const modules = qr.getModuleCount();
const size = (modules + marginModules * 2) * scale;
const buf = Buffer.alloc(size * size * 4, 255);
for (let row = 0; row < modules; row += 1) {
for (let col = 0; col < modules; col += 1) {
if (!qr.isDark(row, col)) continue;
const startX = (col + marginModules) * scale;
const startY = (row + marginModules) * scale;
for (let y = 0; y < scale; y += 1) {
const pixelY = startY + y;
for (let x = 0; x < scale; x += 1) {
const pixelX = startX + x;
fillPixel(buf, pixelX, pixelY, size, 0, 0, 0, 255);
}
}
}
}
const png = encodePngRgba(buf, size, size);
return `data:image/png;base64,${png.toString('base64')}`;
}
function isLoginFresh(login: ActiveLogin): boolean {
return Date.now() - login.startedAt < ACTIVE_LOGIN_TTL_MS;
}
function resolveConfigPath(): string {
const envPath = process.env.OPENCLAW_CONFIG?.trim();
if (envPath) return envPath;
return join(OPENCLAW_DIR, 'openclaw.json');
}
function loadWeChatRouteTag(accountId?: string): string | undefined {
try {
const configPath = resolveConfigPath();
if (!existsSync(configPath)) return undefined;
const raw = readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(raw) as {
channels?: Record<string, {
routeTag?: string | number;
accounts?: Record<string, { routeTag?: string | number }>;
}>;
};
const section = parsed.channels?.['openclaw-weixin'];
if (!section) return undefined;
if (accountId) {
const normalizedAccountId = normalizeOpenClawAccountId(accountId);
const scopedRouteTag = section.accounts?.[normalizedAccountId]?.routeTag;
if (typeof scopedRouteTag === 'number') return String(scopedRouteTag);
if (typeof scopedRouteTag === 'string' && scopedRouteTag.trim()) return scopedRouteTag.trim();
}
if (typeof section.routeTag === 'number') return String(section.routeTag);
if (typeof section.routeTag === 'string' && section.routeTag.trim()) return section.routeTag.trim();
} catch {
return undefined;
}
return undefined;
}
async function fetchWeChatQrCode(apiBaseUrl: string, accountId?: string, botType = DEFAULT_ILINK_BOT_TYPE): Promise<QrCodeResponse> {
const base = apiBaseUrl.endsWith('/') ? apiBaseUrl : `${apiBaseUrl}/`;
const url = new URL(`ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`, base);
const headers: Record<string, string> = {};
const routeTag = loadWeChatRouteTag(accountId);
if (routeTag) {
headers.SKRouteTag = routeTag;
}
const response = await fetch(url.toString(), { headers });
if (!response.ok) {
const body = await response.text().catch(() => '(unreadable)');
throw new Error(`Failed to fetch QR code: ${response.status} ${response.statusText} ${body}`);
}
return await response.json() as QrCodeResponse;
}
async function pollWeChatQrStatus(apiBaseUrl: string, qrcode: string, accountId?: string): Promise<QrStatusResponse> {
const base = apiBaseUrl.endsWith('/') ? apiBaseUrl : `${apiBaseUrl}/`;
const url = new URL(`ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`, base);
const headers: Record<string, string> = {
'iLink-App-ClientVersion': '1',
};
const routeTag = loadWeChatRouteTag(accountId);
if (routeTag) {
headers.SKRouteTag = routeTag;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), QR_POLL_TIMEOUT_MS);
try {
const response = await fetch(url.toString(), { headers, signal: controller.signal });
clearTimeout(timer);
const rawText = await response.text();
if (!response.ok) {
throw new Error(`Failed to poll QR status: ${response.status} ${response.statusText} ${rawText}`);
}
return JSON.parse(rawText) as QrStatusResponse;
} catch (error) {
clearTimeout(timer);
if (error instanceof Error && error.name === 'AbortError') {
return { status: 'wait' };
}
throw error;
}
}
async function readAccountIndex(): 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 writeAccountIndex(accountIds: string[]): Promise<void> {
await mkdir(WECHAT_STATE_DIR, { recursive: true });
await writeFile(WECHAT_ACCOUNT_INDEX_FILE, JSON.stringify(accountIds, null, 2), 'utf-8');
}
export async function saveWeChatAccountState(rawAccountId: string, payload: {
token: string;
baseUrl?: string;
userId?: string;
}): Promise<string> {
const accountId = normalizeOpenClawAccountId(rawAccountId);
await mkdir(WECHAT_ACCOUNTS_DIR, { recursive: true });
const filePath = join(WECHAT_ACCOUNTS_DIR, `${accountId}.json`);
const data = {
token: payload.token.trim(),
savedAt: new Date().toISOString(),
...(payload.baseUrl?.trim() ? { baseUrl: payload.baseUrl.trim() } : {}),
...(payload.userId?.trim() ? { userId: payload.userId.trim() } : {}),
};
await writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
try {
await chmod(filePath, 0o600);
} catch {
// best effort only
}
const existingAccountIds = await readAccountIndex();
if (!existingAccountIds.includes(accountId)) {
await writeAccountIndex([...existingAccountIds, accountId]);
}
return accountId;
}
export async function startWeChatLoginSession(options: {
sessionKey?: string;
accountId?: string;
apiBaseUrl?: string;
force?: boolean;
}): Promise<WeChatLoginStartResult> {
const sessionKey = options.sessionKey?.trim() || randomUUID();
const apiBaseUrl = options.apiBaseUrl?.trim() || DEFAULT_WECHAT_BASE_URL;
const existing = activeLogins.get(sessionKey);
if (!options.force && existing && isLoginFresh(existing) && existing.qrcodeUrl) {
return {
sessionKey,
qrcodeUrl: existing.qrcodeUrl,
message: 'QR code is ready. Scan it with WeChat.',
};
}
const qrResponse = await fetchWeChatQrCode(apiBaseUrl, options.accountId);
const qrDataUrl = await renderQrPngDataUrl(qrResponse.qrcode_img_content);
activeLogins.set(sessionKey, {
sessionKey,
qrcode: qrResponse.qrcode,
qrcodeUrl: qrDataUrl,
startedAt: Date.now(),
apiBaseUrl,
});
return {
sessionKey,
qrcodeUrl: qrDataUrl,
message: 'Scan the QR code with WeChat to complete login.',
};
}
export async function waitForWeChatLoginSession(options: {
sessionKey: string;
timeoutMs?: number;
accountId?: string;
onQrRefresh?: (payload: { qrcodeUrl: string }) => void | Promise<void>;
}): Promise<WeChatLoginWaitResult> {
const login = activeLogins.get(options.sessionKey);
if (!login) {
return {
connected: false,
message: 'No active WeChat login session. Generate a new QR code and try again.',
};
}
if (!isLoginFresh(login)) {
activeLogins.delete(options.sessionKey);
return {
connected: false,
message: 'The QR code has expired. Generate a new QR code and try again.',
};
}
const timeoutMs = Math.max(options.timeoutMs ?? 480_000, 1000);
const deadline = Date.now() + timeoutMs;
let qrRefreshCount = 1;
while (Date.now() < deadline) {
const current = activeLogins.get(options.sessionKey);
if (!current) {
return {
connected: false,
message: 'The WeChat login session was cancelled.',
};
}
const statusResponse = await pollWeChatQrStatus(current.apiBaseUrl, current.qrcode, options.accountId);
switch (statusResponse.status) {
case 'wait':
case 'scaned':
break;
case 'expired': {
qrRefreshCount += 1;
if (qrRefreshCount > MAX_QR_REFRESH_COUNT) {
activeLogins.delete(options.sessionKey);
return {
connected: false,
message: 'The QR code expired too many times. Generate a new QR code and try again.',
};
}
const refreshedQr = await fetchWeChatQrCode(current.apiBaseUrl, options.accountId);
const refreshedQrDataUrl = await renderQrPngDataUrl(refreshedQr.qrcode_img_content);
activeLogins.set(options.sessionKey, {
...current,
qrcode: refreshedQr.qrcode,
qrcodeUrl: refreshedQrDataUrl,
startedAt: Date.now(),
});
await options.onQrRefresh?.({ qrcodeUrl: refreshedQrDataUrl });
break;
}
case 'confirmed':
activeLogins.delete(options.sessionKey);
if (!statusResponse.ilink_bot_id || !statusResponse.bot_token) {
return {
connected: false,
message: 'WeChat login succeeded but the server did not return the required account credentials.',
};
}
return {
connected: true,
botToken: statusResponse.bot_token,
accountId: statusResponse.ilink_bot_id,
baseUrl: statusResponse.baseurl,
userId: statusResponse.ilink_user_id,
message: 'WeChat connected successfully.',
};
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
activeLogins.delete(options.sessionKey);
return {
connected: false,
message: 'Timed out waiting for WeChat QR confirmation.',
};
}
export async function cancelWeChatLoginSession(sessionKey?: string): Promise<void> {
if (!sessionKey) {
activeLogins.clear();
return;
}
activeLogins.delete(sessionKey);
}
export async function clearWeChatLoginState(): Promise<void> {
activeLogins.clear();
await rm(WECHAT_STATE_DIR, { recursive: true, force: true });
}