feat(channel): support weichat channel (#620)
This commit is contained in:
@@ -104,6 +104,7 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています
|
||||
### 📡 マルチチャネル管理
|
||||
複数のAIチャネルを同時に設定・監視できます。各チャネルは独立して動作するため、異なるタスクに特化したエージェントを実行できます。
|
||||
現在は各チャンネルで複数アカウントを扱え、Channels ページでアカウントの Agent 紐付けやデフォルトアカウント切替を直接管理できます。
|
||||
ClawX には Tencent 公式の個人 WeChat チャンネルプラグインも同梱されており、Channels ページからアプリ内 QR フローで直接 WeChat を連携できます。
|
||||
|
||||
### ⏰ Cronベースの自動化
|
||||
AIタスクを自動的に実行するようスケジュール設定できます。トリガーを定義し、間隔を設定することで、手動介入なしにAIエージェントを24時間稼働させることができます。
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -105,6 +105,7 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
|
||||
### 📡 多频道管理
|
||||
同时配置和监控多个 AI 频道。每个频道独立运行,允许你为不同任务运行专门的智能体。
|
||||
现在每个频道支持多个账号,并可在 Channels 页面直接完成账号绑定到 Agent 与默认账号切换。
|
||||
ClawX 现在还内置了腾讯官方个人微信渠道插件,可直接在 Channels 页面通过内置二维码流程完成微信连接。
|
||||
|
||||
### ⏰ 定时任务自动化
|
||||
调度 AI 任务自动执行。定义触发器、设置时间间隔,让 AI 智能体 7×24 小时不间断工作。
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from 'http';
|
||||
import {
|
||||
deleteChannelAccountConfig,
|
||||
deleteChannelConfig,
|
||||
cleanupDanglingWeChatPluginState,
|
||||
getChannelFormValues,
|
||||
listConfiguredChannelAccounts,
|
||||
listConfiguredChannels,
|
||||
@@ -22,17 +23,144 @@ import {
|
||||
ensureDingTalkPluginInstalled,
|
||||
ensureFeishuPluginInstalled,
|
||||
ensureQQBotPluginInstalled,
|
||||
ensureWeChatPluginInstalled,
|
||||
ensureWeComPluginInstalled,
|
||||
} from '../../utils/plugin-install';
|
||||
import {
|
||||
computeChannelRuntimeStatus,
|
||||
pickChannelRuntimeStatus,
|
||||
type ChannelRuntimeAccountSnapshot,
|
||||
} from '../../../src/lib/channel-status';
|
||||
} from '../../utils/channel-status';
|
||||
import {
|
||||
OPENCLAW_WECHAT_CHANNEL_TYPE,
|
||||
UI_WECHAT_CHANNEL_TYPE,
|
||||
buildQrChannelEventName,
|
||||
toOpenClawChannelType,
|
||||
toUiChannelType,
|
||||
} from '../../utils/channel-alias';
|
||||
import {
|
||||
cancelWeChatLoginSession,
|
||||
saveWeChatAccountState,
|
||||
startWeChatLoginSession,
|
||||
waitForWeChatLoginSession,
|
||||
} from '../../utils/wechat-login';
|
||||
import { whatsAppLoginManager } from '../../utils/whatsapp-login';
|
||||
import type { HostApiContext } from '../context';
|
||||
import { parseJsonBody, sendJson } from '../route-utils';
|
||||
|
||||
const WECHAT_QR_TIMEOUT_MS = 8 * 60 * 1000;
|
||||
const activeQrLogins = new Map<string, string>();
|
||||
|
||||
interface WebLoginStartResult {
|
||||
qrcodeUrl?: string;
|
||||
message?: string;
|
||||
sessionKey?: string;
|
||||
}
|
||||
|
||||
function resolveStoredChannelType(channelType: string): string {
|
||||
return toOpenClawChannelType(channelType);
|
||||
}
|
||||
|
||||
function buildQrLoginKey(channelType: string, accountId?: string): string {
|
||||
return `${toUiChannelType(channelType)}:${accountId?.trim() || '__new__'}`;
|
||||
}
|
||||
|
||||
function setActiveQrLogin(channelType: string, sessionKey: string, accountId?: string): string {
|
||||
const loginKey = buildQrLoginKey(channelType, accountId);
|
||||
activeQrLogins.set(loginKey, sessionKey);
|
||||
return loginKey;
|
||||
}
|
||||
|
||||
function isActiveQrLogin(loginKey: string, sessionKey: string): boolean {
|
||||
return activeQrLogins.get(loginKey) === sessionKey;
|
||||
}
|
||||
|
||||
function clearActiveQrLogin(channelType: string, accountId?: string): void {
|
||||
activeQrLogins.delete(buildQrLoginKey(channelType, accountId));
|
||||
}
|
||||
|
||||
function emitChannelEvent(
|
||||
ctx: HostApiContext,
|
||||
channelType: string,
|
||||
event: 'qr' | 'success' | 'error',
|
||||
payload: unknown,
|
||||
): void {
|
||||
const eventName = buildQrChannelEventName(channelType, event);
|
||||
ctx.eventBus.emit(eventName, payload);
|
||||
if (ctx.mainWindow && !ctx.mainWindow.isDestroyed()) {
|
||||
ctx.mainWindow.webContents.send(eventName, payload);
|
||||
}
|
||||
}
|
||||
|
||||
async function startWeChatQrLogin(ctx: HostApiContext, accountId?: string): Promise<WebLoginStartResult> {
|
||||
void ctx;
|
||||
return await startWeChatLoginSession({
|
||||
...(accountId ? { accountId } : {}),
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function awaitWeChatQrLogin(
|
||||
ctx: HostApiContext,
|
||||
sessionKey: string,
|
||||
loginKey: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const result = await waitForWeChatLoginSession({
|
||||
sessionKey,
|
||||
timeoutMs: WECHAT_QR_TIMEOUT_MS,
|
||||
onQrRefresh: async ({ qrcodeUrl }) => {
|
||||
if (!isActiveQrLogin(loginKey, sessionKey)) {
|
||||
return;
|
||||
}
|
||||
emitChannelEvent(ctx, UI_WECHAT_CHANNEL_TYPE, 'qr', {
|
||||
qr: qrcodeUrl,
|
||||
raw: qrcodeUrl,
|
||||
sessionKey,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (!isActiveQrLogin(loginKey, sessionKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.connected || !result.accountId || !result.botToken) {
|
||||
emitChannelEvent(ctx, UI_WECHAT_CHANNEL_TYPE, 'error', result.message || 'WeChat login did not complete');
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedAccountId = await saveWeChatAccountState(result.accountId, {
|
||||
token: result.botToken,
|
||||
baseUrl: result.baseUrl,
|
||||
userId: result.userId,
|
||||
});
|
||||
await saveChannelConfig(UI_WECHAT_CHANNEL_TYPE, { enabled: true }, normalizedAccountId);
|
||||
await ensureScopedChannelBinding(UI_WECHAT_CHANNEL_TYPE, normalizedAccountId);
|
||||
scheduleGatewayChannelSaveRefresh(ctx, OPENCLAW_WECHAT_CHANNEL_TYPE, `wechat:loginSuccess:${normalizedAccountId}`);
|
||||
|
||||
if (!isActiveQrLogin(loginKey, sessionKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
emitChannelEvent(ctx, UI_WECHAT_CHANNEL_TYPE, 'success', {
|
||||
accountId: normalizedAccountId,
|
||||
rawAccountId: result.accountId,
|
||||
message: result.message,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isActiveQrLogin(loginKey, sessionKey)) {
|
||||
return;
|
||||
}
|
||||
emitChannelEvent(ctx, UI_WECHAT_CHANNEL_TYPE, 'error', String(error));
|
||||
} finally {
|
||||
if (isActiveQrLogin(loginKey, sessionKey)) {
|
||||
activeQrLogins.delete(loginKey);
|
||||
}
|
||||
await cancelWeChatLoginSession(sessionKey);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleGatewayChannelRestart(ctx: HostApiContext, reason: string): void {
|
||||
if (ctx.gatewayManager.getStatus().state === 'stopped') {
|
||||
return;
|
||||
@@ -44,17 +172,18 @@ function scheduleGatewayChannelRestart(ctx: HostApiContext, reason: string): voi
|
||||
// Plugin-based channels require a full Gateway process restart to properly
|
||||
// initialize / tear-down plugin connections. SIGUSR1 in-process reload is
|
||||
// not sufficient for channel plugins (see restartGatewayForAgentDeletion).
|
||||
const FORCE_RESTART_CHANNELS = new Set(['dingtalk', 'wecom', 'whatsapp', 'feishu', 'qqbot']);
|
||||
const FORCE_RESTART_CHANNELS = new Set(['dingtalk', 'wecom', 'whatsapp', 'feishu', 'qqbot', OPENCLAW_WECHAT_CHANNEL_TYPE]);
|
||||
|
||||
function scheduleGatewayChannelSaveRefresh(
|
||||
ctx: HostApiContext,
|
||||
channelType: string,
|
||||
reason: string,
|
||||
): void {
|
||||
const storedChannelType = resolveStoredChannelType(channelType);
|
||||
if (ctx.gatewayManager.getStatus().state === 'stopped') {
|
||||
return;
|
||||
}
|
||||
if (FORCE_RESTART_CHANNELS.has(channelType)) {
|
||||
if (FORCE_RESTART_CHANNELS.has(storedChannelType)) {
|
||||
ctx.gatewayManager.debouncedRestart();
|
||||
void reason;
|
||||
return;
|
||||
@@ -95,23 +224,24 @@ function isSameConfigValues(
|
||||
}
|
||||
|
||||
async function ensureScopedChannelBinding(channelType: string, accountId?: string): Promise<void> {
|
||||
const storedChannelType = resolveStoredChannelType(channelType);
|
||||
// Multi-agent safety: only bind when the caller explicitly scopes the account.
|
||||
// Global channel saves (no accountId) must not override routing to "main".
|
||||
if (!accountId) return;
|
||||
const agents = await listAgentsSnapshot();
|
||||
if (!agents.entries || agents.entries.length === 0) return;
|
||||
if (!agents.agents || agents.agents.length === 0) return;
|
||||
|
||||
// Keep backward compatibility for the legacy default account.
|
||||
if (accountId === 'default') {
|
||||
if (agents.entries.some((entry) => entry.id === 'main')) {
|
||||
await assignChannelAccountToAgent('main', channelType, 'default');
|
||||
if (agents.agents.some((entry) => entry.id === 'main')) {
|
||||
await assignChannelAccountToAgent('main', storedChannelType, 'default');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy compatibility: if accountId matches an existing agentId, keep auto-binding.
|
||||
if (agents.entries.some((entry) => entry.id === accountId)) {
|
||||
await assignChannelAccountToAgent(accountId, channelType, accountId);
|
||||
if (agents.agents.some((entry) => entry.id === accountId)) {
|
||||
await assignChannelAccountToAgent(accountId, storedChannelType, accountId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,20 +309,26 @@ async function buildChannelAccountsView(ctx: HostApiContext): Promise<ChannelAcc
|
||||
]);
|
||||
|
||||
const channels: ChannelAccountsView[] = [];
|
||||
for (const channelType of channelTypes) {
|
||||
const channelAccountsFromConfig = configuredAccounts[channelType]?.accountIds ?? [];
|
||||
const hasLocalConfig = configuredChannels.includes(channelType) || Boolean(configuredAccounts[channelType]);
|
||||
const channelSection = openClawConfig.channels?.[channelType];
|
||||
for (const rawChannelType of channelTypes) {
|
||||
const uiChannelType = toUiChannelType(rawChannelType);
|
||||
const channelAccountsFromConfig = configuredAccounts[rawChannelType]?.accountIds ?? [];
|
||||
const hasLocalConfig = configuredChannels.includes(rawChannelType) || Boolean(configuredAccounts[rawChannelType]);
|
||||
const channelSection = openClawConfig.channels?.[rawChannelType];
|
||||
const channelSummary =
|
||||
(gatewayStatus?.channels?.[channelType] as { error?: string; lastError?: string } | undefined) ?? undefined;
|
||||
(gatewayStatus?.channels?.[rawChannelType] as { error?: string; lastError?: string } | undefined) ?? undefined;
|
||||
const sortedConfigAccountIds = [...channelAccountsFromConfig].sort((left, right) => {
|
||||
if (left === 'default') return -1;
|
||||
if (right === 'default') return 1;
|
||||
return left.localeCompare(right);
|
||||
});
|
||||
const fallbackDefault =
|
||||
typeof channelSection?.defaultAccount === 'string' && channelSection.defaultAccount.trim()
|
||||
? channelSection.defaultAccount
|
||||
: 'default';
|
||||
const defaultAccountId = configuredAccounts[channelType]?.defaultAccountId
|
||||
?? gatewayStatus?.channelDefaultAccountId?.[channelType]
|
||||
: (sortedConfigAccountIds[0] || 'default');
|
||||
const defaultAccountId = configuredAccounts[rawChannelType]?.defaultAccountId
|
||||
?? gatewayStatus?.channelDefaultAccountId?.[rawChannelType]
|
||||
?? fallbackDefault;
|
||||
const runtimeAccounts = gatewayStatus?.channelAccounts?.[channelType] ?? [];
|
||||
const runtimeAccounts = gatewayStatus?.channelAccounts?.[rawChannelType] ?? [];
|
||||
const hasRuntimeConfigured = runtimeAccounts.some((account) => account.configured === true);
|
||||
if (!hasLocalConfig && !hasRuntimeConfigured) {
|
||||
continue;
|
||||
@@ -216,7 +352,7 @@ async function buildChannelAccountsView(ctx: HostApiContext): Promise<ChannelAcc
|
||||
lastError: typeof runtime?.lastError === 'string' ? runtime.lastError : undefined,
|
||||
status,
|
||||
isDefault: accountId === defaultAccountId,
|
||||
agentId: agentsSnapshot.channelAccountOwners[`${channelType}:${accountId}`],
|
||||
agentId: agentsSnapshot.channelAccountOwners[`${rawChannelType}:${accountId}`],
|
||||
};
|
||||
}).sort((left, right) => {
|
||||
if (left.accountId === defaultAccountId) return -1;
|
||||
@@ -225,7 +361,7 @@ async function buildChannelAccountsView(ctx: HostApiContext): Promise<ChannelAcc
|
||||
});
|
||||
|
||||
channels.push({
|
||||
channelType,
|
||||
channelType: uiChannelType,
|
||||
defaultAccountId,
|
||||
status: pickChannelRuntimeStatus(runtimeAccounts, channelSummary),
|
||||
accounts,
|
||||
@@ -242,7 +378,8 @@ export async function handleChannelRoutes(
|
||||
ctx: HostApiContext,
|
||||
): Promise<boolean> {
|
||||
if (url.pathname === '/api/channels/configured' && req.method === 'GET') {
|
||||
sendJson(res, 200, { success: true, channels: await listConfiguredChannels() });
|
||||
const channels = await listConfiguredChannels();
|
||||
sendJson(res, 200, { success: true, channels: Array.from(new Set(channels.map((channel) => toUiChannelType(channel)))) });
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -271,7 +408,7 @@ export async function handleChannelRoutes(
|
||||
if (url.pathname === '/api/channels/binding' && req.method === 'PUT') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ channelType: string; accountId: string; agentId: string }>(req);
|
||||
await assignChannelAccountToAgent(body.agentId, body.channelType, body.accountId);
|
||||
await assignChannelAccountToAgent(body.agentId, resolveStoredChannelType(body.channelType), body.accountId);
|
||||
scheduleGatewayChannelSaveRefresh(ctx, body.channelType, `channel:setBinding:${body.channelType}`);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
@@ -283,7 +420,7 @@ export async function handleChannelRoutes(
|
||||
if (url.pathname === '/api/channels/binding' && req.method === 'DELETE') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ channelType: string; accountId: string }>(req);
|
||||
await clearChannelBinding(body.channelType, body.accountId);
|
||||
await clearChannelBinding(resolveStoredChannelType(body.channelType), body.accountId);
|
||||
scheduleGatewayChannelSaveRefresh(ctx, body.channelType, `channel:clearBinding:${body.channelType}`);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
@@ -333,37 +470,93 @@ export async function handleChannelRoutes(
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/channels/wechat/start' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ accountId?: string }>(req);
|
||||
const requestedAccountId = body.accountId?.trim() || undefined;
|
||||
|
||||
const installResult = await ensureWeChatPluginInstalled();
|
||||
if (!installResult.installed) {
|
||||
sendJson(res, 500, { success: false, error: installResult.warning || 'WeChat plugin install failed' });
|
||||
return true;
|
||||
}
|
||||
|
||||
await cleanupDanglingWeChatPluginState();
|
||||
const startResult = await startWeChatQrLogin(ctx, requestedAccountId);
|
||||
if (!startResult.qrcodeUrl || !startResult.sessionKey) {
|
||||
throw new Error(startResult.message || 'Failed to generate WeChat QR code');
|
||||
}
|
||||
|
||||
const loginKey = setActiveQrLogin(UI_WECHAT_CHANNEL_TYPE, startResult.sessionKey, requestedAccountId);
|
||||
emitChannelEvent(ctx, UI_WECHAT_CHANNEL_TYPE, 'qr', {
|
||||
qr: startResult.qrcodeUrl,
|
||||
raw: startResult.qrcodeUrl,
|
||||
sessionKey: startResult.sessionKey,
|
||||
});
|
||||
void awaitWeChatQrLogin(ctx, startResult.sessionKey, loginKey);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/channels/wechat/cancel' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ accountId?: string }>(req);
|
||||
const accountId = body.accountId?.trim() || undefined;
|
||||
const loginKey = buildQrLoginKey(UI_WECHAT_CHANNEL_TYPE, accountId);
|
||||
const sessionKey = activeQrLogins.get(loginKey);
|
||||
clearActiveQrLogin(UI_WECHAT_CHANNEL_TYPE, accountId);
|
||||
if (sessionKey) {
|
||||
await cancelWeChatLoginSession(sessionKey);
|
||||
}
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/channels/config' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await parseJsonBody<{ channelType: string; config: Record<string, unknown>; accountId?: string }>(req);
|
||||
if (body.channelType === 'dingtalk') {
|
||||
const storedChannelType = resolveStoredChannelType(body.channelType);
|
||||
if (storedChannelType === 'dingtalk') {
|
||||
const installResult = await ensureDingTalkPluginInstalled();
|
||||
if (!installResult.installed) {
|
||||
sendJson(res, 500, { success: false, error: installResult.warning || 'DingTalk plugin install failed' });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (body.channelType === 'wecom') {
|
||||
if (storedChannelType === 'wecom') {
|
||||
const installResult = await ensureWeComPluginInstalled();
|
||||
if (!installResult.installed) {
|
||||
sendJson(res, 500, { success: false, error: installResult.warning || 'WeCom plugin install failed' });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (body.channelType === 'qqbot') {
|
||||
if (storedChannelType === 'qqbot') {
|
||||
const installResult = await ensureQQBotPluginInstalled();
|
||||
if (!installResult.installed) {
|
||||
sendJson(res, 500, { success: false, error: installResult.warning || 'QQ Bot plugin install failed' });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (body.channelType === 'feishu') {
|
||||
if (storedChannelType === 'feishu') {
|
||||
const installResult = await ensureFeishuPluginInstalled();
|
||||
if (!installResult.installed) {
|
||||
sendJson(res, 500, { success: false, error: installResult.warning || 'Feishu plugin install failed' });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (storedChannelType === OPENCLAW_WECHAT_CHANNEL_TYPE) {
|
||||
const installResult = await ensureWeChatPluginInstalled();
|
||||
if (!installResult.installed) {
|
||||
sendJson(res, 500, { success: false, error: installResult.warning || 'WeChat plugin install failed' });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const existingValues = await getChannelFormValues(body.channelType, body.accountId);
|
||||
if (isSameConfigValues(existingValues, body.config)) {
|
||||
await ensureScopedChannelBinding(body.channelType, body.accountId);
|
||||
@@ -372,7 +565,7 @@ export async function handleChannelRoutes(
|
||||
}
|
||||
await saveChannelConfig(body.channelType, body.config, body.accountId);
|
||||
await ensureScopedChannelBinding(body.channelType, body.accountId);
|
||||
scheduleGatewayChannelSaveRefresh(ctx, body.channelType, `channel:saveConfig:${body.channelType}`);
|
||||
scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:saveConfig:${storedChannelType}`);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
@@ -384,7 +577,7 @@ export async function handleChannelRoutes(
|
||||
try {
|
||||
const body = await parseJsonBody<{ channelType: string; enabled: boolean }>(req);
|
||||
await setChannelEnabled(body.channelType, body.enabled);
|
||||
scheduleGatewayChannelRestart(ctx, `channel:setEnabled:${body.channelType}`);
|
||||
scheduleGatewayChannelRestart(ctx, `channel:setEnabled:${resolveStoredChannelType(body.channelType)}`);
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
@@ -410,14 +603,15 @@ export async function handleChannelRoutes(
|
||||
try {
|
||||
const channelType = decodeURIComponent(url.pathname.slice('/api/channels/config/'.length));
|
||||
const accountId = url.searchParams.get('accountId') || undefined;
|
||||
const storedChannelType = resolveStoredChannelType(channelType);
|
||||
if (accountId) {
|
||||
await deleteChannelAccountConfig(channelType, accountId);
|
||||
await clearChannelBinding(channelType, accountId);
|
||||
scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:deleteAccount:${channelType}`);
|
||||
await clearChannelBinding(storedChannelType, accountId);
|
||||
scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:deleteAccount:${storedChannelType}`);
|
||||
} else {
|
||||
await deleteChannelConfig(channelType);
|
||||
await clearAllBindingsForChannel(channelType);
|
||||
scheduleGatewayChannelRestart(ctx, `channel:deleteConfig:${channelType}`);
|
||||
await clearAllBindingsForChannel(storedChannelType);
|
||||
scheduleGatewayChannelRestart(ctx, `channel:deleteConfig:${storedChannelType}`);
|
||||
}
|
||||
sendJson(res, 200, { success: true });
|
||||
} catch (error) {
|
||||
|
||||
@@ -20,7 +20,7 @@ import { getApiKey, getDefaultProvider, getProvider } from '../utils/secure-stor
|
||||
import { getProviderEnvVar, getKeyableProviderTypes } from '../utils/provider-registry';
|
||||
import { getOpenClawDir, getOpenClawEntryPath, isOpenClawPresent } from '../utils/paths';
|
||||
import { getUvMirrorEnv } from '../utils/uv-env';
|
||||
import { listConfiguredChannels } from '../utils/channel-config';
|
||||
import { cleanupDanglingWeChatPluginState, listConfiguredChannels } from '../utils/channel-config';
|
||||
import { syncGatewayTokenToConfig, syncBrowserConfigToOpenClaw, syncSessionIdleMinutesToOpenClaw, sanitizeOpenClawConfig } from '../utils/openclaw-auth';
|
||||
import { buildProxyEnv, resolveProxySettings } from '../utils/proxy';
|
||||
import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy';
|
||||
@@ -48,6 +48,7 @@ const CHANNEL_PLUGIN_MAP: Record<string, { dirName: string; npmName: string }> =
|
||||
wecom: { dirName: 'wecom', npmName: '@wecom/wecom-openclaw-plugin' },
|
||||
feishu: { dirName: 'feishu-openclaw-plugin', npmName: '@larksuite/openclaw-lark' },
|
||||
qqbot: { dirName: 'qqbot', npmName: '@sliverp/qqbot' },
|
||||
'openclaw-weixin': { dirName: 'openclaw-weixin', npmName: '@tencent-weixin/openclaw-weixin' },
|
||||
};
|
||||
|
||||
function readPluginVersion(pkgJsonPath: string): string | null {
|
||||
@@ -144,6 +145,12 @@ export async function syncGatewayConfigBeforeLaunch(
|
||||
logger.warn('Failed to sanitize openclaw.json:', err);
|
||||
}
|
||||
|
||||
try {
|
||||
await cleanupDanglingWeChatPluginState();
|
||||
} catch (err) {
|
||||
logger.warn('Failed to clean dangling WeChat plugin state before launch:', err);
|
||||
}
|
||||
|
||||
// Auto-upgrade installed plugins before Gateway starts so that
|
||||
// the plugin manifest ID matches what sanitize wrote to the config.
|
||||
try {
|
||||
|
||||
@@ -332,7 +332,7 @@ async function initialize(): Promise<void> {
|
||||
logger.warn('Failed to install preinstalled skills:', error);
|
||||
});
|
||||
|
||||
// Pre-deploy/upgrade bundled OpenClaw plugins (dingtalk, wecom, qqbot, feishu)
|
||||
// Pre-deploy/upgrade bundled OpenClaw plugins (dingtalk, wecom, qqbot, feishu, wechat)
|
||||
// to ~/.openclaw/extensions/ so they are always up-to-date after an app update.
|
||||
void ensureAllBundledPluginsInstalled().catch((error) => {
|
||||
logger.warn('Failed to install/upgrade bundled plugins:', error);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -5,6 +5,7 @@ import { deleteAgentChannelAccounts, listConfiguredChannels, readOpenClawConfig,
|
||||
import { withConfigLock } from './config-mutex';
|
||||
import { expandPath, getOpenClawConfigDir } from './paths';
|
||||
import * as logger from './logger';
|
||||
import { toUiChannelType } from './channel-alias';
|
||||
|
||||
const MAIN_AGENT_ID = 'main';
|
||||
const MAIN_AGENT_NAME = 'Main Agent';
|
||||
@@ -493,14 +494,16 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument): Promise<Age
|
||||
workspace: entry.workspace || (entry.id === MAIN_AGENT_ID ? getDefaultWorkspacePath(config) : `~/.openclaw/workspace-${entry.id}`),
|
||||
agentDir: entry.agentDir || getDefaultAgentDirPath(entry.id),
|
||||
mainSessionKey: buildAgentMainSessionKey(config, entry.id),
|
||||
channelTypes: configuredChannels.filter((ct) => ownedChannels.has(ct)),
|
||||
channelTypes: configuredChannels
|
||||
.filter((ct) => ownedChannels.has(ct))
|
||||
.map((channelType) => toUiChannelType(channelType)),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
agents,
|
||||
defaultAgentId,
|
||||
configuredChannelTypes: configuredChannels,
|
||||
configuredChannelTypes: configuredChannels.map((channelType) => toUiChannelType(channelType)),
|
||||
channelOwners,
|
||||
channelAccountOwners,
|
||||
};
|
||||
|
||||
46
electron/utils/channel-alias.ts
Normal file
46
electron/utils/channel-alias.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
const BLOCKED_OBJECT_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
|
||||
const VALID_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
|
||||
const INVALID_CHARS_RE = /[^a-z0-9_-]+/g;
|
||||
const LEADING_DASH_RE = /^-+/;
|
||||
const TRAILING_DASH_RE = /-+$/;
|
||||
|
||||
export const UI_WECHAT_CHANNEL_TYPE = 'wechat';
|
||||
export const OPENCLAW_WECHAT_CHANNEL_TYPE = 'openclaw-weixin';
|
||||
|
||||
export type QrChannelEvent = 'qr' | 'success' | 'error';
|
||||
|
||||
export function toOpenClawChannelType(channelType: string): string {
|
||||
return channelType === UI_WECHAT_CHANNEL_TYPE ? OPENCLAW_WECHAT_CHANNEL_TYPE : channelType;
|
||||
}
|
||||
|
||||
export function toUiChannelType(channelType: string): string {
|
||||
return channelType === OPENCLAW_WECHAT_CHANNEL_TYPE ? UI_WECHAT_CHANNEL_TYPE : channelType;
|
||||
}
|
||||
|
||||
export function isWechatChannelType(channelType: string | null | undefined): boolean {
|
||||
return channelType === UI_WECHAT_CHANNEL_TYPE || channelType === OPENCLAW_WECHAT_CHANNEL_TYPE;
|
||||
}
|
||||
|
||||
export function buildQrChannelEventName(channelType: string, event: QrChannelEvent): string {
|
||||
return `channel:${toUiChannelType(channelType)}-${event}`;
|
||||
}
|
||||
|
||||
function canonicalizeAccountId(value: string): string {
|
||||
if (VALID_ID_RE.test(value)) return value.toLowerCase();
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(INVALID_CHARS_RE, '-')
|
||||
.replace(LEADING_DASH_RE, '')
|
||||
.replace(TRAILING_DASH_RE, '')
|
||||
.slice(0, 64);
|
||||
}
|
||||
|
||||
export function normalizeOpenClawAccountId(value: string | null | undefined, fallback = 'default'): string {
|
||||
const trimmed = (value ?? '').trim();
|
||||
if (!trimmed) return fallback;
|
||||
const normalized = canonicalizeAccountId(trimmed);
|
||||
if (!normalized || BLOCKED_OBJECT_KEYS.has(normalized)) {
|
||||
return fallback;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
@@ -12,13 +12,25 @@ import { getOpenClawResolvedDir } from './paths';
|
||||
import * as logger from './logger';
|
||||
import { proxyAwareFetch } from './proxy-fetch';
|
||||
import { withConfigLock } from './config-mutex';
|
||||
import {
|
||||
OPENCLAW_WECHAT_CHANNEL_TYPE,
|
||||
isWechatChannelType,
|
||||
normalizeOpenClawAccountId,
|
||||
toOpenClawChannelType,
|
||||
} from './channel-alias';
|
||||
|
||||
const OPENCLAW_DIR = join(homedir(), '.openclaw');
|
||||
const CONFIG_FILE = join(OPENCLAW_DIR, 'openclaw.json');
|
||||
const WECOM_PLUGIN_ID = 'wecom';
|
||||
const WECHAT_PLUGIN_ID = OPENCLAW_WECHAT_CHANNEL_TYPE;
|
||||
const FEISHU_PLUGIN_ID_CANDIDATES = ['openclaw-lark', 'feishu-openclaw-plugin'] as const;
|
||||
const DEFAULT_ACCOUNT_ID = 'default';
|
||||
const CHANNEL_TOP_LEVEL_KEYS_TO_KEEP = new Set(['accounts', 'defaultAccount', 'enabled']);
|
||||
const WECHAT_STATE_DIR = join(OPENCLAW_DIR, WECHAT_PLUGIN_ID);
|
||||
const WECHAT_ACCOUNT_INDEX_FILE = join(WECHAT_STATE_DIR, 'accounts.json');
|
||||
const WECHAT_ACCOUNTS_DIR = join(WECHAT_STATE_DIR, 'accounts');
|
||||
const LEGACY_WECHAT_CREDENTIALS_DIR = join(OPENCLAW_DIR, 'credentials', WECHAT_PLUGIN_ID);
|
||||
const LEGACY_WECHAT_SYNC_DIR = join(OPENCLAW_DIR, 'agents', 'default', 'sessions', '.openclaw-weixin-sync');
|
||||
|
||||
// Channels that are managed as plugins (config goes under plugins.entries, not channels)
|
||||
const PLUGIN_CHANNELS = ['whatsapp'];
|
||||
@@ -70,6 +82,117 @@ async function resolveFeishuPluginId(): Promise<string> {
|
||||
return FEISHU_PLUGIN_ID_CANDIDATES[0];
|
||||
}
|
||||
|
||||
function resolveStoredChannelType(channelType: string): string {
|
||||
return toOpenClawChannelType(channelType);
|
||||
}
|
||||
|
||||
function deriveLegacyWeChatRawAccountId(normalizedId: string): string | undefined {
|
||||
if (normalizedId.endsWith('-im-bot')) {
|
||||
return `${normalizedId.slice(0, -7)}@im.bot`;
|
||||
}
|
||||
if (normalizedId.endsWith('-im-wechat')) {
|
||||
return `${normalizedId.slice(0, -10)}@im.wechat`;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function readWeChatAccountIndex(): Promise<string[]> {
|
||||
try {
|
||||
const raw = await readFile(WECHAT_ACCOUNT_INDEX_FILE, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function writeWeChatAccountIndex(accountIds: string[]): Promise<void> {
|
||||
await mkdir(WECHAT_STATE_DIR, { recursive: true });
|
||||
await writeFile(WECHAT_ACCOUNT_INDEX_FILE, JSON.stringify(accountIds, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
async function deleteWeChatAccountState(accountId: string): Promise<void> {
|
||||
const normalizedAccountId = normalizeOpenClawAccountId(accountId);
|
||||
const legacyRawAccountId = deriveLegacyWeChatRawAccountId(normalizedAccountId);
|
||||
const candidateIds = new Set<string>([normalizedAccountId]);
|
||||
if (legacyRawAccountId) {
|
||||
candidateIds.add(legacyRawAccountId);
|
||||
}
|
||||
if (accountId.trim()) {
|
||||
candidateIds.add(accountId.trim());
|
||||
}
|
||||
|
||||
for (const candidateId of candidateIds) {
|
||||
await rm(join(WECHAT_ACCOUNTS_DIR, `${candidateId}.json`), { force: true });
|
||||
}
|
||||
|
||||
const existingAccountIds = await readWeChatAccountIndex();
|
||||
const nextAccountIds = existingAccountIds.filter((entry) => !candidateIds.has(entry));
|
||||
if (nextAccountIds.length !== existingAccountIds.length) {
|
||||
if (nextAccountIds.length === 0) {
|
||||
await rm(WECHAT_ACCOUNT_INDEX_FILE, { force: true });
|
||||
} else {
|
||||
await writeWeChatAccountIndex(nextAccountIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteWeChatState(): Promise<void> {
|
||||
await rm(WECHAT_STATE_DIR, { recursive: true, force: true });
|
||||
await rm(LEGACY_WECHAT_CREDENTIALS_DIR, { recursive: true, force: true });
|
||||
await rm(LEGACY_WECHAT_SYNC_DIR, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function removePluginRegistration(currentConfig: OpenClawConfig, pluginId: string): boolean {
|
||||
if (!currentConfig.plugins) return false;
|
||||
let modified = false;
|
||||
|
||||
if (Array.isArray(currentConfig.plugins.allow)) {
|
||||
const nextAllow = currentConfig.plugins.allow.filter((entry) => entry !== pluginId);
|
||||
if (nextAllow.length !== currentConfig.plugins.allow.length) {
|
||||
currentConfig.plugins.allow = nextAllow;
|
||||
modified = true;
|
||||
}
|
||||
if (nextAllow.length === 0) {
|
||||
delete currentConfig.plugins.allow;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentConfig.plugins.entries && currentConfig.plugins.entries[pluginId]) {
|
||||
delete currentConfig.plugins.entries[pluginId];
|
||||
modified = true;
|
||||
if (Object.keys(currentConfig.plugins.entries).length === 0) {
|
||||
delete currentConfig.plugins.entries;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
currentConfig.plugins.enabled !== undefined
|
||||
&& !currentConfig.plugins.allow?.length
|
||||
&& !currentConfig.plugins.entries
|
||||
) {
|
||||
delete currentConfig.plugins.enabled;
|
||||
modified = true;
|
||||
}
|
||||
|
||||
if (Object.keys(currentConfig.plugins).length === 0) {
|
||||
delete currentConfig.plugins;
|
||||
modified = true;
|
||||
}
|
||||
|
||||
return modified;
|
||||
}
|
||||
|
||||
function channelHasConfiguredAccounts(channelSection: ChannelConfigData | undefined): boolean {
|
||||
if (!channelSection || typeof channelSection !== 'object') return false;
|
||||
const accounts = channelSection.accounts as Record<string, ChannelConfigData> | undefined;
|
||||
if (accounts && typeof accounts === 'object') {
|
||||
return Object.keys(accounts).length > 0;
|
||||
}
|
||||
return Object.keys(channelSection).some((key) => !CHANNEL_TOP_LEVEL_KEYS_TO_KEEP.has(key));
|
||||
}
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────
|
||||
|
||||
export interface ChannelConfigData {
|
||||
@@ -239,6 +362,35 @@ async function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType:
|
||||
currentConfig.plugins.allow = [...allow, 'qqbot'];
|
||||
}
|
||||
}
|
||||
|
||||
if (channelType === WECHAT_PLUGIN_ID) {
|
||||
if (!currentConfig.plugins) {
|
||||
currentConfig.plugins = {
|
||||
allow: [WECHAT_PLUGIN_ID],
|
||||
enabled: true,
|
||||
entries: {
|
||||
[WECHAT_PLUGIN_ID]: { enabled: true },
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
currentConfig.plugins.enabled = true;
|
||||
const allow = Array.isArray(currentConfig.plugins.allow)
|
||||
? currentConfig.plugins.allow as string[]
|
||||
: [];
|
||||
if (!allow.includes(WECHAT_PLUGIN_ID)) {
|
||||
currentConfig.plugins.allow = [...allow, WECHAT_PLUGIN_ID];
|
||||
}
|
||||
|
||||
if (!currentConfig.plugins.entries) {
|
||||
currentConfig.plugins.entries = {};
|
||||
}
|
||||
if (!currentConfig.plugins.entries[WECHAT_PLUGIN_ID]) {
|
||||
currentConfig.plugins.entries[WECHAT_PLUGIN_ID] = {};
|
||||
}
|
||||
currentConfig.plugins.entries[WECHAT_PLUGIN_ID].enabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
function transformChannelConfig(
|
||||
@@ -426,55 +578,56 @@ export async function saveChannelConfig(
|
||||
accountId?: string,
|
||||
): Promise<void> {
|
||||
return withConfigLock(async () => {
|
||||
const resolvedChannelType = resolveStoredChannelType(channelType);
|
||||
const currentConfig = await readOpenClawConfig();
|
||||
const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID;
|
||||
|
||||
await ensurePluginAllowlist(currentConfig, channelType);
|
||||
await ensurePluginAllowlist(currentConfig, resolvedChannelType);
|
||||
|
||||
// Plugin-based channels (e.g. WhatsApp) go under plugins.entries, not channels
|
||||
if (PLUGIN_CHANNELS.includes(channelType)) {
|
||||
if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
|
||||
if (!currentConfig.plugins) {
|
||||
currentConfig.plugins = {};
|
||||
}
|
||||
if (!currentConfig.plugins.entries) {
|
||||
currentConfig.plugins.entries = {};
|
||||
}
|
||||
currentConfig.plugins.entries[channelType] = {
|
||||
...currentConfig.plugins.entries[channelType],
|
||||
currentConfig.plugins.entries[resolvedChannelType] = {
|
||||
...currentConfig.plugins.entries[resolvedChannelType],
|
||||
enabled: config.enabled ?? true,
|
||||
};
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
logger.info('Plugin channel config saved', {
|
||||
channelType,
|
||||
channelType: resolvedChannelType,
|
||||
configFile: CONFIG_FILE,
|
||||
path: `plugins.entries.${channelType}`,
|
||||
path: `plugins.entries.${resolvedChannelType}`,
|
||||
});
|
||||
console.log(`Saved plugin channel config for ${channelType}`);
|
||||
console.log(`Saved plugin channel config for ${resolvedChannelType}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentConfig.channels) {
|
||||
currentConfig.channels = {};
|
||||
}
|
||||
if (!currentConfig.channels[channelType]) {
|
||||
currentConfig.channels[channelType] = {};
|
||||
if (!currentConfig.channels[resolvedChannelType]) {
|
||||
currentConfig.channels[resolvedChannelType] = {};
|
||||
}
|
||||
|
||||
const channelSection = currentConfig.channels[channelType];
|
||||
const channelSection = currentConfig.channels[resolvedChannelType];
|
||||
migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID);
|
||||
|
||||
// Guard: reject if this bot/app credential is already used by another account.
|
||||
assertNoDuplicateCredential(channelType, config, channelSection, resolvedAccountId);
|
||||
assertNoDuplicateCredential(resolvedChannelType, config, channelSection, resolvedAccountId);
|
||||
|
||||
const existingAccountConfig = resolveAccountConfig(channelSection, resolvedAccountId);
|
||||
const transformedConfig = transformChannelConfig(channelType, config, existingAccountConfig);
|
||||
const uniqueKey = CHANNEL_UNIQUE_CREDENTIAL_KEY[channelType];
|
||||
const transformedConfig = transformChannelConfig(resolvedChannelType, config, existingAccountConfig);
|
||||
const uniqueKey = CHANNEL_UNIQUE_CREDENTIAL_KEY[resolvedChannelType];
|
||||
if (uniqueKey && typeof transformedConfig[uniqueKey] === 'string') {
|
||||
const rawCredentialValue = transformedConfig[uniqueKey] as string;
|
||||
const normalizedCredentialValue = normalizeCredentialValue(rawCredentialValue);
|
||||
if (normalizedCredentialValue !== rawCredentialValue) {
|
||||
logger.warn('Normalizing channel credential value before save', {
|
||||
channelType,
|
||||
channelType: resolvedChannelType,
|
||||
accountId: resolvedAccountId,
|
||||
key: uniqueKey,
|
||||
});
|
||||
@@ -490,7 +643,7 @@ export async function saveChannelConfig(
|
||||
channelSection.defaultAccount =
|
||||
typeof channelSection.defaultAccount === 'string' && channelSection.defaultAccount.trim()
|
||||
? channelSection.defaultAccount
|
||||
: DEFAULT_ACCOUNT_ID;
|
||||
: resolvedAccountId;
|
||||
accounts[resolvedAccountId] = {
|
||||
...accounts[resolvedAccountId],
|
||||
...transformedConfig,
|
||||
@@ -506,7 +659,11 @@ export async function saveChannelConfig(
|
||||
// credential keys on every invocation. Without this, saving a non-default
|
||||
// account (e.g. a sub-agent's Feishu bot) leaves the top-level credentials
|
||||
// missing, breaking plugins that only read from the top level.
|
||||
const defaultAccountData = accounts[DEFAULT_ACCOUNT_ID];
|
||||
const mirroredAccountId =
|
||||
typeof channelSection.defaultAccount === 'string' && channelSection.defaultAccount.trim()
|
||||
? channelSection.defaultAccount
|
||||
: resolvedAccountId;
|
||||
const defaultAccountData = accounts[mirroredAccountId] ?? accounts[resolvedAccountId] ?? accounts[DEFAULT_ACCOUNT_ID];
|
||||
if (defaultAccountData) {
|
||||
for (const [key, value] of Object.entries(defaultAccountData)) {
|
||||
channelSection[key] = value;
|
||||
@@ -515,19 +672,20 @@ export async function saveChannelConfig(
|
||||
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
logger.info('Channel config saved', {
|
||||
channelType,
|
||||
channelType: resolvedChannelType,
|
||||
accountId: resolvedAccountId,
|
||||
configFile: CONFIG_FILE,
|
||||
rawKeys: Object.keys(config),
|
||||
transformedKeys: Object.keys(transformedConfig),
|
||||
});
|
||||
console.log(`Saved channel config for ${channelType} account ${resolvedAccountId}`);
|
||||
console.log(`Saved channel config for ${resolvedChannelType} account ${resolvedAccountId}`);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getChannelConfig(channelType: string, accountId?: string): Promise<ChannelConfigData | undefined> {
|
||||
const resolvedChannelType = resolveStoredChannelType(channelType);
|
||||
const config = await readOpenClawConfig();
|
||||
const channelSection = config.channels?.[channelType];
|
||||
const channelSection = config.channels?.[resolvedChannelType];
|
||||
if (!channelSection) return undefined;
|
||||
|
||||
const resolvedAccountId = accountId || DEFAULT_ACCOUNT_ID;
|
||||
@@ -596,9 +754,17 @@ export async function getChannelFormValues(channelType: string, accountId?: stri
|
||||
|
||||
export async function deleteChannelAccountConfig(channelType: string, accountId: string): Promise<void> {
|
||||
return withConfigLock(async () => {
|
||||
const resolvedChannelType = resolveStoredChannelType(channelType);
|
||||
const currentConfig = await readOpenClawConfig();
|
||||
const channelSection = currentConfig.channels?.[channelType];
|
||||
if (!channelSection) return;
|
||||
const channelSection = currentConfig.channels?.[resolvedChannelType];
|
||||
if (!channelSection) {
|
||||
if (isWechatChannelType(resolvedChannelType)) {
|
||||
removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID);
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
await deleteWeChatAccountState(accountId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID);
|
||||
const accounts = channelSection.accounts as Record<string, ChannelConfigData> | undefined;
|
||||
@@ -607,7 +773,10 @@ export async function deleteChannelAccountConfig(channelType: string, accountId:
|
||||
delete accounts[accountId];
|
||||
|
||||
if (Object.keys(accounts).length === 0) {
|
||||
delete currentConfig.channels![channelType];
|
||||
delete currentConfig.channels![resolvedChannelType];
|
||||
if (isWechatChannelType(resolvedChannelType)) {
|
||||
removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID);
|
||||
}
|
||||
} else {
|
||||
if (channelSection.defaultAccount === accountId) {
|
||||
const nextDefaultAccountId = Object.keys(accounts).sort((a, b) => {
|
||||
@@ -634,22 +803,32 @@ export async function deleteChannelAccountConfig(channelType: string, accountId:
|
||||
}
|
||||
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
logger.info('Deleted channel account config', { channelType, accountId });
|
||||
console.log(`Deleted channel account config for ${channelType}/${accountId}`);
|
||||
if (isWechatChannelType(resolvedChannelType)) {
|
||||
await deleteWeChatAccountState(accountId);
|
||||
}
|
||||
logger.info('Deleted channel account config', { channelType: resolvedChannelType, accountId });
|
||||
console.log(`Deleted channel account config for ${resolvedChannelType}/${accountId}`);
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteChannelConfig(channelType: string): Promise<void> {
|
||||
return withConfigLock(async () => {
|
||||
const resolvedChannelType = resolveStoredChannelType(channelType);
|
||||
const currentConfig = await readOpenClawConfig();
|
||||
|
||||
if (currentConfig.channels?.[channelType]) {
|
||||
delete currentConfig.channels[channelType];
|
||||
if (currentConfig.channels?.[resolvedChannelType]) {
|
||||
delete currentConfig.channels[resolvedChannelType];
|
||||
if (isWechatChannelType(resolvedChannelType)) {
|
||||
removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID);
|
||||
}
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
console.log(`Deleted channel config for ${channelType}`);
|
||||
} else if (PLUGIN_CHANNELS.includes(channelType)) {
|
||||
if (currentConfig.plugins?.entries?.[channelType]) {
|
||||
delete currentConfig.plugins.entries[channelType];
|
||||
if (isWechatChannelType(resolvedChannelType)) {
|
||||
await deleteWeChatState();
|
||||
}
|
||||
console.log(`Deleted channel config for ${resolvedChannelType}`);
|
||||
} else if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
|
||||
if (currentConfig.plugins?.entries?.[resolvedChannelType]) {
|
||||
delete currentConfig.plugins.entries[resolvedChannelType];
|
||||
if (Object.keys(currentConfig.plugins.entries).length === 0) {
|
||||
delete currentConfig.plugins.entries;
|
||||
}
|
||||
@@ -657,11 +836,15 @@ export async function deleteChannelConfig(channelType: string): Promise<void> {
|
||||
delete currentConfig.plugins;
|
||||
}
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
console.log(`Deleted plugin channel config for ${channelType}`);
|
||||
console.log(`Deleted plugin channel config for ${resolvedChannelType}`);
|
||||
}
|
||||
} else if (isWechatChannelType(resolvedChannelType)) {
|
||||
removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID);
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
await deleteWeChatState();
|
||||
}
|
||||
|
||||
if (channelType === 'whatsapp') {
|
||||
if (resolvedChannelType === 'whatsapp') {
|
||||
try {
|
||||
const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp');
|
||||
if (await fileExists(whatsappDir)) {
|
||||
@@ -742,9 +925,16 @@ export async function listConfiguredChannelAccounts(): Promise<Record<string, Co
|
||||
? Object.keys(section.accounts).filter(Boolean)
|
||||
: [];
|
||||
|
||||
const defaultAccountId = typeof section.defaultAccount === 'string' && section.defaultAccount.trim()
|
||||
let defaultAccountId = typeof section.defaultAccount === 'string' && section.defaultAccount.trim()
|
||||
? section.defaultAccount
|
||||
: DEFAULT_ACCOUNT_ID;
|
||||
if (accountIds.length > 0 && !accountIds.includes(defaultAccountId)) {
|
||||
defaultAccountId = accountIds.sort((a, b) => {
|
||||
if (a === DEFAULT_ACCOUNT_ID) return -1;
|
||||
if (b === DEFAULT_ACCOUNT_ID) return 1;
|
||||
return a.localeCompare(b);
|
||||
})[0];
|
||||
}
|
||||
|
||||
if (accountIds.length === 0) {
|
||||
const hasAnyPayload = Object.keys(section).some((key) => !CHANNEL_TOP_LEVEL_KEYS_TO_KEEP.has(key));
|
||||
@@ -771,21 +961,22 @@ export async function listConfiguredChannelAccounts(): Promise<Record<string, Co
|
||||
|
||||
export async function setChannelDefaultAccount(channelType: string, accountId: string): Promise<void> {
|
||||
return withConfigLock(async () => {
|
||||
const resolvedChannelType = resolveStoredChannelType(channelType);
|
||||
const trimmedAccountId = accountId.trim();
|
||||
if (!trimmedAccountId) {
|
||||
throw new Error('accountId is required');
|
||||
}
|
||||
|
||||
const currentConfig = await readOpenClawConfig();
|
||||
const channelSection = currentConfig.channels?.[channelType];
|
||||
const channelSection = currentConfig.channels?.[resolvedChannelType];
|
||||
if (!channelSection) {
|
||||
throw new Error(`Channel "${channelType}" is not configured`);
|
||||
throw new Error(`Channel "${resolvedChannelType}" is not configured`);
|
||||
}
|
||||
|
||||
migrateLegacyChannelConfigToAccounts(channelSection, DEFAULT_ACCOUNT_ID);
|
||||
const accounts = channelSection.accounts as Record<string, ChannelConfigData> | undefined;
|
||||
if (!accounts || !accounts[trimmedAccountId]) {
|
||||
throw new Error(`Account "${trimmedAccountId}" is not configured for channel "${channelType}"`);
|
||||
throw new Error(`Account "${trimmedAccountId}" is not configured for channel "${resolvedChannelType}"`);
|
||||
}
|
||||
|
||||
channelSection.defaultAccount = trimmedAccountId;
|
||||
@@ -796,7 +987,7 @@ export async function setChannelDefaultAccount(channelType: string, accountId: s
|
||||
}
|
||||
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
logger.info('Set channel default account', { channelType, accountId: trimmedAccountId });
|
||||
logger.info('Set channel default account', { channelType: resolvedChannelType, accountId: trimmedAccountId });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -856,23 +1047,55 @@ export async function deleteAgentChannelAccounts(agentId: string, ownedChannelAc
|
||||
|
||||
export async function setChannelEnabled(channelType: string, enabled: boolean): Promise<void> {
|
||||
return withConfigLock(async () => {
|
||||
const resolvedChannelType = resolveStoredChannelType(channelType);
|
||||
const currentConfig = await readOpenClawConfig();
|
||||
|
||||
if (PLUGIN_CHANNELS.includes(channelType)) {
|
||||
if (isWechatChannelType(resolvedChannelType)) {
|
||||
if (enabled) {
|
||||
await ensurePluginAllowlist(currentConfig, WECHAT_PLUGIN_ID);
|
||||
} else {
|
||||
removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID);
|
||||
}
|
||||
}
|
||||
|
||||
if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
|
||||
if (!currentConfig.plugins) currentConfig.plugins = {};
|
||||
if (!currentConfig.plugins.entries) currentConfig.plugins.entries = {};
|
||||
if (!currentConfig.plugins.entries[channelType]) currentConfig.plugins.entries[channelType] = {};
|
||||
currentConfig.plugins.entries[channelType].enabled = enabled;
|
||||
if (!currentConfig.plugins.entries[resolvedChannelType]) currentConfig.plugins.entries[resolvedChannelType] = {};
|
||||
currentConfig.plugins.entries[resolvedChannelType].enabled = enabled;
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
console.log(`Set plugin channel ${channelType} enabled: ${enabled}`);
|
||||
console.log(`Set plugin channel ${resolvedChannelType} enabled: ${enabled}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentConfig.channels) currentConfig.channels = {};
|
||||
if (!currentConfig.channels[channelType]) currentConfig.channels[channelType] = {};
|
||||
currentConfig.channels[channelType].enabled = enabled;
|
||||
if (!currentConfig.channels[resolvedChannelType]) currentConfig.channels[resolvedChannelType] = {};
|
||||
currentConfig.channels[resolvedChannelType].enabled = enabled;
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
console.log(`Set channel ${channelType} enabled: ${enabled}`);
|
||||
console.log(`Set channel ${resolvedChannelType} enabled: ${enabled}`);
|
||||
});
|
||||
}
|
||||
|
||||
export async function cleanupDanglingWeChatPluginState(): Promise<{ cleanedDanglingState: boolean }> {
|
||||
return withConfigLock(async () => {
|
||||
const currentConfig = await readOpenClawConfig();
|
||||
const channelSection = currentConfig.channels?.[WECHAT_PLUGIN_ID];
|
||||
const hasConfiguredWeChatAccounts = channelHasConfiguredAccounts(channelSection);
|
||||
const hadPluginRegistration = Boolean(
|
||||
currentConfig.plugins?.entries?.[WECHAT_PLUGIN_ID]
|
||||
|| currentConfig.plugins?.allow?.includes(WECHAT_PLUGIN_ID),
|
||||
);
|
||||
|
||||
if (hasConfiguredWeChatAccounts) {
|
||||
return { cleanedDanglingState: false };
|
||||
}
|
||||
|
||||
const modified = removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID);
|
||||
if (modified) {
|
||||
await writeOpenClawConfig(currentConfig);
|
||||
}
|
||||
await deleteWeChatState();
|
||||
return { cleanedDanglingState: hadPluginRegistration || modified };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -954,7 +1177,7 @@ export async function validateChannelCredentials(
|
||||
channelType: string,
|
||||
config: Record<string, string>
|
||||
): Promise<CredentialValidationResult> {
|
||||
switch (channelType) {
|
||||
switch (resolveStoredChannelType(channelType)) {
|
||||
case 'discord':
|
||||
return validateDiscordCredentials(config);
|
||||
case 'telegram':
|
||||
@@ -1072,6 +1295,7 @@ async function validateTelegramCredentials(
|
||||
|
||||
export async function validateChannelConfig(channelType: string): Promise<ValidationResult> {
|
||||
const { exec } = await import('child_process');
|
||||
const resolvedChannelType = resolveStoredChannelType(channelType);
|
||||
|
||||
const result: ValidationResult = { valid: true, errors: [], warnings: [] };
|
||||
|
||||
@@ -1104,7 +1328,7 @@ export async function validateChannelConfig(channelType: string): Promise<Valida
|
||||
|
||||
const output = await runDoctor(`node openclaw.mjs doctor 2>&1`);
|
||||
|
||||
const parsedDoctor = parseDoctorValidationOutput(channelType, output);
|
||||
const parsedDoctor = parseDoctorValidationOutput(resolvedChannelType, output);
|
||||
result.errors.push(...parsedDoctor.errors);
|
||||
result.warnings.push(...parsedDoctor.warnings);
|
||||
if (parsedDoctor.errors.length > 0) {
|
||||
@@ -1112,27 +1336,27 @@ export async function validateChannelConfig(channelType: string): Promise<Valida
|
||||
}
|
||||
if (parsedDoctor.undetermined) {
|
||||
logger.warn('Doctor output parsing fell back to local channel checks', {
|
||||
channelType,
|
||||
channelType: resolvedChannelType,
|
||||
hint: DOCTOR_PARSER_FALLBACK_HINT,
|
||||
});
|
||||
}
|
||||
|
||||
const config = await readOpenClawConfig();
|
||||
const savedChannelConfig = await getChannelConfig(channelType, DEFAULT_ACCOUNT_ID);
|
||||
if (!config.channels?.[channelType] || !savedChannelConfig) {
|
||||
result.errors.push(`Channel ${channelType} is not configured`);
|
||||
const savedChannelConfig = await getChannelConfig(resolvedChannelType, DEFAULT_ACCOUNT_ID);
|
||||
if (!config.channels?.[resolvedChannelType] || !savedChannelConfig) {
|
||||
result.errors.push(`Channel ${resolvedChannelType} is not configured`);
|
||||
result.valid = false;
|
||||
} else if (config.channels[channelType].enabled === false) {
|
||||
result.warnings.push(`Channel ${channelType} is disabled`);
|
||||
} else if (config.channels[resolvedChannelType].enabled === false) {
|
||||
result.warnings.push(`Channel ${resolvedChannelType} is disabled`);
|
||||
}
|
||||
|
||||
if (channelType === 'discord') {
|
||||
if (resolvedChannelType === 'discord') {
|
||||
const discordConfig = savedChannelConfig;
|
||||
if (!discordConfig?.token) {
|
||||
result.errors.push('Discord: Bot token is required');
|
||||
result.valid = false;
|
||||
}
|
||||
} else if (channelType === 'telegram') {
|
||||
} else if (resolvedChannelType === 'telegram') {
|
||||
const telegramConfig = savedChannelConfig;
|
||||
if (!telegramConfig?.botToken) {
|
||||
result.errors.push('Telegram: Bot token is required');
|
||||
@@ -1161,10 +1385,10 @@ export async function validateChannelConfig(channelType: string): Promise<Valida
|
||||
} else {
|
||||
console.warn('Doctor command failed:', errorMessage);
|
||||
const config = await readOpenClawConfig();
|
||||
if (config.channels?.[channelType]) {
|
||||
if (config.channels?.[resolvedChannelType]) {
|
||||
result.valid = true;
|
||||
} else {
|
||||
result.errors.push(`Channel ${channelType} is not configured`);
|
||||
result.errors.push(`Channel ${resolvedChannelType} is not configured`);
|
||||
result.valid = false;
|
||||
}
|
||||
}
|
||||
|
||||
101
electron/utils/channel-status.ts
Normal file
101
electron/utils/channel-status.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
export type ChannelConnectionStatus = 'connected' | 'connecting' | 'disconnected' | 'error';
|
||||
|
||||
export interface ChannelRuntimeAccountSnapshot {
|
||||
connected?: boolean;
|
||||
linked?: boolean;
|
||||
running?: boolean;
|
||||
lastError?: string | null;
|
||||
lastConnectedAt?: number | null;
|
||||
lastInboundAt?: number | null;
|
||||
lastOutboundAt?: number | null;
|
||||
lastProbeAt?: number | null;
|
||||
probe?: {
|
||||
ok?: boolean | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface ChannelRuntimeSummarySnapshot {
|
||||
error?: string | null;
|
||||
lastError?: string | null;
|
||||
}
|
||||
|
||||
const RECENT_ACTIVITY_MS = 10 * 60 * 1000;
|
||||
|
||||
function hasNonEmptyError(value: string | null | undefined): boolean {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
|
||||
export function hasRecentChannelActivity(
|
||||
account: Pick<ChannelRuntimeAccountSnapshot, 'lastConnectedAt' | 'lastInboundAt' | 'lastOutboundAt'>,
|
||||
now = Date.now(),
|
||||
recentMs = RECENT_ACTIVITY_MS,
|
||||
): boolean {
|
||||
return (
|
||||
(typeof account.lastInboundAt === 'number' && now - account.lastInboundAt < recentMs) ||
|
||||
(typeof account.lastOutboundAt === 'number' && now - account.lastOutboundAt < recentMs) ||
|
||||
(typeof account.lastConnectedAt === 'number' && now - account.lastConnectedAt < recentMs)
|
||||
);
|
||||
}
|
||||
|
||||
export function hasSuccessfulChannelProbe(
|
||||
account: Pick<ChannelRuntimeAccountSnapshot, 'probe'>,
|
||||
): boolean {
|
||||
return account.probe?.ok === true;
|
||||
}
|
||||
|
||||
export function hasChannelRuntimeError(
|
||||
account: Pick<ChannelRuntimeAccountSnapshot, 'lastError'>,
|
||||
): boolean {
|
||||
return hasNonEmptyError(account.lastError);
|
||||
}
|
||||
|
||||
export function hasSummaryRuntimeError(
|
||||
summary: ChannelRuntimeSummarySnapshot | undefined,
|
||||
): boolean {
|
||||
if (!summary) return false;
|
||||
return hasNonEmptyError(summary.error) || hasNonEmptyError(summary.lastError);
|
||||
}
|
||||
|
||||
export function isChannelRuntimeConnected(
|
||||
account: ChannelRuntimeAccountSnapshot,
|
||||
): boolean {
|
||||
if (account.connected === true || account.linked === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (hasRecentChannelActivity(account) || hasSuccessfulChannelProbe(account)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// OpenClaw integrations such as Feishu/WeCom may stay "running" without ever
|
||||
// setting a durable connected=true flag. Treat healthy running as connected.
|
||||
return account.running === true && !hasChannelRuntimeError(account);
|
||||
}
|
||||
|
||||
export function computeChannelRuntimeStatus(
|
||||
account: ChannelRuntimeAccountSnapshot,
|
||||
): ChannelConnectionStatus {
|
||||
if (isChannelRuntimeConnected(account)) return 'connected';
|
||||
if (hasChannelRuntimeError(account)) return 'error';
|
||||
if (account.running === true) return 'connecting';
|
||||
return 'disconnected';
|
||||
}
|
||||
|
||||
export function pickChannelRuntimeStatus(
|
||||
accounts: ChannelRuntimeAccountSnapshot[],
|
||||
summary?: ChannelRuntimeSummarySnapshot,
|
||||
): ChannelConnectionStatus {
|
||||
if (accounts.some((account) => isChannelRuntimeConnected(account))) {
|
||||
return 'connected';
|
||||
}
|
||||
|
||||
if (accounts.some((account) => hasChannelRuntimeError(account)) || hasSummaryRuntimeError(summary)) {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
if (accounts.some((account) => account.running === true)) {
|
||||
return 'connecting';
|
||||
}
|
||||
|
||||
return 'disconnected';
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* Shared OpenClaw Plugin Install Utilities
|
||||
*
|
||||
* Provides version-aware install/upgrade logic for bundled OpenClaw plugins
|
||||
* (DingTalk, WeCom, QQBot, Feishu). Used both at app startup (to auto-upgrade
|
||||
* (DingTalk, WeCom, QQBot, Feishu, WeChat). Used both at app startup (to auto-upgrade
|
||||
* stale plugins) and when a user configures a channel.
|
||||
*/
|
||||
import { app } from 'electron';
|
||||
@@ -171,6 +171,7 @@ const PLUGIN_NPM_NAMES: Record<string, string> = {
|
||||
wecom: '@wecom/wecom-openclaw-plugin',
|
||||
'feishu-openclaw-plugin': '@larksuite/openclaw-lark',
|
||||
qqbot: '@sliverp/qqbot',
|
||||
'openclaw-weixin': '@tencent-weixin/openclaw-weixin',
|
||||
};
|
||||
|
||||
// ── Version helper ───────────────────────────────────────────────────────────
|
||||
@@ -450,6 +451,10 @@ export function ensureQQBotPluginInstalled(): { installed: boolean; warning?: st
|
||||
return ensurePluginInstalled('qqbot', buildCandidateSources('qqbot'), 'QQ Bot');
|
||||
}
|
||||
|
||||
export function ensureWeChatPluginInstalled(): { installed: boolean; warning?: string } {
|
||||
return ensurePluginInstalled('openclaw-weixin', buildCandidateSources('openclaw-weixin'), 'WeChat');
|
||||
}
|
||||
|
||||
// ── Bulk startup installer ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -460,6 +465,7 @@ const ALL_BUNDLED_PLUGINS = [
|
||||
{ fn: ensureWeComPluginInstalled, label: 'WeCom' },
|
||||
{ fn: ensureQQBotPluginInstalled, label: 'QQ Bot' },
|
||||
{ fn: ensureFeishuPluginInstalled, label: 'Feishu' },
|
||||
{ fn: ensureWeChatPluginInstalled, label: 'WeChat' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
457
electron/utils/wechat-login.ts
Normal file
457
electron/utils/wechat-login.ts
Normal file
@@ -0,0 +1,457 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { deflateSync } from 'node:zlib';
|
||||
import { normalizeOpenClawAccountId } from './channel-alias';
|
||||
import { getOpenClawResolvedDir } from './paths';
|
||||
|
||||
export const DEFAULT_WECHAT_BASE_URL = 'https://ilinkai.weixin.qq.com';
|
||||
const DEFAULT_ILINK_BOT_TYPE = '3';
|
||||
const ACTIVE_LOGIN_TTL_MS = 5 * 60_000;
|
||||
const QR_POLL_TIMEOUT_MS = 35_000;
|
||||
const MAX_QR_REFRESH_COUNT = 3;
|
||||
const OPENCLAW_DIR = join(homedir(), '.openclaw');
|
||||
const WECHAT_STATE_DIR = join(OPENCLAW_DIR, 'openclaw-weixin');
|
||||
const WECHAT_ACCOUNT_INDEX_FILE = join(WECHAT_STATE_DIR, 'accounts.json');
|
||||
const WECHAT_ACCOUNTS_DIR = join(WECHAT_STATE_DIR, 'accounts');
|
||||
const require = createRequire(import.meta.url);
|
||||
type QrRenderDeps = {
|
||||
QRCode: typeof import('qrcode-terminal/vendor/QRCode/index.js');
|
||||
QRErrorCorrectLevel: typeof import('qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js');
|
||||
};
|
||||
|
||||
let qrRenderDeps: QrRenderDeps | null = null;
|
||||
|
||||
function getQrRenderDeps(): QrRenderDeps {
|
||||
if (qrRenderDeps) {
|
||||
return qrRenderDeps;
|
||||
}
|
||||
|
||||
const openclawRequire = createRequire(join(getOpenClawResolvedDir(), 'package.json'));
|
||||
const qrcodeTerminalPath = dirname(openclawRequire.resolve('qrcode-terminal/package.json'));
|
||||
qrRenderDeps = {
|
||||
QRCode: require(join(qrcodeTerminalPath, 'vendor', 'QRCode', 'index.js')),
|
||||
QRErrorCorrectLevel: require(join(qrcodeTerminalPath, 'vendor', 'QRCode', 'QRErrorCorrectLevel.js')),
|
||||
};
|
||||
return qrRenderDeps;
|
||||
}
|
||||
|
||||
type ActiveLogin = {
|
||||
sessionKey: string;
|
||||
qrcode: string;
|
||||
qrcodeUrl: string;
|
||||
startedAt: number;
|
||||
apiBaseUrl: string;
|
||||
};
|
||||
|
||||
type QrCodeResponse = {
|
||||
qrcode: string;
|
||||
qrcode_img_content: string;
|
||||
};
|
||||
|
||||
type QrStatusResponse = {
|
||||
status: 'wait' | 'scaned' | 'confirmed' | 'expired';
|
||||
bot_token?: string;
|
||||
ilink_bot_id?: string;
|
||||
baseurl?: string;
|
||||
ilink_user_id?: string;
|
||||
};
|
||||
|
||||
export type WeChatLoginStartResult = {
|
||||
sessionKey: string;
|
||||
qrcodeUrl?: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type WeChatLoginWaitResult = {
|
||||
connected: boolean;
|
||||
message: string;
|
||||
botToken?: string;
|
||||
accountId?: string;
|
||||
baseUrl?: string;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
const activeLogins = new Map<string, ActiveLogin>();
|
||||
|
||||
function createQrMatrix(input: string) {
|
||||
const { QRCode, QRErrorCorrectLevel } = getQrRenderDeps();
|
||||
const qr = new QRCode(-1, QRErrorCorrectLevel.L);
|
||||
qr.addData(input);
|
||||
qr.make();
|
||||
return qr;
|
||||
}
|
||||
|
||||
function fillPixel(
|
||||
buf: Buffer,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
r: number,
|
||||
g: number,
|
||||
b: number,
|
||||
a = 255,
|
||||
) {
|
||||
const idx = (y * width + x) * 4;
|
||||
buf[idx] = r;
|
||||
buf[idx + 1] = g;
|
||||
buf[idx + 2] = b;
|
||||
buf[idx + 3] = a;
|
||||
}
|
||||
|
||||
function crcTable() {
|
||||
const table = new Uint32Array(256);
|
||||
for (let i = 0; i < 256; i += 1) {
|
||||
let c = i;
|
||||
for (let k = 0; k < 8; k += 1) {
|
||||
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
}
|
||||
table[i] = c >>> 0;
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
const CRC_TABLE = crcTable();
|
||||
|
||||
function crc32(buf: Buffer) {
|
||||
let crc = 0xffffffff;
|
||||
for (let i = 0; i < buf.length; i += 1) {
|
||||
crc = CRC_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function pngChunk(type: string, data: Buffer) {
|
||||
const typeBuf = Buffer.from(type, 'ascii');
|
||||
const len = Buffer.alloc(4);
|
||||
len.writeUInt32BE(data.length, 0);
|
||||
const crc = crc32(Buffer.concat([typeBuf, data]));
|
||||
const crcBuf = Buffer.alloc(4);
|
||||
crcBuf.writeUInt32BE(crc, 0);
|
||||
return Buffer.concat([len, typeBuf, data, crcBuf]);
|
||||
}
|
||||
|
||||
function encodePngRgba(buffer: Buffer, width: number, height: number) {
|
||||
const stride = width * 4;
|
||||
const raw = Buffer.alloc((stride + 1) * height);
|
||||
for (let row = 0; row < height; row += 1) {
|
||||
const rawOffset = row * (stride + 1);
|
||||
raw[rawOffset] = 0;
|
||||
buffer.copy(raw, rawOffset + 1, row * stride, row * stride + stride);
|
||||
}
|
||||
const compressed = deflateSync(raw);
|
||||
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(width, 0);
|
||||
ihdr.writeUInt32BE(height, 4);
|
||||
ihdr[8] = 8;
|
||||
ihdr[9] = 6;
|
||||
ihdr[10] = 0;
|
||||
ihdr[11] = 0;
|
||||
ihdr[12] = 0;
|
||||
|
||||
return Buffer.concat([
|
||||
signature,
|
||||
pngChunk('IHDR', ihdr),
|
||||
pngChunk('IDAT', compressed),
|
||||
pngChunk('IEND', Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
async function renderQrPngDataUrl(
|
||||
input: string,
|
||||
opts: { scale?: number; marginModules?: number } = {},
|
||||
): Promise<string> {
|
||||
const { scale = 6, marginModules = 4 } = opts;
|
||||
const qr = createQrMatrix(input);
|
||||
const modules = qr.getModuleCount();
|
||||
const size = (modules + marginModules * 2) * scale;
|
||||
const buf = Buffer.alloc(size * size * 4, 255);
|
||||
|
||||
for (let row = 0; row < modules; row += 1) {
|
||||
for (let col = 0; col < modules; col += 1) {
|
||||
if (!qr.isDark(row, col)) continue;
|
||||
const startX = (col + marginModules) * scale;
|
||||
const startY = (row + marginModules) * scale;
|
||||
for (let y = 0; y < scale; y += 1) {
|
||||
const pixelY = startY + y;
|
||||
for (let x = 0; x < scale; x += 1) {
|
||||
const pixelX = startX + x;
|
||||
fillPixel(buf, pixelX, pixelY, size, 0, 0, 0, 255);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const png = encodePngRgba(buf, size, size);
|
||||
return `data:image/png;base64,${png.toString('base64')}`;
|
||||
}
|
||||
|
||||
function isLoginFresh(login: ActiveLogin): boolean {
|
||||
return Date.now() - login.startedAt < ACTIVE_LOGIN_TTL_MS;
|
||||
}
|
||||
|
||||
function resolveConfigPath(): string {
|
||||
const envPath = process.env.OPENCLAW_CONFIG?.trim();
|
||||
if (envPath) return envPath;
|
||||
return join(OPENCLAW_DIR, 'openclaw.json');
|
||||
}
|
||||
|
||||
function loadWeChatRouteTag(accountId?: string): string | undefined {
|
||||
try {
|
||||
const configPath = resolveConfigPath();
|
||||
if (!existsSync(configPath)) return undefined;
|
||||
const raw = readFileSync(configPath, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as {
|
||||
channels?: Record<string, {
|
||||
routeTag?: string | number;
|
||||
accounts?: Record<string, { routeTag?: string | number }>;
|
||||
}>;
|
||||
};
|
||||
const section = parsed.channels?.['openclaw-weixin'];
|
||||
if (!section) return undefined;
|
||||
if (accountId) {
|
||||
const normalizedAccountId = normalizeOpenClawAccountId(accountId);
|
||||
const scopedRouteTag = section.accounts?.[normalizedAccountId]?.routeTag;
|
||||
if (typeof scopedRouteTag === 'number') return String(scopedRouteTag);
|
||||
if (typeof scopedRouteTag === 'string' && scopedRouteTag.trim()) return scopedRouteTag.trim();
|
||||
}
|
||||
if (typeof section.routeTag === 'number') return String(section.routeTag);
|
||||
if (typeof section.routeTag === 'string' && section.routeTag.trim()) return section.routeTag.trim();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function fetchWeChatQrCode(apiBaseUrl: string, accountId?: string, botType = DEFAULT_ILINK_BOT_TYPE): Promise<QrCodeResponse> {
|
||||
const base = apiBaseUrl.endsWith('/') ? apiBaseUrl : `${apiBaseUrl}/`;
|
||||
const url = new URL(`ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`, base);
|
||||
const headers: Record<string, string> = {};
|
||||
const routeTag = loadWeChatRouteTag(accountId);
|
||||
if (routeTag) {
|
||||
headers.SKRouteTag = routeTag;
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), { headers });
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => '(unreadable)');
|
||||
throw new Error(`Failed to fetch QR code: ${response.status} ${response.statusText} ${body}`);
|
||||
}
|
||||
return await response.json() as QrCodeResponse;
|
||||
}
|
||||
|
||||
async function pollWeChatQrStatus(apiBaseUrl: string, qrcode: string, accountId?: string): Promise<QrStatusResponse> {
|
||||
const base = apiBaseUrl.endsWith('/') ? apiBaseUrl : `${apiBaseUrl}/`;
|
||||
const url = new URL(`ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`, base);
|
||||
const headers: Record<string, string> = {
|
||||
'iLink-App-ClientVersion': '1',
|
||||
};
|
||||
const routeTag = loadWeChatRouteTag(accountId);
|
||||
if (routeTag) {
|
||||
headers.SKRouteTag = routeTag;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), QR_POLL_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(url.toString(), { headers, signal: controller.signal });
|
||||
clearTimeout(timer);
|
||||
const rawText = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to poll QR status: ${response.status} ${response.statusText} ${rawText}`);
|
||||
}
|
||||
return JSON.parse(rawText) as QrStatusResponse;
|
||||
} catch (error) {
|
||||
clearTimeout(timer);
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
return { status: 'wait' };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function readAccountIndex(): Promise<string[]> {
|
||||
try {
|
||||
const raw = await readFile(WECHAT_ACCOUNT_INDEX_FILE, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function writeAccountIndex(accountIds: string[]): Promise<void> {
|
||||
await mkdir(WECHAT_STATE_DIR, { recursive: true });
|
||||
await writeFile(WECHAT_ACCOUNT_INDEX_FILE, JSON.stringify(accountIds, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
export async function saveWeChatAccountState(rawAccountId: string, payload: {
|
||||
token: string;
|
||||
baseUrl?: string;
|
||||
userId?: string;
|
||||
}): Promise<string> {
|
||||
const accountId = normalizeOpenClawAccountId(rawAccountId);
|
||||
await mkdir(WECHAT_ACCOUNTS_DIR, { recursive: true });
|
||||
|
||||
const filePath = join(WECHAT_ACCOUNTS_DIR, `${accountId}.json`);
|
||||
const data = {
|
||||
token: payload.token.trim(),
|
||||
savedAt: new Date().toISOString(),
|
||||
...(payload.baseUrl?.trim() ? { baseUrl: payload.baseUrl.trim() } : {}),
|
||||
...(payload.userId?.trim() ? { userId: payload.userId.trim() } : {}),
|
||||
};
|
||||
await writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
try {
|
||||
await chmod(filePath, 0o600);
|
||||
} catch {
|
||||
// best effort only
|
||||
}
|
||||
|
||||
const existingAccountIds = await readAccountIndex();
|
||||
if (!existingAccountIds.includes(accountId)) {
|
||||
await writeAccountIndex([...existingAccountIds, accountId]);
|
||||
}
|
||||
|
||||
return accountId;
|
||||
}
|
||||
|
||||
export async function startWeChatLoginSession(options: {
|
||||
sessionKey?: string;
|
||||
accountId?: string;
|
||||
apiBaseUrl?: string;
|
||||
force?: boolean;
|
||||
}): Promise<WeChatLoginStartResult> {
|
||||
const sessionKey = options.sessionKey?.trim() || randomUUID();
|
||||
const apiBaseUrl = options.apiBaseUrl?.trim() || DEFAULT_WECHAT_BASE_URL;
|
||||
const existing = activeLogins.get(sessionKey);
|
||||
|
||||
if (!options.force && existing && isLoginFresh(existing) && existing.qrcodeUrl) {
|
||||
return {
|
||||
sessionKey,
|
||||
qrcodeUrl: existing.qrcodeUrl,
|
||||
message: 'QR code is ready. Scan it with WeChat.',
|
||||
};
|
||||
}
|
||||
|
||||
const qrResponse = await fetchWeChatQrCode(apiBaseUrl, options.accountId);
|
||||
const qrDataUrl = await renderQrPngDataUrl(qrResponse.qrcode_img_content);
|
||||
activeLogins.set(sessionKey, {
|
||||
sessionKey,
|
||||
qrcode: qrResponse.qrcode,
|
||||
qrcodeUrl: qrDataUrl,
|
||||
startedAt: Date.now(),
|
||||
apiBaseUrl,
|
||||
});
|
||||
|
||||
return {
|
||||
sessionKey,
|
||||
qrcodeUrl: qrDataUrl,
|
||||
message: 'Scan the QR code with WeChat to complete login.',
|
||||
};
|
||||
}
|
||||
|
||||
export async function waitForWeChatLoginSession(options: {
|
||||
sessionKey: string;
|
||||
timeoutMs?: number;
|
||||
accountId?: string;
|
||||
onQrRefresh?: (payload: { qrcodeUrl: string }) => void | Promise<void>;
|
||||
}): Promise<WeChatLoginWaitResult> {
|
||||
const login = activeLogins.get(options.sessionKey);
|
||||
if (!login) {
|
||||
return {
|
||||
connected: false,
|
||||
message: 'No active WeChat login session. Generate a new QR code and try again.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!isLoginFresh(login)) {
|
||||
activeLogins.delete(options.sessionKey);
|
||||
return {
|
||||
connected: false,
|
||||
message: 'The QR code has expired. Generate a new QR code and try again.',
|
||||
};
|
||||
}
|
||||
|
||||
const timeoutMs = Math.max(options.timeoutMs ?? 480_000, 1000);
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let qrRefreshCount = 1;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const current = activeLogins.get(options.sessionKey);
|
||||
if (!current) {
|
||||
return {
|
||||
connected: false,
|
||||
message: 'The WeChat login session was cancelled.',
|
||||
};
|
||||
}
|
||||
|
||||
const statusResponse = await pollWeChatQrStatus(current.apiBaseUrl, current.qrcode, options.accountId);
|
||||
switch (statusResponse.status) {
|
||||
case 'wait':
|
||||
case 'scaned':
|
||||
break;
|
||||
case 'expired': {
|
||||
qrRefreshCount += 1;
|
||||
if (qrRefreshCount > MAX_QR_REFRESH_COUNT) {
|
||||
activeLogins.delete(options.sessionKey);
|
||||
return {
|
||||
connected: false,
|
||||
message: 'The QR code expired too many times. Generate a new QR code and try again.',
|
||||
};
|
||||
}
|
||||
const refreshedQr = await fetchWeChatQrCode(current.apiBaseUrl, options.accountId);
|
||||
const refreshedQrDataUrl = await renderQrPngDataUrl(refreshedQr.qrcode_img_content);
|
||||
activeLogins.set(options.sessionKey, {
|
||||
...current,
|
||||
qrcode: refreshedQr.qrcode,
|
||||
qrcodeUrl: refreshedQrDataUrl,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
await options.onQrRefresh?.({ qrcodeUrl: refreshedQrDataUrl });
|
||||
break;
|
||||
}
|
||||
case 'confirmed':
|
||||
activeLogins.delete(options.sessionKey);
|
||||
if (!statusResponse.ilink_bot_id || !statusResponse.bot_token) {
|
||||
return {
|
||||
connected: false,
|
||||
message: 'WeChat login succeeded but the server did not return the required account credentials.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
connected: true,
|
||||
botToken: statusResponse.bot_token,
|
||||
accountId: statusResponse.ilink_bot_id,
|
||||
baseUrl: statusResponse.baseurl,
|
||||
userId: statusResponse.ilink_user_id,
|
||||
message: 'WeChat connected successfully.',
|
||||
};
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
activeLogins.delete(options.sessionKey);
|
||||
return {
|
||||
connected: false,
|
||||
message: 'Timed out waiting for WeChat QR confirmation.',
|
||||
};
|
||||
}
|
||||
|
||||
export async function cancelWeChatLoginSession(sessionKey?: string): Promise<void> {
|
||||
if (!sessionKey) {
|
||||
activeLogins.clear();
|
||||
return;
|
||||
}
|
||||
activeLogins.delete(sessionKey);
|
||||
}
|
||||
|
||||
export async function clearWeChatLoginState(): Promise<void> {
|
||||
activeLogins.clear();
|
||||
await rm(WECHAT_STATE_DIR, { recursive: true, force: true });
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
23
pnpm-lock.yaml
generated
23
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
1
src/assets/channels/wechat.svg
Normal file
1
src/assets/channels/wechat.svg
Normal file
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1774167686596" class="icon" viewBox="0 0 1309 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2533" xmlns:xlink="http://www.w3.org/1999/xlink" width="255.6640625" height="200"><path d="M1147.26896 912.681417l34.90165 111.318583-127.165111-66.823891a604.787313 604.787313 0 0 1-139.082747 22.263717c-220.607239 0-394.296969-144.615936-394.296969-322.758409s173.526026-322.889372 394.296969-322.889372C1124.219465 333.661082 1309.630388 478.669907 1309.630388 656.550454c0 100.284947-69.344929 189.143369-162.361428 256.130963zM788.070086 511.869037a49.11114 49.11114 0 0 0-46.360916 44.494692 48.783732 48.783732 0 0 0 46.360916 44.494693 52.090549 52.090549 0 0 0 57.983885-44.494693 52.385216 52.385216 0 0 0-57.983885-44.494692z m254.985036 0a48.881954 48.881954 0 0 0-46.09899 44.494692 48.620028 48.620028 0 0 0 46.09899 44.494693 52.385216 52.385216 0 0 0 57.983886-44.494693 52.58166 52.58166 0 0 0-57.951145-44.494692z m-550.568615 150.018161a318.567592 318.567592 0 0 0 14.307712 93.212943c-14.307712 1.080445-28.746387 1.768001-43.283284 1.768001a827.293516 827.293516 0 0 1-162.394168-22.296458l-162.001279 77.955749 46.328175-133.811485C69.410411 600.858422 0 500.507993 0 378.38496 0 166.683208 208.689602 0 463.510935 0c227.908428 0 427.594322 133.18941 467.701752 312.379588a427.463358 427.463358 0 0 0-44.625655-2.619261c-220.24709 0-394.100524 157.74498-394.100525 352.126871zM312.90344 189.143369a64.270111 64.270111 0 0 0-69.803299 55.659291 64.532037 64.532037 0 0 0 69.803299 55.659292 53.694846 53.694846 0 0 0 57.852923-55.659292 53.465661 53.465661 0 0 0-57.852923-55.659291z m324.428188 0a64.040926 64.040926 0 0 0-69.574114 55.659291 64.302852 64.302852 0 0 0 69.574114 55.659292 53.694846 53.694846 0 0 0 57.951145-55.659292 53.465661 53.465661 0 0 0-57.951145-55.659291z" p-id="2534" fill="#515151"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -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 ? (
|
||||
<div className="text-center space-y-6">
|
||||
<div className="bg-[#eeece3] dark:bg-muted p-4 rounded-3xl inline-block shadow-sm border border-black/10 dark:border-white/10">
|
||||
{qrCode.startsWith('data:image') ? (
|
||||
{qrCode.startsWith('data:image') || qrCode.startsWith('http://') || qrCode.startsWith('https://') ? (
|
||||
<img src={qrCode} alt="Scan QR Code" className="w-64 h-64 object-contain rounded-2xl" />
|
||||
) : (
|
||||
<div className="w-64 h-64 bg-white dark:bg-background rounded-2xl flex items-center justify-center">
|
||||
@@ -590,7 +634,7 @@ export function ChannelConfigModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allowEditAccountId && (
|
||||
{showAccountIdEditor && (
|
||||
<div className="space-y-2.5">
|
||||
<Label htmlFor="account-id" className={labelClasses}>{t('account.customIdLabel')}</Label>
|
||||
<Input
|
||||
@@ -693,7 +737,7 @@ export function ChannelConfigModal({
|
||||
onClick={() => {
|
||||
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 <img src={discordIcon} alt="Discord" className="w-[22px] h-[22px] dark:invert" />;
|
||||
case 'whatsapp':
|
||||
return <img src={whatsappIcon} alt="WhatsApp" className="w-[22px] h-[22px] dark:invert" />;
|
||||
case 'wechat':
|
||||
return <img src={wechatIcon} alt="WeChat" className="w-[22px] h-[22px] dark:invert" />;
|
||||
case 'dingtalk':
|
||||
return <img src={dingtalkIcon} alt="DingTalk" className="w-[22px] h-[22px] dark:invert" />;
|
||||
case 'feishu':
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
50
src/lib/channel-alias.ts
Normal file
50
src/lib/channel-alias.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -15,6 +15,9 @@ const HOST_EVENT_TO_IPC_CHANNEL: Record<string, string> = {
|
||||
'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 {
|
||||
|
||||
@@ -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 <img src={discordIcon} alt="Discord" className="w-[20px] h-[20px] dark:invert" />;
|
||||
case 'whatsapp':
|
||||
return <img src={whatsappIcon} alt="WhatsApp" className="w-[20px] h-[20px] dark:invert" />;
|
||||
case 'wechat':
|
||||
return <img src={wechatIcon} alt="WeChat" className="w-[20px] h-[20px] dark:invert" />;
|
||||
case 'dingtalk':
|
||||
return <img src={dingtalkIcon} alt="DingTalk" className="w-[20px] h-[20px] dark:invert" />;
|
||||
case 'feishu':
|
||||
|
||||
@@ -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 <img src={discordIcon} alt="Discord" className="w-[22px] h-[22px] dark:invert" />;
|
||||
case 'whatsapp':
|
||||
return <img src={whatsappIcon} alt="WhatsApp" className="w-[22px] h-[22px] dark:invert" />;
|
||||
case 'wechat':
|
||||
return <img src={wechatIcon} alt="WeChat" className="w-[22px] h-[22px] dark:invert" />;
|
||||
case 'dingtalk':
|
||||
return <img src={dingtalkIcon} alt="DingTalk" className="w-[22px] h-[22px] dark:invert" />;
|
||||
case 'feishu':
|
||||
|
||||
@@ -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<string, NodeJS.Timeout>();
|
||||
const reconnectAttempts = new Map<string, number>();
|
||||
|
||||
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<ChannelsState>((set, get) => ({
|
||||
channels: [],
|
||||
loading: false,
|
||||
@@ -75,6 +87,8 @@ export const useChannelsStore = create<ChannelsState>((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<string, unknown> | undefined)?.[channelId] as Record<string, unknown> | undefined;
|
||||
const configured =
|
||||
typeof summary?.configured === 'boolean'
|
||||
@@ -101,14 +115,17 @@ export const useChannelsStore = create<ChannelsState>((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<ChannelsState>((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<ChannelsState>((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<ChannelsState>((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<ChannelsState>((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<ChannelsState>((set, get) => ({
|
||||
requestQrCode: async (channelType) => {
|
||||
return await useGatewayStore.getState().rpc<{ qrCode: string; sessionId: string }>(
|
||||
'channels.requestQr',
|
||||
{ type: channelType },
|
||||
{ type: toOpenClawChannelType(channelType) },
|
||||
);
|
||||
},
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
export type ChannelType =
|
||||
| 'whatsapp'
|
||||
| 'wechat'
|
||||
| 'dingtalk'
|
||||
| 'telegram'
|
||||
| 'discord'
|
||||
@@ -81,6 +82,7 @@ export interface ChannelMeta {
|
||||
*/
|
||||
export const CHANNEL_ICONS: Record<ChannelType, string> = {
|
||||
whatsapp: '📱',
|
||||
wechat: '💬',
|
||||
dingtalk: '💬',
|
||||
telegram: '✈️',
|
||||
discord: '🎮',
|
||||
@@ -101,6 +103,7 @@ export const CHANNEL_ICONS: Record<ChannelType, string> = {
|
||||
*/
|
||||
export const CHANNEL_NAMES: Record<ChannelType, string> = {
|
||||
whatsapp: 'WhatsApp',
|
||||
wechat: 'WeChat',
|
||||
dingtalk: 'DingTalk',
|
||||
telegram: 'Telegram',
|
||||
discord: 'Discord',
|
||||
@@ -323,6 +326,22 @@ export const CHANNEL_META: Record<ChannelType, ChannelMeta> = {
|
||||
'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<ChannelType, ChannelMeta> = {
|
||||
* 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'];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(<Channels />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
103
tests/unit/wechat-login.test.ts
Normal file
103
tests/unit/wechat-login.test.ts
Normal file
@@ -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<typeof import('node:os')>('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']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user