feat(im): 新增微信 IM 接入,支持扫码绑定微信机器人

- 新增 weixin 平台类型定义、默认配置与 Redux 状态管理
- 集成 openclaw-weixin 插件,通过 OpenClaw Gateway RPC 实现二维码扫码登录
- 新增微信 IM 设置面板(二维码扫码、DM/群策略、连通性测试)
- 同步微信配置至 OpenClaw channels.openclaw-weixin
- 安装后自动补丁 openclaw-weixin 插件的 gatewayMethods 声明
- 新增中英文 i18n 翻译,微信在 IM 列表中位于企微上方
This commit is contained in:
liuzhq
2026-03-22 16:24:28 +08:00
parent d03e2cf108
commit fc692b99d6
15 changed files with 815 additions and 19 deletions
+5
View File
@@ -31,6 +31,11 @@
"npm": "@wecom/wecom-openclaw-plugin",
"version": "1.0.12"
},
{
"id": "openclaw-weixin",
"npm": "@tencent-weixin/openclaw-weixin",
"version": "1.0.2"
},
{
"id": "moltbot-popo",
"npm": "moltbot-popo",
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

+23
View File
@@ -298,3 +298,26 @@ for (const plugin of plugins) {
}
log(`All ${plugins.length} plugin(s) installed successfully.`);
// --- Post-install patch: openclaw-weixin gatewayMethods ---
// The openclaw-weixin plugin defines loginWithQrStart/loginWithQrWait in its
// gateway adapter but does not declare gatewayMethods on the channel plugin
// object. Without this declaration, the gateway's resolveWebLoginProvider()
// cannot discover the plugin for web.login.start/web.login.wait RPC calls.
// Patch channel.ts to inject the missing property.
const weixinChannelPath = path.join(runtimeExtensionsDir, 'openclaw-weixin', 'src', 'channel.ts');
if (fs.existsSync(weixinChannelPath)) {
let src = fs.readFileSync(weixinChannelPath, 'utf8');
if (!src.includes('gatewayMethods')) {
// Insert gatewayMethods right after the configSchema block in weixinPlugin
const marker = 'configSchema: {';
const idx = src.indexOf(marker);
if (idx !== -1) {
src = src.slice(0, idx) + 'gatewayMethods: ["web.login.start", "web.login.wait"],\n ' + src.slice(idx);
fs.writeFileSync(weixinChannelPath, src);
log('Patched openclaw-weixin/src/channel.ts: added gatewayMethods declaration');
}
} else {
log('openclaw-weixin/src/channel.ts already has gatewayMethods, skipping patch');
}
}
+161 -2
View File
@@ -1,7 +1,7 @@
/**
* IM Gateway Manager
* Unified manager for DingTalk, Feishu, NIM, Xiaomifeng gateways
* and Telegram, Discord, QQ, WeCom, POPO via OpenClaw
* and Telegram, Discord, QQ, WeCom, Weixin, POPO via OpenClaw
*/
import { EventEmitter } from 'events';
@@ -170,6 +170,8 @@ export class IMGatewayManager extends EventEmitter {
// WeCom runs via OpenClaw; no direct gateway events to forward
// Weixin runs via OpenClaw; no direct gateway events to forward
// POPO runs via OpenClaw; no direct gateway events to forward
}
@@ -193,6 +195,8 @@ export class IMGatewayManager extends EventEmitter {
// WeCom runs via OpenClaw; no direct reconnection needed
// Weixin runs via OpenClaw; no direct reconnection needed
// POPO runs via OpenClaw; no direct reconnection needed
}
@@ -276,6 +280,7 @@ export class IMGatewayManager extends EventEmitter {
target = this.nimGateway.getNotificationTarget();
}
// WeCom runs via OpenClaw; notification target not managed locally
// Weixin runs via OpenClaw; notification target not managed locally
// POPO runs via OpenClaw; notification target not managed locally
if (target != null) {
this.imStore.setNotificationTarget(platform, target);
@@ -297,6 +302,7 @@ export class IMGatewayManager extends EventEmitter {
this.nimGateway.setNotificationTarget(target);
}
// WeCom runs via OpenClaw; notification target not managed locally
// Weixin runs via OpenClaw; notification target not managed locally
// POPO runs via OpenClaw; notification target not managed locally
console.log(`[IMGatewayManager] Restored notification target for ${platform}`);
} catch (err: any) {
@@ -410,6 +416,8 @@ export class IMGatewayManager extends EventEmitter {
// WeCom runs via OpenClaw; config changes are synced via OpenClawConfigSync
// Weixin runs via OpenClaw; config changes are synced via OpenClawConfigSync
// POPO runs via OpenClaw; config changes are synced via OpenClawConfigSync
}
@@ -496,6 +504,13 @@ export class IMGatewayManager extends EventEmitter {
lastInboundAt: null as number | null,
lastOutboundAt: null as number | null,
},
weixin: {
connected: Boolean(config.weixin?.enabled),
startedAt: null as number | null,
lastError: null as string | null,
lastInboundAt: null as number | null,
lastOutboundAt: null as number | null,
},
popo: {
connected: Boolean(config.popo?.enabled && config.popo.appKey && config.popo.appSecret && config.popo.aesKey && (config.popo.connectionMode === 'websocket' || config.popo.token)),
startedAt: null as number | null,
@@ -539,6 +554,11 @@ export class IMGatewayManager extends EventEmitter {
return this.testWecomOpenClawConnectivity(configOverride);
}
// Weixin always uses OpenClaw mode
if (platform === 'weixin') {
return this.testWeixinOpenClawConnectivity(configOverride);
}
// POPO always uses OpenClaw mode
if (platform === 'popo') {
return this.testPopoOpenClawConnectivity(configOverride);
@@ -746,6 +766,12 @@ export class IMGatewayManager extends EventEmitter {
await this.syncOpenClawConfig?.();
await this.ensureOpenClawGatewayConnected?.();
return;
} else if (platform === 'weixin') {
// Weixin runs via OpenClaw gateway (weixin-openclaw-plugin)
console.log('[IMGatewayManager] Weixin in OpenClaw mode, syncing config instead of starting direct gateway');
await this.syncOpenClawConfig?.();
await this.ensureOpenClawGatewayConnected?.();
return;
} else if (platform === 'popo') {
// POPO runs via OpenClaw gateway (moltbot-popo plugin)
console.log('[IMGatewayManager] POPO in OpenClaw mode, syncing config instead of starting direct gateway');
@@ -796,6 +822,11 @@ export class IMGatewayManager extends EventEmitter {
console.log('[IMGatewayManager] WeCom in OpenClaw mode, syncing disabled config');
await this.syncOpenClawConfig?.();
return;
} else if (platform === 'weixin') {
// Weixin runs via OpenClaw gateway
console.log('[IMGatewayManager] Weixin in OpenClaw mode, syncing disabled config');
await this.syncOpenClawConfig?.();
return;
} else if (platform === 'popo') {
// POPO runs via OpenClaw gateway
console.log('[IMGatewayManager] POPO in OpenClaw mode, syncing disabled config');
@@ -807,7 +838,7 @@ export class IMGatewayManager extends EventEmitter {
/**
* Start all enabled gateways.
*
* OpenClaw platforms (dingtalk/feishu/telegram/discord/qq/wecom/popo/nim) are batched
* OpenClaw platforms (dingtalk/feishu/telegram/discord/qq/wecom/weixin/popo/nim) are batched
* so that `syncOpenClawConfig` + `ensureOpenClawGatewayConnected` are called
* only **once** regardless of how many OpenClaw platforms are enabled.
* This avoids N serial gateway restarts which cause message loss, Telegram
@@ -851,6 +882,9 @@ export class IMGatewayManager extends EventEmitter {
if (config.wecom?.enabled && config.wecom?.botId && config.wecom?.secret) {
openClawPlatformsToStart.push('wecom');
}
if (config.weixin?.enabled) {
openClawPlatformsToStart.push('weixin');
}
if (config.popo?.enabled && config.popo?.appKey && config.popo?.appSecret && config.popo?.aesKey && (config.popo.connectionMode === 'websocket' || config.popo.token)) {
openClawPlatformsToStart.push('popo');
}
@@ -913,6 +947,11 @@ export class IMGatewayManager extends EventEmitter {
const config = this.getConfig();
return Boolean(config.wecom?.enabled && config.wecom.botId && config.wecom.secret);
}
if (platform === 'weixin') {
// Weixin runs via OpenClaw; consider it connected when enabled
const config = this.getConfig();
return Boolean(config.weixin?.enabled);
}
if (platform === 'popo') {
// POPO runs via OpenClaw; consider it connected when enabled and configured
const config = this.getConfig();
@@ -937,6 +976,9 @@ export class IMGatewayManager extends EventEmitter {
} else if (platform === 'wecom') {
// WeCom runs via OpenClaw; notifications are handled by the wecom-openclaw-plugin
console.log('[IMGatewayManager] WeCom notification via OpenClaw not yet supported');
} else if (platform === 'weixin') {
// Weixin runs via OpenClaw; notifications are handled by the weixin-openclaw-plugin
console.log('[IMGatewayManager] Weixin notification via OpenClaw not yet supported');
} else if (platform === 'popo') {
// POPO runs via OpenClaw; notifications are handled by the moltbot-popo plugin
console.log('[IMGatewayManager] POPO notification via OpenClaw not yet supported');
@@ -966,6 +1008,9 @@ export class IMGatewayManager extends EventEmitter {
} else if (platform === 'wecom') {
// WeCom runs via OpenClaw; notifications are handled by the wecom-openclaw-plugin
console.log('[IMGatewayManager] WeCom notification with media via OpenClaw not yet supported');
} else if (platform === 'weixin') {
// Weixin runs via OpenClaw; notifications are handled by the weixin-openclaw-plugin
console.log('[IMGatewayManager] Weixin notification with media via OpenClaw not yet supported');
} else if (platform === 'popo') {
// POPO runs via OpenClaw; notifications are handled by the moltbot-popo plugin
console.log('[IMGatewayManager] POPO notification with media via OpenClaw not yet supported');
@@ -1338,6 +1383,106 @@ export class IMGatewayManager extends EventEmitter {
return { platform, testedAt, verdict, checks };
}
private async testWeixinOpenClawConnectivity(
configOverride?: Partial<IMGatewayConfig>
): Promise<IMConnectivityTestResult> {
const checks: IMConnectivityCheck[] = [];
const testedAt = Date.now();
const platform: IMPlatform = 'weixin';
const mergedConfig = this.buildMergedConfig(configOverride);
const wxConfig = mergedConfig.weixin;
// Weixin has no credentials; just check if enabled
if (!wxConfig?.enabled) {
checks.push({
code: 'gateway_running',
level: 'info',
message: '微信渠道当前未启用。',
suggestion: '请启用微信渠道后重新测试连通性。',
});
return { platform, testedAt, verdict: 'pass', checks };
}
// Config completeness passes (no credentials needed)
checks.push({
code: 'auth_check',
level: 'pass',
message: '微信配置已就绪。',
});
// OpenClaw Gateway running info
checks.push({
code: 'gateway_running',
level: 'info',
message: '微信通过 OpenClaw 运行时运行,Bot 将在 OpenClaw Gateway 启动后自动连接。',
});
const verdict: IMConnectivityVerdict = checks.some(c => c.level === 'fail')
? 'fail'
: checks.some(c => c.level === 'warn')
? 'warn'
: 'pass';
return { platform, testedAt, verdict, checks };
}
/**
* Start Weixin QR code login via OpenClaw Gateway RPC.
* Returns the QR code data URL and a session key for polling.
*/
async weixinQrLoginStart(): Promise<{ qrDataUrl?: string; message: string; sessionKey?: string }> {
const client = this.getOpenClawGatewayClient?.();
if (!client) {
await this.ensureOpenClawGatewayReady?.();
const retryClient = this.getOpenClawGatewayClient?.();
if (!retryClient) {
return { message: 'OpenClaw Gateway is not running. Please start OpenClaw engine first.' };
}
return this.doWeixinQrLoginStart(retryClient);
}
return this.doWeixinQrLoginStart(client);
}
private async doWeixinQrLoginStart(client: GatewayClientLike): Promise<{ qrDataUrl?: string; message: string; sessionKey?: string }> {
try {
const result = await client.request<{ qrDataUrl?: string; message: string; sessionKey?: string }>(
'web.login.start',
{ force: true, timeoutMs: 300000, verbose: true },
);
console.log('[IMGatewayManager] Weixin QR login start result:', result.message);
return result;
} catch (err) {
console.error('[IMGatewayManager] Weixin QR login start failed:', err);
return { message: `Failed to start Weixin login: ${String(err)}` };
}
}
/**
* Wait for Weixin QR code scan completion via OpenClaw Gateway RPC.
*/
async weixinQrLoginWait(accountId?: string): Promise<{ connected: boolean; message: string; accountId?: string }> {
const client = this.getOpenClawGatewayClient?.();
if (!client) {
return { connected: false, message: 'OpenClaw Gateway is not connected.' };
}
try {
const result = await client.request<{ connected: boolean; message: string; accountId?: string }>(
'web.login.wait',
{ timeoutMs: 480000, ...(accountId ? { accountId } : {}) },
);
console.log('[IMGatewayManager] Weixin QR login wait result:', result.message, 'connected:', result.connected);
if (result.connected) {
// Sync config to pick up new account
await this.syncOpenClawConfig?.();
}
return result;
} catch (err) {
console.error('[IMGatewayManager] Weixin QR login wait failed:', err);
return { connected: false, message: `Login failed: ${String(err)}` };
}
}
private async testNimOpenClawConnectivity(
configOverride?: Partial<IMGatewayConfig>
): Promise<IMConnectivityTestResult> {
@@ -1464,6 +1609,7 @@ export class IMGatewayManager extends EventEmitter {
nim: { ...current.nim, ...(configOverride.nim || {}) },
xiaomifeng: { ...current.xiaomifeng, ...(configOverride.xiaomifeng || {}) },
wecom: { ...current.wecom, ...(configOverride.wecom || {}) },
weixin: { ...current.weixin, ...(configOverride.weixin || {}) },
popo: { ...current.popo, ...(configOverride.popo || {}) },
settings: { ...current.settings, ...(configOverride.settings || {}) },
};
@@ -1510,6 +1656,10 @@ export class IMGatewayManager extends EventEmitter {
if (!config.wecom?.secret) fields.push('secret');
return fields;
}
if (platform === 'weixin') {
// Weixin has no credentials; nothing to check
return [];
}
if (platform === 'popo') {
const fields: string[] = [];
if (!config.popo?.appKey) fields.push('appKey');
@@ -1578,6 +1728,11 @@ export class IMGatewayManager extends EventEmitter {
}
if (platform === 'weixin') {
// Weixin has no credentials to probe; just confirm enabled
return '微信配置已就绪,通过 OpenClaw 运行。';
}
if (platform === 'popo') {
const { appKey, appSecret, token, aesKey, connectionMode } = config.popo;
const isWebhook = (connectionMode ?? 'websocket') === 'webhook';
@@ -2081,6 +2236,7 @@ export class IMGatewayManager extends EventEmitter {
if (platform === 'xiaomifeng') return status.xiaomifeng.startedAt;
if (platform === 'qq') return status.qq.startedAt;
if (platform === 'wecom') return status.wecom.startedAt;
if (platform === 'weixin') return status.weixin.startedAt;
if (platform === 'popo') return status.popo.startedAt;
return status.discord.startedAt;
}
@@ -2093,6 +2249,7 @@ export class IMGatewayManager extends EventEmitter {
if (platform === 'xiaomifeng') return status.xiaomifeng.lastInboundAt;
if (platform === 'qq') return status.qq.lastInboundAt;
if (platform === 'wecom') return status.wecom.lastInboundAt;
if (platform === 'weixin') return status.weixin.lastInboundAt;
if (platform === 'popo') return status.popo.lastInboundAt;
return status.discord.lastInboundAt;
}
@@ -2105,6 +2262,7 @@ export class IMGatewayManager extends EventEmitter {
if (platform === 'xiaomifeng') return status.xiaomifeng.lastOutboundAt;
if (platform === 'qq') return status.qq.lastOutboundAt;
if (platform === 'wecom') return status.wecom.lastOutboundAt;
if (platform === 'weixin') return status.weixin.lastOutboundAt;
if (platform === 'popo') return status.popo.lastOutboundAt;
return status.discord.lastOutboundAt;
}
@@ -2117,6 +2275,7 @@ export class IMGatewayManager extends EventEmitter {
if (platform === 'xiaomifeng') return status.xiaomifeng.lastError;
if (platform === 'qq') return status.qq.lastError;
if (platform === 'wecom') return status.wecom.lastError;
if (platform === 'weixin') return status.weixin.lastError;
if (platform === 'popo') return status.popo.lastError;
return status.discord.lastError;
}
+20 -1
View File
@@ -16,6 +16,7 @@ import {
WecomConfig,
WecomOpenClawConfig,
PopoOpenClawConfig,
WeixinOpenClawConfig,
IMSettings,
IMPlatform,
IMSessionMapping,
@@ -28,6 +29,7 @@ import {
DEFAULT_XIAOMIFENG_CONFIG,
DEFAULT_WECOM_CONFIG,
DEFAULT_POPO_CONFIG,
DEFAULT_WEIXIN_CONFIG,
DEFAULT_IM_SETTINGS,
} from './types';
@@ -76,7 +78,7 @@ export class IMStore {
* Migrate existing IM configs to ensure stable defaults.
*/
private migrateDefaults(): void {
const platforms = ['dingtalk', 'feishu', 'telegram', 'discord', 'nim', 'xiaomifeng', 'qq', 'wecom', 'popo'] as const;
const platforms = ['dingtalk', 'feishu', 'telegram', 'discord', 'nim', 'xiaomifeng', 'qq', 'wecom', 'popo', 'weixin'] as const;
let changed = false;
for (const platform of platforms) {
@@ -354,6 +356,7 @@ export class IMStore {
const qq = this.getConfigValue<QQConfig>('qq') ?? DEFAULT_QQ_CONFIG;
const wecom = this.getConfigValue<WecomOpenClawConfig>('wecomOpenClaw') ?? DEFAULT_WECOM_CONFIG;
const popo = this.getConfigValue<PopoOpenClawConfig>('popo') ?? DEFAULT_POPO_CONFIG;
const weixin = this.getConfigValue<WeixinOpenClawConfig>('weixin') ?? DEFAULT_WEIXIN_CONFIG;
const settings = this.getConfigValue<IMSettings>('settings') ?? DEFAULT_IM_SETTINGS;
// Resolve enabled field: default to false for safety
@@ -377,6 +380,7 @@ export class IMStore {
qq: resolveEnabled(qq, DEFAULT_QQ_CONFIG),
wecom: resolveEnabled(wecom, DEFAULT_WECOM_CONFIG),
popo: resolveEnabled(popo, DEFAULT_POPO_CONFIG),
weixin: resolveEnabled(weixin, DEFAULT_WEIXIN_CONFIG),
settings: { ...DEFAULT_IM_SETTINGS, ...settings },
};
}
@@ -409,6 +413,9 @@ export class IMStore {
if (config.popo) {
this.setPopoConfig(config.popo);
}
if (config.weixin) {
this.setWeixinConfig(config.weixin);
}
if (config.settings) {
this.setIMSettings(config.settings);
}
@@ -522,6 +529,18 @@ export class IMStore {
this.setConfigValue('popo', { ...current, ...config });
}
// ==================== Weixin (微信) ====================
getWeixinConfig(): WeixinOpenClawConfig {
const stored = this.getConfigValue<WeixinOpenClawConfig>('weixin');
return { ...DEFAULT_WEIXIN_CONFIG, ...stored };
}
setWeixinConfig(config: Partial<WeixinOpenClawConfig>): void {
const current = this.getWeixinConfig();
this.setConfigValue('weixin', { ...current, ...config });
}
// ==================== IM Settings ====================
getIMSettings(): IMSettings {
+41 -1
View File
@@ -277,9 +277,28 @@ export interface PopoGatewayStatus {
lastOutboundAt: number | null;
}
// ==================== Weixin (微信) Types ====================
export interface WeixinOpenClawConfig {
enabled: boolean;
dmPolicy: 'open' | 'pairing' | 'allowlist' | 'disabled';
allowFrom: string[];
groupPolicy: 'open' | 'allowlist' | 'disabled';
groupAllowFrom: string[];
debug: boolean;
}
export interface WeixinGatewayStatus {
connected: boolean;
startedAt: number | null;
lastError: string | null;
lastInboundAt: number | null;
lastOutboundAt: number | null;
}
// ==================== Common IM Types ====================
export type IMPlatform = 'dingtalk' | 'feishu' | 'qq' | 'telegram' | 'discord' | 'nim' | 'xiaomifeng' | 'wecom' | 'popo';
export type IMPlatform = 'dingtalk' | 'feishu' | 'qq' | 'telegram' | 'discord' | 'nim' | 'xiaomifeng' | 'wecom' | 'popo' | 'weixin';
export interface IMGatewayConfig {
dingtalk: DingTalkOpenClawConfig;
@@ -291,6 +310,7 @@ export interface IMGatewayConfig {
xiaomifeng: XiaomifengConfig;
wecom: WecomOpenClawConfig;
popo: PopoOpenClawConfig;
weixin: WeixinOpenClawConfig;
settings: IMSettings;
}
@@ -309,6 +329,7 @@ export interface IMGatewayStatus {
xiaomifeng: XiaomifengGatewayStatus;
wecom: WecomGatewayStatus;
popo: PopoGatewayStatus;
weixin: WeixinGatewayStatus;
}
// ==================== Media Attachment Types ====================
@@ -549,6 +570,15 @@ export const DEFAULT_POPO_CONFIG: PopoOpenClawConfig = {
debug: true,
};
export const DEFAULT_WEIXIN_CONFIG: WeixinOpenClawConfig = {
enabled: false,
dmPolicy: 'open',
allowFrom: [],
groupPolicy: 'open',
groupAllowFrom: [],
debug: true,
};
export const DEFAULT_IM_SETTINGS: IMSettings = {
systemPrompt: '',
skillsEnabled: true,
@@ -564,6 +594,7 @@ export const DEFAULT_IM_CONFIG: IMGatewayConfig = {
xiaomifeng: DEFAULT_XIAOMIFENG_CONFIG,
wecom: DEFAULT_WECOM_CONFIG,
popo: DEFAULT_POPO_CONFIG,
weixin: DEFAULT_WEIXIN_CONFIG,
settings: DEFAULT_IM_SETTINGS,
};
@@ -637,6 +668,14 @@ export const DEFAULT_POPO_STATUS: PopoGatewayStatus = {
lastOutboundAt: null,
};
export const DEFAULT_WEIXIN_STATUS: WeixinGatewayStatus = {
connected: false,
startedAt: null,
lastError: null,
lastInboundAt: null,
lastOutboundAt: null,
};
export const DEFAULT_IM_STATUS: IMGatewayStatus = {
dingtalk: DEFAULT_DINGTALK_STATUS,
feishu: DEFAULT_FEISHU_STATUS,
@@ -654,6 +693,7 @@ export const DEFAULT_IM_STATUS: IMGatewayStatus = {
xiaomifeng: DEFAULT_XIAOMIFENG_STATUS,
wecom: DEFAULT_WECOM_STATUS,
popo: DEFAULT_POPO_STATUS,
weixin: DEFAULT_WEIXIN_STATUS,
};
// ==================== Media Marker Types ====================
+28 -2
View File
@@ -3,7 +3,7 @@ import fs from 'fs';
import path from 'path';
import type { CoworkConfig, CoworkExecutionMode } from '../coworkStore';
import type { TelegramOpenClawConfig, DiscordOpenClawConfig } from '../im/types';
import type { DingTalkOpenClawConfig, FeishuOpenClawConfig, QQOpenClawConfig, WecomOpenClawConfig, PopoOpenClawConfig, NimConfig } from '../im/types';
import type { DingTalkOpenClawConfig, FeishuOpenClawConfig, QQOpenClawConfig, WecomOpenClawConfig, PopoOpenClawConfig, NimConfig, WeixinOpenClawConfig } from '../im/types';
import { resolveRawApiConfig } from './claudeSettings';
import type { OpenClawEngineManager } from './openclawEngineManager';
import { parseChannelSessionKey } from './openclawChannelSessionSync';
@@ -430,6 +430,7 @@ type OpenClawConfigSyncDeps = {
getWecomConfig: () => WecomOpenClawConfig | null;
getPopoConfig: () => PopoOpenClawConfig | null;
getNimConfig: () => NimConfig | null;
getWeixinConfig: () => WeixinOpenClawConfig | null;
getMcpBridgeConfig?: () => McpBridgeConfig | null;
getSkillsList?: () => Array<{ id: string; enabled: boolean }>;
};
@@ -445,6 +446,7 @@ export class OpenClawConfigSync {
private readonly getWecomConfig: () => WecomOpenClawConfig | null;
private readonly getPopoConfig: () => PopoOpenClawConfig | null;
private readonly getNimConfig: () => NimConfig | null;
private readonly getWeixinConfig: () => WeixinOpenClawConfig | null;
private readonly getMcpBridgeConfig?: () => McpBridgeConfig | null;
private readonly getSkillsList?: () => Array<{ id: string; enabled: boolean }>;
@@ -459,6 +461,7 @@ export class OpenClawConfigSync {
this.getWecomConfig = deps.getWecomConfig;
this.getPopoConfig = deps.getPopoConfig;
this.getNimConfig = deps.getNimConfig;
this.getWeixinConfig = deps.getWeixinConfig;
this.getMcpBridgeConfig = deps.getMcpBridgeConfig;
this.getSkillsList = deps.getSkillsList;
}
@@ -525,9 +528,11 @@ export class OpenClawConfigSync {
const wecomConfig = this.getWecomConfig();
const popoConfig = this.getPopoConfig();
const nimConfig = this.getNimConfig();
const weixinConfig = this.getWeixinConfig();
const hasAnyChannel = hasDingTalkOpenClaw;
const managedConfig: Record<string, unknown> = {
@@ -605,6 +610,7 @@ export class OpenClawConfigSync {
if (id === 'wecom-openclaw-plugin') return !!(wecomConfig?.enabled && wecomConfig.botId);
if (id === 'moltbot-popo') return !!(popoConfig?.enabled && popoConfig.appKey);
if (id === 'nim') return !!(nimConfig?.enabled && nimConfig.appKey && nimConfig.account && nimConfig.token);
if (id === 'openclaw-weixin') return true; // Always keep enabled for QR login discovery
return true; // other plugins stay enabled
})();
return [id, { enabled: pluginEnabled }];
@@ -895,6 +901,26 @@ export class OpenClawConfigSync {
managedConfig.channels = { ...(managedConfig.channels as Record<string, unknown> || {}), 'nim': nimChannel };
}
// Sync Weixin OpenClaw channel config (via openclaw-weixin-cli plugin)
if (weixinConfig?.enabled) {
const weixinChannel: Record<string, unknown> = {
enabled: true,
dmPolicy: weixinConfig.dmPolicy || 'open',
allowFrom: (() => {
const ids = weixinConfig.allowFrom?.length ? [...weixinConfig.allowFrom] : [];
if (weixinConfig.dmPolicy === 'open' && !ids.includes('*')) ids.push('*');
return ids;
})(),
groupPolicy: weixinConfig.groupPolicy || 'open',
groupAllowFrom: (() => {
const ids = weixinConfig.groupAllowFrom?.length ? [...weixinConfig.groupAllowFrom] : [];
if (weixinConfig.groupPolicy === 'open' && !ids.includes('*')) ids.push('*');
return ids;
})(),
};
managedConfig.channels = { ...(managedConfig.channels as Record<string, unknown> || {}), 'openclaw-weixin': weixinChannel };
}
const nextContent = `${JSON.stringify(managedConfig, null, 2)}\n`;
let currentContent = '';
try {
+26
View File
@@ -792,6 +792,13 @@ const getOpenClawConfigSync = (): OpenClawConfigSync => {
return null;
}
},
getWeixinConfig: () => {
try {
return getIMGatewayManager().getConfig().weixin;
} catch {
return null;
}
},
getDiscordOpenClawConfig: () => {
try {
return getIMGatewayManager()?.getConfig()?.discord ?? null;
@@ -2952,6 +2959,25 @@ if (!gotTheLock) {
}
});
// Weixin QR login
ipcMain.handle('im:weixin:qr-login-start', async () => {
try {
const result = await getIMGatewayManager().weixinQrLoginStart();
return { success: true, ...result };
} catch (error) {
return { success: false, message: error instanceof Error ? error.message : 'Failed to start Weixin QR login' };
}
});
ipcMain.handle('im:weixin:qr-login-wait', async (_event, accountId?: string) => {
try {
const result = await getIMGatewayManager().weixinQrLoginWait(accountId);
return { success: true, ...result };
} catch (error) {
return { success: false, connected: false, message: error instanceof Error ? error.message : 'Weixin QR login failed' };
}
});
ipcMain.handle('im:status:get', async () => {
try {
const status = getIMGatewayManager().getStatus();
+7 -3
View File
@@ -288,10 +288,10 @@ contextBridge.exposeInMainWorld('electron', {
syncConfig: () => ipcRenderer.invoke('im:config:sync'),
// Gateway control
startGateway: (platform: 'dingtalk' | 'feishu' | 'telegram' | 'discord' | 'nim' | 'xiaomifeng' | 'wecom' | 'popo') => ipcRenderer.invoke('im:gateway:start', platform),
stopGateway: (platform: 'dingtalk' | 'feishu' | 'telegram' | 'discord' | 'nim' | 'xiaomifeng' | 'wecom' | 'popo') => ipcRenderer.invoke('im:gateway:stop', platform),
startGateway: (platform: 'dingtalk' | 'feishu' | 'telegram' | 'discord' | 'nim' | 'xiaomifeng' | 'wecom' | 'popo' | 'weixin') => ipcRenderer.invoke('im:gateway:start', platform),
stopGateway: (platform: 'dingtalk' | 'feishu' | 'telegram' | 'discord' | 'nim' | 'xiaomifeng' | 'wecom' | 'popo' | 'weixin') => ipcRenderer.invoke('im:gateway:stop', platform),
testGateway: (
platform: 'dingtalk' | 'feishu' | 'telegram' | 'discord' | 'nim' | 'xiaomifeng' | 'wecom' | 'popo',
platform: 'dingtalk' | 'feishu' | 'telegram' | 'discord' | 'nim' | 'xiaomifeng' | 'wecom' | 'popo' | 'weixin',
configOverride?: any
) => ipcRenderer.invoke('im:gateway:test', platform, configOverride),
@@ -302,6 +302,10 @@ contextBridge.exposeInMainWorld('electron', {
getOpenClawConfigSchema: () => ipcRenderer.invoke('im:openclaw:config-schema'),
// Weixin QR login
weixinQrLoginStart: () => ipcRenderer.invoke('im:weixin:qr-login-start'),
weixinQrLoginWait: (accountId?: string) => ipcRenderer.invoke('im:weixin:qr-login-wait', accountId),
// Pairing
listPairingRequests: (platform: string) => ipcRenderer.invoke('im:pairing:list', platform),
approvePairingCode: (platform: string, code: string) => ipcRenderer.invoke('im:pairing:approve', platform, code),
+407 -2
View File
@@ -9,9 +9,9 @@ import { SignalIcon, XMarkIcon, CheckCircleIcon, XCircleIcon, ExclamationTriangl
import { EyeIcon, EyeSlashIcon, XCircleIcon as XCircleIconSolid } from '@heroicons/react/20/solid';
import { RootState } from '../../store';
import { imService } from '../../services/im';
import { setDingTalkConfig, setFeishuConfig, setTelegramOpenClawConfig, setQQConfig, setDiscordConfig, setNimConfig, setXiaomifengConfig, setWecomConfig, setPopoConfig, clearError } from '../../store/slices/imSlice';
import { setDingTalkConfig, setFeishuConfig, setTelegramOpenClawConfig, setQQConfig, setDiscordConfig, setNimConfig, setXiaomifengConfig, setWecomConfig, setWeixinConfig, setPopoConfig, clearError } from '../../store/slices/imSlice';
import { i18nService } from '../../services/i18n';
import type { IMPlatform, IMConnectivityCheck, IMConnectivityTestResult, IMGatewayConfig, TelegramOpenClawConfig, DiscordOpenClawConfig, FeishuOpenClawConfig, DingTalkOpenClawConfig, QQOpenClawConfig, WecomOpenClawConfig, PopoOpenClawConfig } from '../../types/im';
import type { IMPlatform, IMConnectivityCheck, IMConnectivityTestResult, IMGatewayConfig, TelegramOpenClawConfig, DiscordOpenClawConfig, FeishuOpenClawConfig, DingTalkOpenClawConfig, QQOpenClawConfig, WecomOpenClawConfig, WeixinOpenClawConfig, PopoOpenClawConfig } from '../../types/im';
import { getVisibleIMPlatforms } from '../../utils/regionFilter';
import WecomAIBotSDK from '@wecom/wecom-aibot-sdk';
import { QRCodeSVG } from 'qrcode.react';
@@ -28,6 +28,7 @@ const platformLogos: Record<IMPlatform, string> = {
discord: 'discord.svg',
nim: 'nim.png',
xiaomifeng: 'xiaomifeng.png',
weixin: 'weixin.png',
wecom: 'wecom.png',
popo: 'popo.png',
};
@@ -40,6 +41,7 @@ const IM_GUIDE_URLS: Partial<Record<IMPlatform, string>> = {
qq: 'https://lobsterai.youdao.com/#/docs/lobsterai_im_bot_config_guide/qqqq-bot',
telegram: 'https://lobsterai.youdao.com/#/en/docs/lobsterai_im_bot_config_guide/telegram-bot-configuration',
discord: 'https://lobsterai.youdao.com/#/en/docs/lobsterai_im_bot_config_guide/discord-bot-configuration',
weixin: '',
popo: '',
};
@@ -133,6 +135,11 @@ const IMSettings: React.FC = () => {
// WeCom quick setup state
const [wecomQuickSetupStatus, setWecomQuickSetupStatus] = useState<'idle' | 'pending' | 'success' | 'error'>('idle');
const [wecomQuickSetupError, setWecomQuickSetupError] = useState<string>('');
// Weixin QR login state
const [weixinQrStatus, setWeixinQrStatus] = useState<'idle' | 'loading' | 'showing' | 'waiting' | 'success' | 'error'>('idle');
const [weixinQrUrl, setWeixinQrUrl] = useState<string>('');
const [weixinQrError, setWeixinQrError] = useState<string>('');
const [, setWeixinQrSessionKey] = useState<string>('');
const [localIp, setLocalIp] = useState<string>('');
const isMountedRef = useRef(true);
@@ -244,6 +251,16 @@ const IMSettings: React.FC = () => {
}
}, [activePlatform]);
// Reset weixin QR login state when switching away from weixin
useEffect(() => {
if (activePlatform !== 'weixin') {
setWeixinQrStatus('idle');
setWeixinQrUrl('');
setWeixinQrError('');
setWeixinQrSessionKey('');
}
}, [activePlatform]);
// Reset password visibility when switching platforms
useEffect(() => {
setShowSecrets({});
@@ -393,6 +410,10 @@ const IMSettings: React.FC = () => {
const [popoAllowedUserIdInput, setPopoAllowedUserIdInput] = useState('');
const [popoGroupAllowIdInput, setPopoGroupAllowIdInput] = useState('');
// State for Weixin allow-from inputs
const [weixinAllowedUserIdInput, setWeixinAllowedUserIdInput] = useState('');
const [weixinGroupAllowIdInput, setWeixinGroupAllowIdInput] = useState('');
// Handle Xiaomifeng config change
const handleXiaomifengChange = (field: 'clientId' | 'secret', value: string) => {
dispatch(setXiaomifengConfig({ [field]: value }));
@@ -411,6 +432,19 @@ const IMSettings: React.FC = () => {
await imService.persistConfig({ wecom: configToSave });
};
// Handle Weixin OpenClaw config change
const weixinOpenClawConfig = config.weixin;
const handleWeixinOpenClawChange = (update: Partial<WeixinOpenClawConfig>) => {
dispatch(setWeixinConfig(update));
};
const handleSaveWeixinOpenClawConfig = async (override?: Partial<WeixinOpenClawConfig>) => {
if (!configLoaded) return;
const configToSave = override
? { ...weixinOpenClawConfig, ...override }
: weixinOpenClawConfig;
await imService.persistConfig({ weixin: configToSave });
};
// Handle POPO OpenClaw config change
const popoConfig = config.popo;
const handlePopoChange = (update: Partial<PopoOpenClawConfig>) => {
@@ -462,6 +496,46 @@ const IMSettings: React.FC = () => {
}
};
const handleWeixinQrLogin = async () => {
setWeixinQrStatus('loading');
setWeixinQrError('');
try {
const startResult = await window.electron.im.weixinQrLoginStart();
if (!isMountedRef.current) return;
if (!startResult.success || !startResult.qrDataUrl) {
setWeixinQrStatus('error');
setWeixinQrError(startResult.message || i18nService.t('imWeixinQrFailed'));
return;
}
setWeixinQrUrl(startResult.qrDataUrl);
setWeixinQrStatus('showing');
// Start polling for scan result
setWeixinQrStatus('waiting');
const waitResult = await window.electron.im.weixinQrLoginWait(startResult.sessionKey);
if (!isMountedRef.current) return;
if (waitResult.success && waitResult.connected) {
setWeixinQrStatus('success');
// Enable weixin and save config
const fullConfig = { ...weixinOpenClawConfig, enabled: true };
dispatch(setWeixinConfig({ enabled: true }));
dispatch(clearError());
await imService.updateConfig({ weixin: fullConfig });
await imService.loadStatus();
} else {
setWeixinQrStatus('error');
setWeixinQrError(waitResult.message || i18nService.t('imWeixinQrFailed'));
}
} catch (err) {
if (!isMountedRef.current) return;
setWeixinQrStatus('error');
setWeixinQrError(String(err));
}
};
const handleSaveConfig = async () => {
if (!configLoaded) return;
@@ -496,6 +570,12 @@ const IMSettings: React.FC = () => {
return;
}
// For Weixin, save weixin config directly (OpenClaw mode)
if (activePlatform === 'weixin') {
await imService.persistConfig({ weixin: weixinOpenClawConfig });
return;
}
// For POPO, save popo config directly (OpenClaw mode)
if (activePlatform === 'popo') {
await imService.persistConfig({ popo: popoConfig });
@@ -626,6 +706,17 @@ const IMSettings: React.FC = () => {
return;
}
if (platform === 'weixin') {
const newEnabled = !weixinOpenClawConfig.enabled;
const success = await imService.updateConfig({ weixin: { ...weixinOpenClawConfig, enabled: newEnabled } });
if (success) {
dispatch(setWeixinConfig({ enabled: newEnabled }));
if (newEnabled) dispatch(clearError());
await imService.loadStatus();
}
return;
}
if (platform === 'popo') {
const newEnabled = !popoConfig.enabled;
const success = await imService.updateConfig({ popo: { ...popoConfig, enabled: newEnabled } });
@@ -687,6 +778,7 @@ const IMSettings: React.FC = () => {
const xiaomifengConnected = status.xiaomifeng?.connected ?? false;
const qqConnected = status.qq?.connected ?? false;
const wecomConnected = status.wecom?.connected ?? false;
const weixinConnected = status.weixin?.connected ?? false;
const popoConnected = status.popo?.connected ?? false;
// Compute visible platforms based on language
@@ -725,6 +817,9 @@ const IMSettings: React.FC = () => {
if (platform === 'wecom') {
return !!(wecomOpenClawConfig.botId && wecomOpenClawConfig.secret);
}
if (platform === 'weixin') {
return true; // No credentials needed, connects via QR code in CLI
}
if (platform === 'popo') {
const effectiveMode = config.popo.connectionMode || (config.popo.token ? 'webhook' : 'websocket');
if (effectiveMode === 'webhook') {
@@ -749,6 +844,7 @@ const IMSettings: React.FC = () => {
if (platform === 'xiaomifeng') return xiaomifengConnected;
if (platform === 'qq') return qqConnected;
if (platform === 'wecom') return wecomConnected;
if (platform === 'weixin') return weixinConnected;
if (platform === 'popo') return popoConnected;
return feishuConnected;
};
@@ -811,6 +907,21 @@ const IMSettings: React.FC = () => {
return;
}
// For Weixin, persist weixin config and test (OpenClaw mode)
if (platform === 'weixin') {
await imService.persistConfig({ weixin: weixinOpenClawConfig });
const result = await runConnectivityTest(platform, {
weixin: weixinOpenClawConfig,
} as Partial<IMGatewayConfig>);
if (!weixinOpenClawConfig.enabled && result) {
const authCheck = result.checks.find((c) => c.code === 'auth_check');
if (authCheck && authCheck.level === 'pass') {
toggleGateway(platform);
}
}
return;
}
// For Feishu, persist feishu config and test (OpenClaw mode)
if (platform === 'feishu') {
await imService.persistConfig({ feishu: fsOpenClawConfig });
@@ -886,6 +997,7 @@ const IMSettings: React.FC = () => {
nim: setNimConfig,
xiaomifeng: setXiaomifengConfig,
wecom: setWecomConfig,
weixin: setWeixinConfig,
popo: setPopoConfig,
};
return actionMap[platform];
@@ -2869,6 +2981,299 @@ const IMSettings: React.FC = () => {
</div>
)}
{/* Weixin (微信) Settings */}
{activePlatform === 'weixin' && (
<div className="space-y-3">
{/* Scan QR code section */}
<div className="rounded-lg border border-dashed dark:border-claude-darkBorder/60 border-claude-border/60 p-4 text-center space-y-3">
{(weixinQrStatus === 'idle' || weixinQrStatus === 'error') && (
<>
<button
type="button"
onClick={() => void handleWeixinQrLogin()}
className="px-4 py-2.5 rounded-lg text-sm font-medium bg-claude-accent text-white hover:bg-claude-accent/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{i18nService.t('imWeixinScanBtn')}
</button>
<p className="text-xs text-claude-textSecondary dark:text-claude-darkTextSecondary">
{i18nService.t('imWeixinScanHint')}
</p>
{weixinQrStatus === 'error' && weixinQrError && (
<div className="flex items-center justify-center gap-1.5 text-xs text-red-500 bg-red-500/10 px-3 py-2 rounded-lg">
<XCircleIcon className="h-4 w-4 flex-shrink-0" />
{weixinQrError}
</div>
)}
</>
)}
{weixinQrStatus === 'loading' && (
<div className="flex items-center justify-center gap-2 py-4">
<ArrowPathIcon className="h-5 w-5 animate-spin text-claude-accent" />
<span className="text-sm text-claude-textSecondary dark:text-claude-darkTextSecondary">
{i18nService.t('imWeixinQrLoading')}
</span>
</div>
)}
{(weixinQrStatus === 'showing' || weixinQrStatus === 'waiting') && weixinQrUrl && (
<div className="space-y-3">
<p className="text-sm font-medium dark:text-claude-darkText text-claude-text">
{i18nService.t('imWeixinQrScanPrompt')}
</p>
<div className="flex justify-center">
<div className="p-3 bg-white rounded-lg border dark:border-claude-darkBorder/40 border-claude-border/40">
<QRCodeSVG value={weixinQrUrl} size={192} />
</div>
</div>
{weixinQrStatus === 'waiting' && (
<div className="flex items-center justify-center gap-2">
<ArrowPathIcon className="h-4 w-4 animate-spin text-claude-accent" />
<span className="text-xs text-claude-textSecondary dark:text-claude-darkTextSecondary">
{i18nService.t('imWeixinQrWaiting')}
</span>
</div>
)}
</div>
)}
{weixinQrStatus === 'success' && (
<div className="flex items-center justify-center gap-1.5 text-xs text-green-600 dark:text-green-400 bg-green-500/10 px-3 py-2 rounded-lg">
<CheckCircleIcon className="h-4 w-4 flex-shrink-0" />
{i18nService.t('imWeixinQrSuccess')}
</div>
)}
</div>
{/* Platform Guide */}
<PlatformGuide
steps={[
i18nService.t('imWeixinGuideStep1'),
i18nService.t('imWeixinGuideStep2'),
i18nService.t('imWeixinGuideStep3'),
]}
guideUrl={IM_GUIDE_URLS.weixin}
/>
{/* Advanced Settings (collapsible) */}
<details className="group">
<summary className="cursor-pointer text-xs font-medium dark:text-claude-darkTextSecondary text-claude-textSecondary hover:text-claude-accent transition-colors">
{i18nService.t('imAdvancedSettings')}
</summary>
<div className="mt-2 space-y-3 pl-2 border-l-2 border-claude-border/30 dark:border-claude-darkBorder/30">
{/* DM Policy */}
<div className="space-y-1.5">
<label className="block text-xs font-medium dark:text-claude-darkTextSecondary text-claude-textSecondary">
DM Policy
</label>
<select
value={weixinOpenClawConfig.dmPolicy}
onChange={(e) => {
const update = { dmPolicy: e.target.value as WeixinOpenClawConfig['dmPolicy'] };
handleWeixinOpenClawChange(update);
void handleSaveWeixinOpenClawConfig(update);
}}
className="block w-full rounded-lg dark:bg-claude-darkSurface/80 bg-claude-surface/80 dark:border-claude-darkBorder/60 border-claude-border/60 border focus:border-claude-accent focus:ring-1 focus:ring-claude-accent/30 dark:text-claude-darkText text-claude-text px-3 py-2 text-sm transition-colors"
>
<option value="open">{i18nService.t('imDmPolicyOpen')}</option>
<option value="pairing">{i18nService.t('imDmPolicyPairing')}</option>
<option value="allowlist">{i18nService.t('imDmPolicyAllowlist')}</option>
<option value="disabled">{i18nService.t('imDmPolicyDisabled')}</option>
</select>
</div>
{/* Pairing Requests (shown when dmPolicy is 'pairing') */}
{weixinOpenClawConfig.dmPolicy === 'pairing' && renderPairingSection('weixin')}
{/* Allow From */}
<div className="space-y-1.5">
<label className="block text-xs font-medium dark:text-claude-darkTextSecondary text-claude-textSecondary">
Allow From (User IDs)
</label>
<div className="flex gap-2">
<input
type="text"
value={weixinAllowedUserIdInput}
onChange={(e) => setWeixinAllowedUserIdInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const id = weixinAllowedUserIdInput.trim();
if (id && !weixinOpenClawConfig.allowFrom.includes(id)) {
const newIds = [...weixinOpenClawConfig.allowFrom, id];
handleWeixinOpenClawChange({ allowFrom: newIds });
setWeixinAllowedUserIdInput('');
void imService.persistConfig({ weixin: { ...weixinOpenClawConfig, allowFrom: newIds } });
}
}
}}
className="block flex-1 rounded-lg dark:bg-claude-darkSurface/80 bg-claude-surface/80 dark:border-claude-darkBorder/60 border-claude-border/60 border focus:border-claude-accent focus:ring-1 focus:ring-claude-accent/30 dark:text-claude-darkText text-claude-text px-3 py-2 text-sm transition-colors"
placeholder={i18nService.t('imUserIdPlaceholder') || 'Enter user ID'}
/>
<button
type="button"
onClick={() => {
const id = weixinAllowedUserIdInput.trim();
if (id && !weixinOpenClawConfig.allowFrom.includes(id)) {
const newIds = [...weixinOpenClawConfig.allowFrom, id];
handleWeixinOpenClawChange({ allowFrom: newIds });
setWeixinAllowedUserIdInput('');
void imService.persistConfig({ weixin: { ...weixinOpenClawConfig, allowFrom: newIds } });
}
}}
className="px-3 py-2 rounded-lg text-xs font-medium bg-claude-accent/10 text-claude-accent hover:bg-claude-accent/20 transition-colors"
>
{i18nService.t('add') || '添加'}
</button>
</div>
{weixinOpenClawConfig.allowFrom.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-1.5">
{weixinOpenClawConfig.allowFrom.map((id) => (
<span
key={id}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs dark:bg-claude-darkSurface/80 bg-claude-surface/80 dark:border-claude-darkBorder/60 border-claude-border/60 border dark:text-claude-darkText text-claude-text"
>
{id}
<button
type="button"
onClick={() => {
const newIds = weixinOpenClawConfig.allowFrom.filter((uid) => uid !== id);
handleWeixinOpenClawChange({ allowFrom: newIds });
void imService.persistConfig({ weixin: { ...weixinOpenClawConfig, allowFrom: newIds } });
}}
className="text-claude-textSecondary dark:text-claude-darkTextSecondary hover:text-red-500 dark:hover:text-red-400 transition-colors"
>
<XMarkIcon className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
</div>
{/* Group Policy */}
<div className="space-y-1.5">
<label className="block text-xs font-medium dark:text-claude-darkTextSecondary text-claude-textSecondary">
Group Policy
</label>
<select
value={weixinOpenClawConfig.groupPolicy}
onChange={(e) => {
const update = { groupPolicy: e.target.value as WeixinOpenClawConfig['groupPolicy'] };
handleWeixinOpenClawChange(update);
void handleSaveWeixinOpenClawConfig(update);
}}
className="block w-full rounded-lg dark:bg-claude-darkSurface/80 bg-claude-surface/80 dark:border-claude-darkBorder/60 border-claude-border/60 border focus:border-claude-accent focus:ring-1 focus:ring-claude-accent/30 dark:text-claude-darkText text-claude-text px-3 py-2 text-sm transition-colors"
>
<option value="open">Open</option>
<option value="allowlist">Allowlist</option>
<option value="disabled">Disabled</option>
</select>
</div>
{/* Group Allow From (shown when groupPolicy is 'allowlist') */}
{weixinOpenClawConfig.groupPolicy === 'allowlist' && (
<div className="space-y-1.5">
<label className="block text-xs font-medium dark:text-claude-darkTextSecondary text-claude-textSecondary">
Group Allow From (Group IDs)
</label>
<div className="flex gap-2">
<input
type="text"
value={weixinGroupAllowIdInput}
onChange={(e) => setWeixinGroupAllowIdInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const id = weixinGroupAllowIdInput.trim();
if (id && !weixinOpenClawConfig.groupAllowFrom.includes(id)) {
const newIds = [...weixinOpenClawConfig.groupAllowFrom, id];
handleWeixinOpenClawChange({ groupAllowFrom: newIds });
setWeixinGroupAllowIdInput('');
void imService.persistConfig({ weixin: { ...weixinOpenClawConfig, groupAllowFrom: newIds } });
}
}
}}
className="block flex-1 rounded-lg dark:bg-claude-darkSurface/80 bg-claude-surface/80 dark:border-claude-darkBorder/60 border-claude-border/60 border focus:border-claude-accent focus:ring-1 focus:ring-claude-accent/30 dark:text-claude-darkText text-claude-text px-3 py-2 text-sm transition-colors"
placeholder={i18nService.t('imGroupIdPlaceholder') || 'Enter group ID'}
/>
<button
type="button"
onClick={() => {
const id = weixinGroupAllowIdInput.trim();
if (id && !weixinOpenClawConfig.groupAllowFrom.includes(id)) {
const newIds = [...weixinOpenClawConfig.groupAllowFrom, id];
handleWeixinOpenClawChange({ groupAllowFrom: newIds });
setWeixinGroupAllowIdInput('');
void imService.persistConfig({ weixin: { ...weixinOpenClawConfig, groupAllowFrom: newIds } });
}
}}
className="px-3 py-2 rounded-lg text-xs font-medium bg-claude-accent/10 text-claude-accent hover:bg-claude-accent/20 transition-colors"
>
{i18nService.t('add') || '添加'}
</button>
</div>
{weixinOpenClawConfig.groupAllowFrom.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-1.5">
{weixinOpenClawConfig.groupAllowFrom.map((id) => (
<span
key={id}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs dark:bg-claude-darkSurface/80 bg-claude-surface/80 dark:border-claude-darkBorder/60 border-claude-border/60 border dark:text-claude-darkText text-claude-text"
>
{id}
<button
type="button"
onClick={() => {
const newIds = weixinOpenClawConfig.groupAllowFrom.filter((gid) => gid !== id);
handleWeixinOpenClawChange({ groupAllowFrom: newIds });
void imService.persistConfig({ weixin: { ...weixinOpenClawConfig, groupAllowFrom: newIds } });
}}
className="text-claude-textSecondary dark:text-claude-darkTextSecondary hover:text-red-500 dark:hover:text-red-400 transition-colors"
>
<XMarkIcon className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
</div>
)}
{/* Debug toggle */}
<div className="flex items-center justify-between">
<label className="text-xs font-medium dark:text-claude-darkTextSecondary text-claude-textSecondary">
Debug
</label>
<button
type="button"
onClick={() => {
const update = { debug: !weixinOpenClawConfig.debug };
handleWeixinOpenClawChange(update);
void handleSaveWeixinOpenClawConfig(update);
}}
className={`relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out ${
weixinOpenClawConfig.debug ? 'bg-claude-accent' : 'dark:bg-claude-darkSurface bg-claude-surface'
}`}
>
<span className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
weixinOpenClawConfig.debug ? 'translate-x-4' : 'translate-x-0'
}`} />
</button>
</div>
</div>
</details>
{/* Connectivity test */}
<div className="pt-1">
{renderConnectivityTestButton('weixin')}
</div>
{/* Error display */}
{status.weixin?.lastError && (
<div className="text-xs text-red-500 bg-red-500/10 px-3 py-2 rounded-lg">
{status.weixin.lastError}
</div>
)}
</div>
)}
{/* WeCom (企业微信) Settings */}
{activePlatform === 'wecom' && (
<div className="space-y-3">
+27 -2
View File
@@ -676,6 +676,7 @@ const translations: Record<LanguageType, Record<string, string>> = {
discord: 'Discord',
nim: '云信',
xiaomifeng: '小蜜蜂',
weixin: '微信',
wecom: '企业微信',
popo: 'POPO',
connected: '已连接',
@@ -832,6 +833,17 @@ const translations: Record<LanguageType, Record<string, string>> = {
imWecomGuideStep1: '在企业微信管理后台创建一个新的机器人应用',
imWecomGuideStep2: '获取对应的 BotID 与 Secret 凭证',
imWecomGuideStep3: '将 BotID 和 Secret 填入下方表单',
imWeixinGuideStep1: '点击下方按钮启用微信接入',
imWeixinGuideStep2: '使用微信扫描二维码完成连接',
imWeixinGuideStep3: '连接成功后,即可通过微信发送消息与 AI 对话',
imWeixinScanBtn: '扫码连接微信',
imWeixinScanHint: '使用微信扫描二维码,连接微信 IM 机器人',
imWeixinQrLoading: '正在获取二维码...',
imWeixinQrScanPrompt: '请使用微信扫描下方二维码',
imWeixinQrWaiting: '等待扫码确认...',
imWeixinQrSuccess: '微信连接成功!',
imWeixinQrFailed: '连接失败',
imWeixinQrExpired: '二维码已过期,请重新获取',
imPopoCredentialHint: '从 POPO 开发者后台获取 AppKey、AppSecret 和 AES Key',
imPopoGuideStep1: '在 POPO 开发者后台创建一个新的机器人应用',
imPopoGuideStep2: '获取 AppKey、AppSecret 凭证,并配置 AES Key',
@@ -1024,6 +1036,7 @@ const translations: Record<LanguageType, Record<string, string>> = {
scheduledTasksFormNotifyNim: '云信',
scheduledTasksFormNotifyXiaomifeng: '小蜜蜂',
scheduledTasksFormNotifyWecom: '企业微信',
scheduledTasksFormNotifyWeixin: '微信',
scheduledTasksFormNotifyPopo: 'POPO',
scheduledTasksFormNotifyNone: '无',
scheduledTasksFormNotifyNotConfigured: '未配置',
@@ -1721,8 +1734,8 @@ const translations: Record<LanguageType, Record<string, string>> = {
discord: 'Discord',
nim: 'NetEase IM',
xiaomifeng: 'Netease Bee',
wecom: 'WeCom',
popo: 'POPO',
weixin: 'WeChat',
wecom: 'WeCom', popo: 'POPO',
connected: 'Connected',
disconnected: 'Disconnected',
kickedByOtherClient: 'Account logged in elsewhere',
@@ -1877,6 +1890,17 @@ const translations: Record<LanguageType, Record<string, string>> = {
imWecomGuideStep1: 'Create a new bot application in the WeCom Admin Console',
imWecomGuideStep2: 'Copy the BotID and Secret credentials',
imWecomGuideStep3: 'Enter BotID and Secret in the form below',
imWeixinGuideStep1: 'Click the button below to enable WeChat integration',
imWeixinGuideStep2: 'Scan the QR code with WeChat to connect',
imWeixinGuideStep3: 'Once connected, you can chat with AI via WeChat messages',
imWeixinScanBtn: 'Scan to Connect WeChat',
imWeixinScanHint: 'Scan the QR code with WeChat to connect IM bot',
imWeixinQrLoading: 'Getting QR code...',
imWeixinQrScanPrompt: 'Scan the QR code below with WeChat',
imWeixinQrWaiting: 'Waiting for scan confirmation...',
imWeixinQrSuccess: 'WeChat connected successfully!',
imWeixinQrFailed: 'Connection failed',
imWeixinQrExpired: 'QR code expired, please try again',
imPopoCredentialHint: 'Get AppKey, AppSecret and AES Key from the POPO Developer Portal',
imPopoGuideStep1: 'Create a new bot application in the POPO Developer Portal',
imPopoGuideStep2: 'Get AppKey, AppSecret credentials and configure AES Key',
@@ -2069,6 +2093,7 @@ const translations: Record<LanguageType, Record<string, string>> = {
scheduledTasksFormNotifyNim: 'NetEase IM',
scheduledTasksFormNotifyXiaomifeng: 'Xiaomifeng',
scheduledTasksFormNotifyWecom: 'WeCom',
scheduledTasksFormNotifyWeixin: 'WeChat',
scheduledTasksFormNotifyPopo: 'POPO',
scheduledTasksFormNotifyNone: 'None',
scheduledTasksFormNotifyNotConfigured: 'Not configured',
+5
View File
@@ -16,6 +16,7 @@ import type {
XiaomifengConfig,
WecomOpenClawConfig,
PopoOpenClawConfig,
WeixinOpenClawConfig,
IMSettings,
} from '../../types/im';
import {
@@ -74,6 +75,9 @@ const imSlice = createSlice({
setPopoConfig: (state, action: PayloadAction<Partial<PopoOpenClawConfig>>) => {
state.config.popo = { ...state.config.popo, ...action.payload };
},
setWeixinConfig: (state, action: PayloadAction<Partial<WeixinOpenClawConfig>>) => {
state.config.weixin = { ...state.config.weixin, ...action.payload };
},
setIMSettings: (state, action: PayloadAction<Partial<IMSettings>>) => {
state.config.settings = { ...state.config.settings, ...action.payload };
},
@@ -103,6 +107,7 @@ export const {
setXiaomifengConfig,
setWecomConfig,
setPopoConfig,
setWeixinConfig,
setIMSettings,
setStatus,
setLoading,
+25 -4
View File
@@ -382,15 +382,17 @@ interface IElectronAPI {
getConfig: () => Promise<{ success: boolean; config?: IMGatewayConfig; error?: string }>;
setConfig: (config: Partial<IMGatewayConfig>, options?: { syncGateway?: boolean }) => Promise<{ success: boolean; error?: string }>;
syncConfig: () => Promise<{ success: boolean; error?: string }>;
startGateway: (platform: 'dingtalk' | 'feishu' | 'qq' | 'telegram' | 'discord' | 'nim' | 'xiaomifeng' | 'wecom' | 'popo') => Promise<{ success: boolean; error?: string }>;
stopGateway: (platform: 'dingtalk' | 'feishu' | 'qq' | 'telegram' | 'discord' | 'nim' | 'xiaomifeng' | 'wecom' | 'popo') => Promise<{ success: boolean; error?: string }>;
startGateway: (platform: 'dingtalk' | 'feishu' | 'qq' | 'telegram' | 'discord' | 'nim' | 'xiaomifeng' | 'wecom' | 'popo' | 'weixin') => Promise<{ success: boolean; error?: string }>;
stopGateway: (platform: 'dingtalk' | 'feishu' | 'qq' | 'telegram' | 'discord' | 'nim' | 'xiaomifeng' | 'wecom' | 'popo' | 'weixin') => Promise<{ success: boolean; error?: string }>;
testGateway: (
platform: 'dingtalk' | 'feishu' | 'qq' | 'telegram' | 'discord' | 'nim' | 'xiaomifeng' | 'wecom' | 'popo',
platform: 'dingtalk' | 'feishu' | 'qq' | 'telegram' | 'discord' | 'nim' | 'xiaomifeng' | 'wecom' | 'popo' | 'weixin',
configOverride?: Partial<IMGatewayConfig>
) => Promise<{ success: boolean; result?: IMConnectivityTestResult; error?: string }>;
getStatus: () => Promise<{ success: boolean; status?: IMGatewayStatus; error?: string }>;
getLocalIp: () => Promise<string>;
getOpenClawConfigSchema: () => Promise<{ success: boolean; result?: { schema: Record<string, unknown>; uiHints: Record<string, Record<string, unknown>> }; error?: string }>;
weixinQrLoginStart: () => Promise<{ success: boolean; qrDataUrl?: string; message: string; sessionKey?: string }>;
weixinQrLoginWait: (accountId?: string) => Promise<{ success: boolean; connected: boolean; message: string; accountId?: string }>;
listPairingRequests: (platform: string) => Promise<{
success: boolean;
requests: Array<{ id: string; code: string; createdAt: string; lastSeenAt: string; meta?: Record<string, string> }>;
@@ -469,6 +471,7 @@ interface IMGatewayConfig {
xiaomifeng: XiaomifengConfig;
wecom: WecomConfig;
popo: PopoOpenClawConfig;
weixin: WeixinOpenClawConfig;
settings: IMSettings;
}
@@ -639,6 +642,15 @@ interface PopoOpenClawConfig {
debug: boolean;
}
interface WeixinOpenClawConfig {
enabled: boolean;
dmPolicy: 'open' | 'pairing' | 'allowlist' | 'disabled';
allowFrom: string[];
groupPolicy: 'open' | 'allowlist' | 'disabled';
groupAllowFrom: string[];
debug: boolean;
}
interface IMSettings {
systemPrompt?: string;
skillsEnabled: boolean;
@@ -654,6 +666,7 @@ interface IMGatewayStatus {
xiaomifeng: XiaomifengGatewayStatus;
wecom: WecomGatewayStatus;
popo: PopoGatewayStatus;
weixin: WeixinGatewayStatus;
}
type IMConnectivityVerdict = 'pass' | 'warn' | 'fail';
@@ -768,8 +781,16 @@ interface PopoGatewayStatus {
lastOutboundAt: number | null;
}
interface WeixinGatewayStatus {
connected: boolean;
startedAt: number | null;
lastError: string | null;
lastInboundAt: number | null;
lastOutboundAt: number | null;
}
interface IMMessage {
platform: 'dingtalk' | 'feishu' | 'qq' | 'telegram' | 'discord' | 'nim' | 'xiaomifeng' | 'wecom' | 'popo';
platform: 'dingtalk' | 'feishu' | 'qq' | 'telegram' | 'discord' | 'nim' | 'xiaomifeng' | 'wecom' | 'popo' | 'weixin';
messageId: string;
conversationId: string;
senderId: string;
+39 -1
View File
@@ -278,9 +278,28 @@ export interface PopoGatewayStatus {
lastOutboundAt: number | null;
}
// ==================== Weixin (微信) Types ====================
export interface WeixinOpenClawConfig {
enabled: boolean;
dmPolicy: 'open' | 'pairing' | 'allowlist' | 'disabled';
allowFrom: string[];
groupPolicy: 'open' | 'allowlist' | 'disabled';
groupAllowFrom: string[];
debug: boolean;
}
export interface WeixinGatewayStatus {
connected: boolean;
startedAt: number | null;
lastError: string | null;
lastInboundAt: number | null;
lastOutboundAt: number | null;
}
// ==================== Common IM Types ====================
export type IMPlatform = 'dingtalk' | 'feishu' | 'qq' | 'telegram' | 'discord' | 'nim' | 'xiaomifeng' | 'wecom' | 'popo';
export type IMPlatform = 'dingtalk' | 'feishu' | 'qq' | 'telegram' | 'discord' | 'nim' | 'xiaomifeng' | 'wecom' | 'popo' | 'weixin';
export interface IMGatewayConfig {
dingtalk: DingTalkOpenClawConfig;
@@ -292,6 +311,7 @@ export interface IMGatewayConfig {
xiaomifeng: XiaomifengConfig;
wecom: WecomOpenClawConfig;
popo: PopoOpenClawConfig;
weixin: WeixinOpenClawConfig;
settings: IMSettings;
}
@@ -310,6 +330,7 @@ export interface IMGatewayStatus {
xiaomifeng: XiaomifengGatewayStatus;
wecom: WecomGatewayStatus;
popo: PopoGatewayStatus;
weixin: WeixinGatewayStatus;
}
// ==================== Media Attachment Types ====================
@@ -544,6 +565,15 @@ export const DEFAULT_POPO_CONFIG: PopoOpenClawConfig = {
debug: true,
};
export const DEFAULT_WEIXIN_CONFIG: WeixinOpenClawConfig = {
enabled: false,
dmPolicy: 'open',
allowFrom: [],
groupPolicy: 'open',
groupAllowFrom: [],
debug: true,
};
export const DEFAULT_IM_SETTINGS: IMSettings = {
systemPrompt: '',
skillsEnabled: true,
@@ -559,6 +589,7 @@ export const DEFAULT_IM_CONFIG: IMGatewayConfig = {
xiaomifeng: DEFAULT_XIAOMIFENG_CONFIG,
wecom: DEFAULT_WECOM_CONFIG,
popo: DEFAULT_POPO_CONFIG,
weixin: DEFAULT_WEIXIN_CONFIG,
settings: DEFAULT_IM_SETTINGS,
};
@@ -633,4 +664,11 @@ export const DEFAULT_IM_STATUS: IMGatewayStatus = {
lastInboundAt: null,
lastOutboundAt: null,
},
weixin: {
connected: false,
startedAt: null,
lastError: null,
lastInboundAt: null,
lastOutboundAt: null,
},
};
+1 -1
View File
@@ -1,5 +1,5 @@
// IM 平台分类
export const CHINA_IM_PLATFORMS = ['dingtalk', 'feishu', 'wecom', 'qq', 'nim', 'xiaomifeng', 'popo'] as const;
export const CHINA_IM_PLATFORMS = ['dingtalk', 'feishu', 'weixin', 'wecom', 'qq', 'nim', 'xiaomifeng', 'popo'] as const;
export const GLOBAL_IM_PLATFORMS = ['telegram', 'discord'] as const;
/**