diff --git a/README.ja-JP.md b/README.ja-JP.md index 8ffdb964c..58d19c173 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -104,6 +104,7 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています ### 📡 マルチチャネル管理 複数のAIチャネルを同時に設定・監視できます。各チャネルは独立して動作するため、異なるタスクに特化したエージェントを実行できます。 現在は各チャンネルで複数アカウントを扱え、Channels ページでアカウントの Agent 紐付けやデフォルトアカウント切替を直接管理できます。 +ClawX には Tencent 公式の個人 WeChat チャンネルプラグインも同梱されており、Channels ページからアプリ内 QR フローで直接 WeChat を連携できます。 ### ⏰ Cronベースの自動化 AIタスクを自動的に実行するようスケジュール設定できます。トリガーを定義し、間隔を設定することで、手動介入なしにAIエージェントを24時間稼働させることができます。 diff --git a/README.md b/README.md index ef9bffc0b..bbffc8b05 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ When you target another agent with `@agent`, ClawX switches into that agent's ow ### 📡 Multi-Channel Management Configure and monitor multiple AI channels simultaneously. Each channel operates independently, allowing you to run specialized agents for different tasks. Each channel now supports multiple accounts, per-account agent binding, and switching the channel default account directly from the Channels page. +ClawX now also bundles Tencent's official personal WeChat channel plugin, so you can link WeChat directly from the Channels page with an in-app QR flow. ### ⏰ Cron-Based Automation Schedule AI tasks to run automatically. Define triggers, set intervals, and let your AI agents work around the clock without manual intervention. diff --git a/README.zh-CN.md b/README.zh-CN.md index 0377a9701..534a2ac58 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -105,6 +105,7 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们 ### 📡 多频道管理 同时配置和监控多个 AI 频道。每个频道独立运行,允许你为不同任务运行专门的智能体。 现在每个频道支持多个账号,并可在 Channels 页面直接完成账号绑定到 Agent 与默认账号切换。 +ClawX 现在还内置了腾讯官方个人微信渠道插件,可直接在 Channels 页面通过内置二维码流程完成微信连接。 ### ⏰ 定时任务自动化 调度 AI 任务自动执行。定义触发器、设置时间间隔,让 AI 智能体 7×24 小时不间断工作。 diff --git a/electron/api/routes/channels.ts b/electron/api/routes/channels.ts index 610746b6d..f1ab23c79 100644 --- a/electron/api/routes/channels.ts +++ b/electron/api/routes/channels.ts @@ -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(); + +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 { + void ctx; + return await startWeChatLoginSession({ + ...(accountId ? { accountId } : {}), + force: true, + }); +} + +async function awaitWeChatQrLogin( + ctx: HostApiContext, + sessionKey: string, + loginKey: string, +): Promise { + 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 { + 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 { + 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 { if (left.accountId === defaultAccountId) return -1; @@ -225,7 +361,7 @@ async function buildChannelAccountsView(ctx: HostApiContext): Promise { 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; 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) { diff --git a/electron/gateway/config-sync.ts b/electron/gateway/config-sync.ts index 77ee0717f..cfb8e6e4e 100644 --- a/electron/gateway/config-sync.ts +++ b/electron/gateway/config-sync.ts @@ -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 = 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 { diff --git a/electron/main/index.ts b/electron/main/index.ts index 7d21e856b..1fccc07c3 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -332,7 +332,7 @@ async function initialize(): Promise { 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); diff --git a/electron/preload/index.ts b/electron/preload/index.ts index c6d443b81..b3f4afbfb 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -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', diff --git a/electron/utils/agent-config.ts b/electron/utils/agent-config.ts index 78a9f3118..3fdc7b9a6 100644 --- a/electron/utils/agent-config.ts +++ b/electron/utils/agent-config.ts @@ -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 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, }; diff --git a/electron/utils/channel-alias.ts b/electron/utils/channel-alias.ts new file mode 100644 index 000000000..293f7a625 --- /dev/null +++ b/electron/utils/channel-alias.ts @@ -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; +} diff --git a/electron/utils/channel-config.ts b/electron/utils/channel-config.ts index ba3ea9d62..5ba9d584d 100644 --- a/electron/utils/channel-config.ts +++ b/electron/utils/channel-config.ts @@ -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 { 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 { + 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 { + 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 { + const normalizedAccountId = normalizeOpenClawAccountId(accountId); + const legacyRawAccountId = deriveLegacyWeChatRawAccountId(normalizedAccountId); + const candidateIds = new Set([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 { + 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 | 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 { 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 { + 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 { 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 | 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 { 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 { 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 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 { 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 | 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 { 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 ): Promise { - 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 { 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&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 0; +} + +export function hasRecentChannelActivity( + account: Pick, + 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, +): boolean { + return account.probe?.ok === true; +} + +export function hasChannelRuntimeError( + account: Pick, +): 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'; +} diff --git a/electron/utils/plugin-install.ts b/electron/utils/plugin-install.ts index fd44dd6e6..f370cd456 100644 --- a/electron/utils/plugin-install.ts +++ b/electron/utils/plugin-install.ts @@ -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 = { 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; /** diff --git a/electron/utils/wechat-login.ts b/electron/utils/wechat-login.ts new file mode 100644 index 000000000..cfc6710e6 --- /dev/null +++ b/electron/utils/wechat-login.ts @@ -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(); + +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 { + 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; + }>; + }; + 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 { + const base = apiBaseUrl.endsWith('/') ? apiBaseUrl : `${apiBaseUrl}/`; + const url = new URL(`ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`, base); + const headers: Record = {}; + 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 { + const base = apiBaseUrl.endsWith('/') ? apiBaseUrl : `${apiBaseUrl}/`; + const url = new URL(`ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`, base); + const headers: Record = { + '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 { + 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 { + 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 { + 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 { + 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; +}): Promise { + 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 { + if (!sessionKey) { + activeLogins.clear(); + return; + } + activeLogins.delete(sessionKey); +} + +export async function clearWeChatLoginState(): Promise { + activeLogins.clear(); + await rm(WECHAT_STATE_DIR, { recursive: true, force: true }); +} diff --git a/package.json b/package.json index 55814e180..27710e984 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawx", - "version": "0.2.8", + "version": "0.2.9-alpha.0", "pnpm": { "onlyBuiltDependencies": [ "@discordjs/opus", @@ -80,6 +80,7 @@ "@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-tooltip": "^1.2.8", "@sliverp/qqbot": "^1.6.1", + "@tencent-weixin/openclaw-weixin": "^1.0.2", "@soimy/dingtalk": "^3.3.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", @@ -128,4 +129,4 @@ "zx": "^8.8.5" }, "packageManager": "pnpm@10.31.0+sha512.e3927388bfaa8078ceb79b748ffc1e8274e84d75163e67bc22e06c0d3aed43dd153151cbf11d7f8301ff4acb98c68bdc5cadf6989532801ffafe3b3e4a63c268" -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4baf65e0b..d8f67aeed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,7 +81,10 @@ importers: version: 1.6.1(clawdbot@2026.1.24-3(@discordjs/opus@0.10.0(encoding@0.1.13))(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(@types/express@5.0.6)(devtools-protocol@0.0.1596832)(encoding@0.1.13)(opusscript@0.1.1)(typescript@5.9.3))(moltbot@0.1.0)(openclaw@2026.3.13(@discordjs/opus@0.10.0(encoding@0.1.13))(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(encoding@0.1.13)(node-llama-cpp@3.16.2(typescript@5.9.3))) '@soimy/dingtalk': specifier: ^3.3.0 - version: 3.3.0(openclaw@2026.3.13(@discordjs/opus@0.10.0(encoding@0.1.13))(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(encoding@0.1.13)(node-llama-cpp@3.16.2(typescript@5.9.3))) + version: 3.4.0(openclaw@2026.3.13(@discordjs/opus@0.10.0(encoding@0.1.13))(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(encoding@0.1.13)(node-llama-cpp@3.16.2(typescript@5.9.3))) + '@tencent-weixin/openclaw-weixin': + specifier: ^1.0.2 + version: 1.0.2 '@testing-library/jest-dom': specifier: ^6.9.1 version: 6.9.1 @@ -2995,8 +2998,8 @@ packages: resolution: {integrity: sha512-J5f7vV5/tnj0xGnqufFRd6qiWn3FcR3iXjpjpEmO2Ok+Io0AASkMaZ3I39TsL45as0Qo5bq9wWuamFQ77PjJ+g==} engines: {node: '>= 10'} - '@soimy/dingtalk@3.3.0': - resolution: {integrity: sha512-2MkBwfU06s/j2ImGoAilunWbvmNk5zKnsc3yItBD+oPxC2fK4IfqhZvzDDvXyBts89MXs2puKB9LDGRzDa1O/Q==} + '@soimy/dingtalk@3.4.0': + resolution: {integrity: sha512-nk8SAob/TmMtGAaEzr9wJwrAZuENzMdDXQmYFgd/7lzMqnSa7FXkACtJdTZU3TH2qP2XojM/e9QUEiJ/bVWW+w==} peerDependencies: openclaw: '>=2026.2.13' @@ -3007,6 +3010,10 @@ packages: resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} + '@tencent-weixin/openclaw-weixin@1.0.2': + resolution: {integrity: sha512-kYPEowHB/0VWt9nQFee/AS6Fb+jbnu38wjkgZDBdKTuJoBL0IBW2obQ0ymgFKBFJJL9rDwnFrb/17ORGZD/X8Q==} + engines: {node: '>=22'} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -4555,7 +4562,6 @@ packages: glob@11.1.0: resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} engines: {node: 20 || >=22} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@13.0.6: @@ -6588,7 +6594,6 @@ packages: tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me tar@7.5.11: resolution: {integrity: sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==} @@ -6597,7 +6602,6 @@ packages: tar@7.5.4: resolution: {integrity: sha512-AN04xbWGrSTDmVwlI4/GTlIIwMFk/XEv7uL8aa57zuvRy6s4hdBed+lVq2fAZ89XDa7Us3ANXcE3Tvqvja1kTA==} engines: {node: '>=18'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me tar@7.5.9: resolution: {integrity: sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==} @@ -10633,7 +10637,7 @@ snapshots: '@snazzah/davey-win32-ia32-msvc': 0.1.10 '@snazzah/davey-win32-x64-msvc': 0.1.10 - '@soimy/dingtalk@3.3.0(openclaw@2026.3.13(@discordjs/opus@0.10.0(encoding@0.1.13))(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(encoding@0.1.13)(node-llama-cpp@3.16.2(typescript@5.9.3)))': + '@soimy/dingtalk@3.4.0(openclaw@2026.3.13(@discordjs/opus@0.10.0(encoding@0.1.13))(@napi-rs/canvas@0.1.95)(@types/express@5.0.6)(encoding@0.1.13)(node-llama-cpp@3.16.2(typescript@5.9.3)))': dependencies: axios: 1.13.5(debug@4.4.3) dingtalk-stream: 2.1.4 @@ -10654,6 +10658,11 @@ snapshots: dependencies: defer-to-connect: 2.0.1 + '@tencent-weixin/openclaw-weixin@1.0.2': + dependencies: + qrcode-terminal: 0.12.0 + zod: 4.3.6 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs index 99e1a321e..4bd94af1d 100644 --- a/scripts/after-pack.cjs +++ b/scripts/after-pack.cjs @@ -438,6 +438,7 @@ exports.default = async function afterPack(context) { { npmName: '@wecom/wecom-openclaw-plugin', pluginId: 'wecom' }, { npmName: '@sliverp/qqbot', pluginId: 'qqbot' }, { npmName: '@larksuite/openclaw-lark', pluginId: 'feishu-openclaw-plugin' }, + { npmName: '@tencent-weixin/openclaw-weixin', pluginId: 'openclaw-weixin' }, ]; mkdirSync(pluginsDestRoot, { recursive: true }); diff --git a/scripts/bundle-openclaw-plugins.mjs b/scripts/bundle-openclaw-plugins.mjs index a9762c154..3f9821655 100644 --- a/scripts/bundle-openclaw-plugins.mjs +++ b/scripts/bundle-openclaw-plugins.mjs @@ -7,6 +7,7 @@ * Current plugins: * - @soimy/dingtalk -> build/openclaw-plugins/dingtalk * - @wecom/wecom-openclaw-plugin -> build/openclaw-plugins/wecom + * - @tencent-weixin/openclaw-weixin -> build/openclaw-plugins/openclaw-weixin * * The output plugin directory contains: * - plugin source files (index.ts, openclaw.plugin.json, package.json, ...) @@ -39,6 +40,7 @@ const PLUGINS = [ { npmName: '@wecom/wecom-openclaw-plugin', pluginId: 'wecom' }, { npmName: '@sliverp/qqbot', pluginId: 'qqbot' }, { npmName: '@larksuite/openclaw-lark', pluginId: 'feishu-openclaw-plugin' }, + { npmName: '@tencent-weixin/openclaw-weixin', pluginId: 'openclaw-weixin' }, ]; function getVirtualStoreNodeModules(realPkgPath) { diff --git a/src/assets/channels/wechat.svg b/src/assets/channels/wechat.svg new file mode 100644 index 000000000..08c765701 --- /dev/null +++ b/src/assets/channels/wechat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/channels/ChannelConfigModal.tsx b/src/components/channels/ChannelConfigModal.tsx index b8217e9a7..8df158c1e 100644 --- a/src/components/channels/ChannelConfigModal.tsx +++ b/src/components/channels/ChannelConfigModal.tsx @@ -32,11 +32,13 @@ import { type ChannelMeta, type ChannelConfigField, } from '@/types/channel'; +import { buildQrChannelEventName, usesPluginManagedQrAccounts } from '@/lib/channel-alias'; import { toast } from 'sonner'; import { useTranslation } from 'react-i18next'; import telegramIcon from '@/assets/channels/telegram.svg'; import discordIcon from '@/assets/channels/discord.svg'; import whatsappIcon from '@/assets/channels/whatsapp.svg'; +import wechatIcon from '@/assets/channels/wechat.svg'; import dingtalkIcon from '@/assets/channels/dingtalk.svg'; import feishuIcon from '@/assets/channels/feishu.svg'; import wecomIcon from '@/assets/channels/wecom.svg'; @@ -95,9 +97,13 @@ export function ChannelConfigModal({ const meta: ChannelMeta | null = selectedType ? CHANNEL_META[selectedType] : null; const shouldUseCredentialValidation = selectedType !== 'feishu'; - const resolvedAccountId = allowEditAccountId - ? accountIdInput.trim() - : (accountId ?? (agentId ? (agentId === 'main' ? 'default' : agentId) : undefined)); + const usesManagedQrAccounts = usesPluginManagedQrAccounts(selectedType); + const showAccountIdEditor = allowEditAccountId && !usesManagedQrAccounts; + const resolvedAccountId = usesManagedQrAccounts + ? (accountId ?? undefined) + : showAccountIdEditor + ? accountIdInput.trim() + : (accountId ?? (agentId ? (agentId === 'main' ? 'default' : agentId) : undefined)); useEffect(() => { setSelectedType(initialSelectedType); @@ -115,7 +121,6 @@ export function ChannelConfigModal({ setValidationResult(null); setQrCode(null); setConnecting(false); - hostApiFetch('/api/channels/whatsapp/cancel', { method: 'POST' }).catch(() => { }); return; } @@ -195,63 +200,102 @@ export function ChannelConfigModal({ await onChannelSaved?.(channelType); }, [addChannel, channelName, channels, configValues, fetchChannels, meta?.configFields, onChannelSaved, showChannelName]); + const finishSaveRef = useRef(finishSave); + const onCloseRef = useRef(onClose); + const translateRef = useRef(t); + useEffect(() => { - if (selectedType !== 'whatsapp') return; + finishSaveRef.current = finishSave; + }, [finishSave]); + + useEffect(() => { + onCloseRef.current = onClose; + }, [onClose]); + + useEffect(() => { + translateRef.current = t; + }, [t]); + + function normalizeQrImageSource(data: { qr?: string; raw?: string }): string | null { + const qr = typeof data.qr === 'string' ? data.qr.trim() : ''; + if (qr) { + if (qr.startsWith('data:image') || qr.startsWith('http://') || qr.startsWith('https://')) { + return qr; + } + return `data:image/png;base64,${qr}`; + } + + const raw = typeof data.raw === 'string' ? data.raw.trim() : ''; + if (!raw) return null; + if (raw.startsWith('data:image') || raw.startsWith('http://') || raw.startsWith('https://')) { + return raw; + } + return null; + } + + useEffect(() => { + if (!selectedType || meta?.connectionType !== 'qr') return; + const channelType = selectedType; const onQr = (...args: unknown[]) => { - const data = args[0] as { qr: string; raw: string }; - void data.raw; - setQrCode(`data:image/png;base64,${data.qr}`); + const data = args[0] as { qr?: string; raw?: string }; + const nextQr = normalizeQrImageSource(data); + if (!nextQr) return; + setQrCode(nextQr); + setConnecting(false); }; const onSuccess = async (...args: unknown[]) => { const data = args[0] as { accountId?: string } | undefined; void data?.accountId; - toast.success(t('toast.whatsappConnected')); + toast.success(translateRef.current('toast.qrConnected', { name: CHANNEL_NAMES[channelType] })); try { - const saveResult = await hostApiFetch<{ success?: boolean; error?: string }>('/api/channels/config', { - method: 'POST', - body: JSON.stringify({ channelType: 'whatsapp', config: { enabled: true }, accountId: resolvedAccountId }), - }); - if (!saveResult?.success) { - throw new Error(saveResult?.error || 'Failed to save WhatsApp config'); + if (channelType === 'whatsapp') { + const saveResult = await hostApiFetch<{ success?: boolean; error?: string }>('/api/channels/config', { + method: 'POST', + body: JSON.stringify({ channelType: 'whatsapp', config: { enabled: true }, accountId: resolvedAccountId }), + }); + if (!saveResult?.success) { + throw new Error(saveResult?.error || 'Failed to save WhatsApp config'); + } } try { - await finishSave('whatsapp'); + await finishSaveRef.current(channelType); } catch (postSaveError) { - toast.warning(t('toast.savedButRefreshFailed')); + toast.warning(translateRef.current('toast.savedButRefreshFailed')); console.warn('Channel saved but post-save refresh failed:', postSaveError); } - // Gateway restart is already triggered by scheduleGatewayChannelRestart - // in the POST /api/channels/config route handler (debounced). Calling - // restart() here directly races with that debounced restart and the - // config write, which can cause openclaw.json overwrites. - onClose(); + onCloseRef.current(); } catch (error) { - toast.error(t('toast.configFailed', { error: String(error) })); + toast.error(translateRef.current('toast.configFailed', { error: String(error) })); setConnecting(false); } }; const onError = (...args: unknown[]) => { - const err = args[0] as string; - toast.error(t('toast.whatsappFailed', { error: err })); + const err = typeof args[0] === 'string' + ? args[0] + : String((args[0] as { message?: string } | undefined)?.message || args[0]); + toast.error(translateRef.current('toast.qrFailed', { name: CHANNEL_NAMES[channelType], error: err })); setQrCode(null); setConnecting(false); }; - const removeQrListener = subscribeHostEvent('channel:whatsapp-qr', onQr); - const removeSuccessListener = subscribeHostEvent('channel:whatsapp-success', onSuccess); - const removeErrorListener = subscribeHostEvent('channel:whatsapp-error', onError); + const removeQrListener = subscribeHostEvent(buildQrChannelEventName(channelType, 'qr'), onQr); + const removeSuccessListener = subscribeHostEvent(buildQrChannelEventName(channelType, 'success'), onSuccess); + const removeErrorListener = subscribeHostEvent(buildQrChannelEventName(channelType, 'error'), onError); return () => { removeQrListener(); removeSuccessListener(); removeErrorListener(); - hostApiFetch('/api/channels/whatsapp/cancel', { method: 'POST' }).catch(() => { }); + hostApiFetch(`/api/channels/${encodeURIComponent(channelType)}/cancel`, { + method: 'POST', + body: JSON.stringify(resolvedAccountId ? { accountId: resolvedAccountId } : {}), + }).catch(() => { }); }; - }, [finishSave, onClose, resolvedAccountId, selectedType, t]); + }, [meta?.connectionType, resolvedAccountId, selectedType]); const handleValidate = async () => { if (!selectedType || !shouldUseCredentialValidation) return; @@ -302,7 +346,7 @@ export function ChannelConfigModal({ setValidationResult(null); try { - if (allowEditAccountId) { + if (showAccountIdEditor) { const nextAccountId = accountIdInput.trim(); if (!nextAccountId) { toast.error(t('account.invalidId')); @@ -318,9 +362,9 @@ export function ChannelConfigModal({ } if (meta.connectionType === 'qr') { - await hostApiFetch('/api/channels/whatsapp/start', { + await hostApiFetch(`/api/channels/${encodeURIComponent(selectedType)}/start`, { method: 'POST', - body: JSON.stringify({ accountId: resolvedAccountId || 'default' }), + body: JSON.stringify(resolvedAccountId ? { accountId: resolvedAccountId } : {}), }); return; } @@ -513,7 +557,7 @@ export function ChannelConfigModal({ ) : qrCode ? (
- {qrCode.startsWith('data:image') ? ( + {qrCode.startsWith('data:image') || qrCode.startsWith('http://') || qrCode.startsWith('https://') ? ( Scan QR Code ) : (
@@ -590,7 +634,7 @@ export function ChannelConfigModal({
)} - {allowEditAccountId && ( + {showAccountIdEditor && (
{ void handleConnect(); }} - disabled={connecting || !isFormValid() || (allowEditAccountId && !accountIdInput.trim())} + disabled={connecting || !isFormValid() || (showAccountIdEditor && !accountIdInput.trim())} className={primaryButtonClasses} > {connecting ? ( @@ -736,6 +780,8 @@ function ChannelLogo({ type }: { type: ChannelType }) { return Discord; case 'whatsapp': return WhatsApp; + case 'wechat': + return WeChat; case 'dingtalk': return DingTalk; case 'feishu': diff --git a/src/i18n/locales/en/channels.json b/src/i18n/locales/en/channels.json index 6f461f3c0..e85aaf3d2 100644 --- a/src/i18n/locales/en/channels.json +++ b/src/i18n/locales/en/channels.json @@ -22,6 +22,8 @@ "toast": { "whatsappConnected": "WhatsApp connected successfully", "whatsappFailed": "WhatsApp connection failed: {{error}}", + "qrConnected": "{{name}} connected successfully", + "qrFailed": "{{name}} connection failed: {{error}}", "channelSaved": "Channel {{name}} saved", "channelConnecting": "Connecting to {{name}}...", "savedButRefreshFailed": "Configuration was saved, but refreshing page data failed. Please refresh manually.", @@ -157,6 +159,16 @@ "The system will automatically identify your phone number" ] }, + "wechat": { + "description": "Connect personal WeChat with Tencent's official OpenClaw plugin by scanning a QR code", + "docsUrl": "https://icnnp7d0dymg.feishu.cn/wiki/GHYZwuPCriF0gWkXwkFcJ9zon3b", + "instructions": [ + "Click Generate QR Code to install and enable the official WeChat plugin inside OpenClaw", + "Scan the QR code below with WeChat and confirm the connection on your phone", + "After linking succeeds, a new WeChat ClawBot chat will appear in WeChat automatically", + "You can repeat the QR flow later to add another WeChat account or reconnect an existing one" + ] + }, "dingtalk": { "description": "Connect DingTalk via OpenClaw channel plugin (Stream mode)", "docsUrl": "https://icnnp7d0dymg.feishu.cn/wiki/Y5eNwiSiZidkLskrwtJc1rUln0b#doxcnDgA78n43DbkiQjI1OqUA7b", diff --git a/src/i18n/locales/ja/channels.json b/src/i18n/locales/ja/channels.json index 4fcc0eabf..9e24490b2 100644 --- a/src/i18n/locales/ja/channels.json +++ b/src/i18n/locales/ja/channels.json @@ -22,6 +22,8 @@ "toast": { "whatsappConnected": "WhatsApp が正常に接続されました", "whatsappFailed": "WhatsApp 接続に失敗しました: {{error}}", + "qrConnected": "{{name}} が正常に接続されました", + "qrFailed": "{{name}} の接続に失敗しました: {{error}}", "channelSaved": "チャンネル {{name}} が保存されました", "channelConnecting": "{{name}} に接続中...", "savedButRefreshFailed": "設定は保存されましたが、画面データの更新に失敗しました。手動で再読み込みしてください。", @@ -157,6 +159,16 @@ "システムが自動的に電話番号を識別します" ] }, + "wechat": { + "description": "Tencent 公式の OpenClaw プラグインを使い、QRコードをスキャンして個人 WeChat に接続します", + "docsUrl": "https://icnnp7d0dymg.feishu.cn/wiki/GHYZwuPCriF0gWkXwkFcJ9zon3b", + "instructions": [ + "QRコードを生成すると、ClawX が OpenClaw に公式 WeChat プラグインをインストールして有効化します", + "以下の QR コードを WeChat でスキャンし、スマートフォン側で接続を確認します", + "接続が完了すると、WeChat に新しい「WeChat ClawBot」チャットが自動で表示されます", + "後から同じ QR フローを使って、別の WeChat アカウントを追加したり既存アカウントを再接続したりできます" + ] + }, "dingtalk": { "description": "OpenClaw のチャンネルプラグイン経由で DingTalk に接続します(Stream モード)", "docsUrl": "https://icnnp7d0dymg.feishu.cn/wiki/Y5eNwiSiZidkLskrwtJc1rUln0b#doxcnDgA78n43DbkiQjI1OqUA7b", diff --git a/src/i18n/locales/zh/channels.json b/src/i18n/locales/zh/channels.json index 2dcf8a455..de6d2a73e 100644 --- a/src/i18n/locales/zh/channels.json +++ b/src/i18n/locales/zh/channels.json @@ -22,6 +22,8 @@ "toast": { "whatsappConnected": "WhatsApp 连接成功", "whatsappFailed": "WhatsApp 连接失败: {{error}}", + "qrConnected": "{{name}} 连接成功", + "qrFailed": "{{name}} 连接失败: {{error}}", "channelSaved": "频道 {{name}} 已保存", "channelConnecting": "正在连接 {{name}}...", "savedButRefreshFailed": "配置已保存,但刷新页面数据失败,请手动刷新查看最新状态", @@ -157,6 +159,16 @@ "系统将自动识别您的手机号" ] }, + "wechat": { + "description": "通过腾讯官方 OpenClaw 插件扫码连接个人微信", + "docsUrl": "https://icnnp7d0dymg.feishu.cn/wiki/GHYZwuPCriF0gWkXwkFcJ9zon3b", + "instructions": [ + "点击生成二维码,ClawX 会在 OpenClaw 中安装并启用官方微信插件", + "使用微信扫描下方二维码,并在手机上确认连接", + "连接成功后,微信里会自动出现新的「微信 ClawBot」对话", + "之后可再次通过扫码流程添加更多微信账号,或重新连接已有账号" + ] + }, "dingtalk": { "description": "通过 OpenClaw 渠道插件连接钉钉(Stream 模式)", "docsUrl": "https://icnnp7d0dymg.feishu.cn/wiki/Y5eNwiSiZidkLskrwtJc1rUln0b#doxcnr8KfaA2mNPeQUeHO83eDPh", diff --git a/src/lib/channel-alias.ts b/src/lib/channel-alias.ts new file mode 100644 index 000000000..5b5a589d6 --- /dev/null +++ b/src/lib/channel-alias.ts @@ -0,0 +1,50 @@ +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 usesPluginManagedQrAccounts(channelType: string | null | undefined): boolean { + return isWechatChannelType(channelType); +} + +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; +} diff --git a/src/lib/host-events.ts b/src/lib/host-events.ts index 200f731b8..ff380b12a 100644 --- a/src/lib/host-events.ts +++ b/src/lib/host-events.ts @@ -15,6 +15,9 @@ const HOST_EVENT_TO_IPC_CHANNEL: Record = { 'channel:whatsapp-qr': 'channel:whatsapp-qr', 'channel:whatsapp-success': 'channel:whatsapp-success', 'channel:whatsapp-error': 'channel:whatsapp-error', + 'channel:wechat-qr': 'channel:wechat-qr', + 'channel:wechat-success': 'channel:wechat-success', + 'channel:wechat-error': 'channel:wechat-error', }; function getEventSource(): EventSource { diff --git a/src/pages/Agents/index.tsx b/src/pages/Agents/index.tsx index ed1652c67..fa8a242b2 100644 --- a/src/pages/Agents/index.tsx +++ b/src/pages/Agents/index.tsx @@ -19,6 +19,7 @@ import { cn } from '@/lib/utils'; import telegramIcon from '@/assets/channels/telegram.svg'; import discordIcon from '@/assets/channels/discord.svg'; import whatsappIcon from '@/assets/channels/whatsapp.svg'; +import wechatIcon from '@/assets/channels/wechat.svg'; import dingtalkIcon from '@/assets/channels/dingtalk.svg'; import feishuIcon from '@/assets/channels/feishu.svg'; import wecomIcon from '@/assets/channels/wecom.svg'; @@ -324,6 +325,8 @@ function ChannelLogo({ type }: { type: ChannelType }) { return Discord; case 'whatsapp': return WhatsApp; + case 'wechat': + return WeChat; case 'dingtalk': return DingTalk; case 'feishu': diff --git a/src/pages/Channels/index.tsx b/src/pages/Channels/index.tsx index a87f7f580..1b5993a0c 100644 --- a/src/pages/Channels/index.tsx +++ b/src/pages/Channels/index.tsx @@ -16,12 +16,14 @@ import { getPrimaryChannels, type ChannelType, } from '@/types/channel'; +import { usesPluginManagedQrAccounts } from '@/lib/channel-alias'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import telegramIcon from '@/assets/channels/telegram.svg'; import discordIcon from '@/assets/channels/discord.svg'; import whatsappIcon from '@/assets/channels/whatsapp.svg'; +import wechatIcon from '@/assets/channels/wechat.svg'; import dingtalkIcon from '@/assets/channels/dingtalk.svg'; import feishuIcon from '@/assets/channels/feishu.svg'; import wecomIcon from '@/assets/channels/wecom.svg'; @@ -307,14 +309,17 @@ export function Channels() { variant="outline" className="h-8 text-xs rounded-full" onClick={() => { - const nextAccountId = createNewAccountId( - group.channelType, - group.accounts.map((item) => item.accountId), - ); + const shouldUseGeneratedAccountId = !usesPluginManagedQrAccounts(group.channelType); + const nextAccountId = shouldUseGeneratedAccountId + ? createNewAccountId( + group.channelType, + group.accounts.map((item) => item.accountId), + ) + : undefined; setSelectedChannelType(group.channelType as ChannelType); setSelectedAccountId(nextAccountId); setAllowExistingConfigInModal(false); - setAllowEditAccountIdInModal(true); + setAllowEditAccountIdInModal(shouldUseGeneratedAccountId); setExistingAccountIdsForModal(group.accounts.map((item) => item.accountId)); setInitialConfigValuesForModal(undefined); setShowConfigModal(true); @@ -519,6 +524,8 @@ function ChannelLogo({ type }: { type: ChannelType }) { return Discord; case 'whatsapp': return WhatsApp; + case 'wechat': + return WeChat; case 'dingtalk': return DingTalk; case 'feishu': diff --git a/src/stores/channels.ts b/src/stores/channels.ts index 58d2db897..5dbc21242 100644 --- a/src/stores/channels.ts +++ b/src/stores/channels.ts @@ -11,6 +11,7 @@ import { } from '@/lib/channel-status'; import { useGatewayStore } from './gateway'; import { CHANNEL_NAMES, type Channel, type ChannelType } from '../types/channel'; +import { toOpenClawChannelType, toUiChannelType } from '@/lib/channel-alias'; interface AddChannelParams { type: ChannelType; @@ -40,6 +41,17 @@ interface ChannelsState { const reconnectTimers = new Map(); const reconnectAttempts = new Map(); +function splitChannelId(channelId: string): { channelType: string; accountId?: string } { + const separatorIndex = channelId.indexOf('-'); + if (separatorIndex === -1) { + return { channelType: channelId }; + } + return { + channelType: channelId.slice(0, separatorIndex), + accountId: channelId.slice(separatorIndex + 1), + }; +} + export const useChannelsStore = create((set, get) => ({ channels: [], loading: false, @@ -75,6 +87,8 @@ export const useChannelsStore = create((set, get) => ({ // Parse the complex channels.status response into simple Channel objects const channelOrder = data.channelOrder || Object.keys(data.channels || {}); for (const channelId of channelOrder) { + const uiChannelId = toUiChannelType(channelId) as ChannelType; + const gatewayChannelId = toOpenClawChannelType(channelId); const summary = (data.channels as Record | undefined)?.[channelId] as Record | undefined; const configured = typeof summary?.configured === 'boolean' @@ -101,14 +115,17 @@ export const useChannelsStore = create((set, get) => ({ : undefined; channels.push({ - id: `${channelId}-${primaryAccount?.accountId || 'default'}`, - type: channelId as ChannelType, - name: primaryAccount?.name || CHANNEL_NAMES[channelId as ChannelType] || channelId, + id: `${uiChannelId}-${primaryAccount?.accountId || 'default'}`, + type: uiChannelId, + name: primaryAccount?.name || CHANNEL_NAMES[uiChannelId] || uiChannelId, status, accountId: primaryAccount?.accountId, error: (typeof primaryAccount?.lastError === 'string' ? primaryAccount.lastError : undefined) || (typeof summaryError === 'string' ? summaryError : undefined), + metadata: { + gatewayChannelId, + }, }); } @@ -162,7 +179,8 @@ export const useChannelsStore = create((set, get) => ({ deleteChannel: async (channelId) => { // Extract channel type from the channelId (format: "channelType-accountId") - const channelType = channelId.split('-')[0]; + const { channelType } = splitChannelId(channelId); + const gatewayChannelType = toOpenClawChannelType(channelType); try { // Delete the channel configuration from openclaw.json @@ -174,7 +192,7 @@ export const useChannelsStore = create((set, get) => ({ } try { - await useGatewayStore.getState().rpc('channels.delete', { channelId: channelType }); + await useGatewayStore.getState().rpc('channels.delete', { channelId: gatewayChannelType }); } catch (error) { // Continue with local deletion even if gateway fails console.error('Failed to delete channel from gateway:', error); @@ -191,7 +209,10 @@ export const useChannelsStore = create((set, get) => ({ updateChannel(channelId, { status: 'connecting', error: undefined }); try { - await useGatewayStore.getState().rpc('channels.connect', { channelId }); + const { channelType, accountId } = splitChannelId(channelId); + await useGatewayStore.getState().rpc('channels.connect', { + channelId: `${toOpenClawChannelType(channelType)}${accountId ? `-${accountId}` : ''}`, + }); updateChannel(channelId, { status: 'connected' }); } catch (error) { updateChannel(channelId, { status: 'error', error: String(error) }); @@ -203,7 +224,10 @@ export const useChannelsStore = create((set, get) => ({ clearAutoReconnect(channelId); try { - await useGatewayStore.getState().rpc('channels.disconnect', { channelId }); + const { channelType, accountId } = splitChannelId(channelId); + await useGatewayStore.getState().rpc('channels.disconnect', { + channelId: `${toOpenClawChannelType(channelType)}${accountId ? `-${accountId}` : ''}`, + }); } catch (error) { console.error('Failed to disconnect channel:', error); } @@ -214,7 +238,7 @@ export const useChannelsStore = create((set, get) => ({ requestQrCode: async (channelType) => { return await useGatewayStore.getState().rpc<{ qrCode: string; sessionId: string }>( 'channels.requestQr', - { type: channelType }, + { type: toOpenClawChannelType(channelType) }, ); }, diff --git a/src/types/channel.ts b/src/types/channel.ts index f84edd749..7832da421 100644 --- a/src/types/channel.ts +++ b/src/types/channel.ts @@ -8,6 +8,7 @@ */ export type ChannelType = | 'whatsapp' + | 'wechat' | 'dingtalk' | 'telegram' | 'discord' @@ -81,6 +82,7 @@ export interface ChannelMeta { */ export const CHANNEL_ICONS: Record = { whatsapp: '📱', + wechat: '💬', dingtalk: '💬', telegram: '✈️', discord: '🎮', @@ -101,6 +103,7 @@ export const CHANNEL_ICONS: Record = { */ export const CHANNEL_NAMES: Record = { whatsapp: 'WhatsApp', + wechat: 'WeChat', dingtalk: 'DingTalk', telegram: 'Telegram', discord: 'Discord', @@ -323,6 +326,22 @@ export const CHANNEL_META: Record = { 'channels:meta.whatsapp.instructions.3', ], }, + wechat: { + id: 'wechat', + name: 'WeChat', + icon: '💬', + description: 'channels:meta.wechat.description', + connectionType: 'qr', + docsUrl: 'channels:meta.wechat.docsUrl', + configFields: [], + instructions: [ + 'channels:meta.wechat.instructions.0', + 'channels:meta.wechat.instructions.1', + 'channels:meta.wechat.instructions.2', + 'channels:meta.wechat.instructions.3', + ], + isPlugin: true, + }, signal: { id: 'signal', name: 'Signal', @@ -561,7 +580,7 @@ export const CHANNEL_META: Record = { * Get primary supported channels (non-plugin, commonly used) */ export function getPrimaryChannels(): ChannelType[] { - return ['telegram', 'discord', 'whatsapp', 'dingtalk', 'feishu', 'wecom', 'qqbot']; + return ['telegram', 'discord', 'whatsapp', 'wechat', 'dingtalk', 'feishu', 'wecom', 'qqbot']; } /** diff --git a/tests/unit/channel-config.test.ts b/tests/unit/channel-config.test.ts index 08bd1921c..0742bc055 100644 --- a/tests/unit/channel-config.test.ts +++ b/tests/unit/channel-config.test.ts @@ -1,4 +1,5 @@ -import { readFile, rm } from 'fs/promises'; +import { existsSync } from 'fs'; +import { mkdir, readFile, rm, writeFile } from 'fs/promises'; import { join } from 'path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -158,3 +159,38 @@ describe('WeCom plugin configuration', () => { expect(plugins.entries['wecom'].enabled).toBe(true); }); }); + +describe('WeChat dangling plugin cleanup', () => { + beforeEach(async () => { + vi.resetAllMocks(); + vi.resetModules(); + await rm(testHome, { recursive: true, force: true }); + await rm(testUserData, { recursive: true, force: true }); + }); + + it('removes dangling openclaw-weixin plugin registration and state when no channel config exists', async () => { + const { cleanupDanglingWeChatPluginState, writeOpenClawConfig } = await import('@electron/utils/channel-config'); + + await writeOpenClawConfig({ + plugins: { + enabled: true, + allow: ['openclaw-weixin'], + entries: { + 'openclaw-weixin': { enabled: true }, + }, + }, + }); + + const staleStateDir = join(testHome, '.openclaw', 'openclaw-weixin', 'accounts'); + await mkdir(staleStateDir, { recursive: true }); + await writeFile(join(staleStateDir, 'bot-im-bot.json'), JSON.stringify({ token: 'stale-token' }), 'utf8'); + await writeFile(join(testHome, '.openclaw', 'openclaw-weixin', 'accounts.json'), JSON.stringify(['bot-im-bot']), 'utf8'); + + const result = await cleanupDanglingWeChatPluginState(); + expect(result.cleanedDanglingState).toBe(true); + + const config = await readOpenClawJson(); + expect(config.plugins).toBeUndefined(); + expect(existsSync(join(testHome, '.openclaw', 'openclaw-weixin'))).toBe(false); + }); +}); diff --git a/tests/unit/channel-routes.test.ts b/tests/unit/channel-routes.test.ts index b8b2fd69c..5f0b82c77 100644 --- a/tests/unit/channel-routes.test.ts +++ b/tests/unit/channel-routes.test.ts @@ -8,6 +8,7 @@ const listAgentsSnapshotMock = vi.fn(); const sendJsonMock = vi.fn(); vi.mock('@electron/utils/channel-config', () => ({ + cleanupDanglingWeChatPluginState: vi.fn(), deleteChannelAccountConfig: vi.fn(), deleteChannelConfig: vi.fn(), getChannelFormValues: vi.fn(), @@ -32,9 +33,17 @@ vi.mock('@electron/utils/plugin-install', () => ({ ensureDingTalkPluginInstalled: vi.fn(), ensureFeishuPluginInstalled: vi.fn(), ensureQQBotPluginInstalled: vi.fn(), + ensureWeChatPluginInstalled: vi.fn(), ensureWeComPluginInstalled: vi.fn(), })); +vi.mock('@electron/utils/wechat-login', () => ({ + cancelWeChatLoginSession: vi.fn(), + saveWeChatAccountState: vi.fn(), + startWeChatLoginSession: vi.fn(), + waitForWeChatLoginSession: vi.fn(), +})); + vi.mock('@electron/utils/whatsapp-login', () => ({ whatsAppLoginManager: { start: vi.fn(), @@ -51,7 +60,7 @@ describe('handleChannelRoutes', () => { beforeEach(() => { vi.resetAllMocks(); listAgentsSnapshotMock.mockResolvedValue({ - entries: [], + agents: [], channelAccountOwners: {}, }); readOpenClawConfigMock.mockResolvedValue({ @@ -75,7 +84,7 @@ describe('handleChannelRoutes', () => { }, }); listAgentsSnapshotMock.mockResolvedValue({ - entries: [], + agents: [], channelAccountOwners: { 'feishu:default': 'main', 'feishu:feishu-2412524e': 'code', diff --git a/tests/unit/channels-page.test.tsx b/tests/unit/channels-page.test.tsx index 5d1d17481..66b694427 100644 --- a/tests/unit/channels-page.test.tsx +++ b/tests/unit/channels-page.test.tsx @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, render, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { Channels } from '@/pages/Channels/index'; const hostApiFetchMock = vi.fn(); @@ -126,4 +126,58 @@ describe('Channels page status refresh', () => { expect(agentFetchCalls).toHaveLength(2); }); }); + + it('treats WeChat accounts as plugin-managed QR accounts', async () => { + subscribeHostEventMock.mockImplementation(() => vi.fn()); + hostApiFetchMock.mockImplementation(async (path: string) => { + if (path === '/api/channels/accounts') { + return { + success: true, + channels: [ + { + channelType: 'wechat', + defaultAccountId: 'wx-bot-im-bot', + status: 'connected', + accounts: [ + { + accountId: 'wx-bot-im-bot', + name: 'WeChat ClawBot', + configured: true, + status: 'connected', + isDefault: true, + }, + ], + }, + ], + }; + } + + if (path === '/api/agents') { + return { + success: true, + agents: [], + }; + } + + if (path === '/api/channels/wechat/cancel') { + return { success: true }; + } + + throw new Error(`Unexpected host API path: ${path}`); + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('WeChat')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: 'account.add' })); + + await waitFor(() => { + expect(screen.getByText('dialog.configureTitle')).toBeInTheDocument(); + }); + + expect(screen.queryByLabelText('account.customIdLabel')).not.toBeInTheDocument(); + }); }); diff --git a/tests/unit/wechat-login.test.ts b/tests/unit/wechat-login.test.ts new file mode 100644 index 000000000..f6fd4a156 --- /dev/null +++ b/tests/unit/wechat-login.test.ts @@ -0,0 +1,103 @@ +import { readFile, rm } from 'fs/promises'; +import { join } from 'path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { testHome } = vi.hoisted(() => { + const suffix = Math.random().toString(36).slice(2); + return { + testHome: `/tmp/clawx-wechat-login-${suffix}`, + }; +}); + +vi.mock('node:os', async () => { + const actual = await vi.importActual('node:os'); + const mocked = { + ...actual, + homedir: () => testHome, + }; + return { + ...mocked, + default: mocked, + }; +}); + +vi.mock('electron', () => ({ + app: { + isPackaged: false, + getPath: () => '/tmp/clawx-test-user-data', + getVersion: () => '0.0.0-test', + getAppPath: () => '/tmp', + }, +})); + +describe('wechat login utility', () => { + beforeEach(async () => { + vi.resetAllMocks(); + vi.resetModules(); + await rm(testHome, { recursive: true, force: true }); + }); + + it('starts a QR session, waits for confirmation, and stores account state in the plugin path', async () => { + const fetchMock = vi.fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + qrcode: 'qr-token', + qrcode_img_content: 'https://example.com/qr.png', + }), + }) + .mockResolvedValueOnce({ + ok: true, + text: async () => JSON.stringify({ status: 'wait' }), + }) + .mockResolvedValueOnce({ + ok: true, + text: async () => JSON.stringify({ + status: 'confirmed', + bot_token: 'secret-token', + ilink_bot_id: 'bot@im.bot', + baseurl: 'https://ilinkai.weixin.qq.com', + ilink_user_id: 'user-123', + }), + }); + vi.stubGlobal('fetch', fetchMock); + + const { + saveWeChatAccountState, + startWeChatLoginSession, + waitForWeChatLoginSession, + } = await import('@electron/utils/wechat-login'); + + const startResult = await startWeChatLoginSession({}); + expect(startResult.qrcodeUrl).toMatch(/^data:image\/png;base64,/); + expect(startResult.sessionKey).toBeTruthy(); + + const waitResult = await waitForWeChatLoginSession({ + sessionKey: startResult.sessionKey, + timeoutMs: 2_500, + }); + expect(waitResult.connected).toBe(true); + expect(waitResult.accountId).toBe('bot@im.bot'); + expect(waitResult.botToken).toBe('secret-token'); + + const normalizedAccountId = await saveWeChatAccountState(waitResult.accountId!, { + token: waitResult.botToken!, + baseUrl: waitResult.baseUrl, + userId: waitResult.userId, + }); + + expect(normalizedAccountId).toBe('bot-im-bot'); + + const accountFile = JSON.parse( + await readFile(join(testHome, '.openclaw', 'openclaw-weixin', 'accounts', 'bot-im-bot.json'), 'utf-8'), + ) as { token?: string; baseUrl?: string; userId?: string }; + expect(accountFile.token).toBe('secret-token'); + expect(accountFile.baseUrl).toBe('https://ilinkai.weixin.qq.com'); + expect(accountFile.userId).toBe('user-123'); + + const accountIndex = JSON.parse( + await readFile(join(testHome, '.openclaw', 'openclaw-weixin', 'accounts.json'), 'utf-8'), + ) as string[]; + expect(accountIndex).toEqual(['bot-im-bot']); + }); +});