feat(channel): support weichat channel (#620)
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user