Files
DeskClaw/electron/api/routes/channels.ts

626 lines
23 KiB
TypeScript

import type { IncomingMessage, ServerResponse } from 'http';
import {
deleteChannelAccountConfig,
deleteChannelConfig,
cleanupDanglingWeChatPluginState,
getChannelFormValues,
listConfiguredChannelAccounts,
listConfiguredChannels,
readOpenClawConfig,
saveChannelConfig,
setChannelDefaultAccount,
setChannelEnabled,
validateChannelConfig,
validateChannelCredentials,
} from '../../utils/channel-config';
import {
assignChannelAccountToAgent,
clearAllBindingsForChannel,
clearChannelBinding,
listAgentsSnapshot,
} from '../../utils/agent-config';
import {
ensureDingTalkPluginInstalled,
ensureFeishuPluginInstalled,
ensureQQBotPluginInstalled,
ensureWeChatPluginInstalled,
ensureWeComPluginInstalled,
} from '../../utils/plugin-install';
import {
computeChannelRuntimeStatus,
pickChannelRuntimeStatus,
type ChannelRuntimeAccountSnapshot,
} 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;
}
ctx.gatewayManager.debouncedRestart();
void reason;
}
// 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', 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(storedChannelType)) {
ctx.gatewayManager.debouncedRestart();
void reason;
return;
}
ctx.gatewayManager.debouncedReload();
void reason;
}
function toComparableConfig(input: Record<string, unknown>): Record<string, string> {
const next: Record<string, string> = {};
for (const [key, value] of Object.entries(input)) {
if (value === undefined || value === null) continue;
if (typeof value === 'string') {
next[key] = value.trim();
continue;
}
if (typeof value === 'number' || typeof value === 'boolean') {
next[key] = String(value);
}
}
return next;
}
function isSameConfigValues(
existing: Record<string, string> | undefined,
incoming: Record<string, unknown>,
): boolean {
if (!existing) return false;
const next = toComparableConfig(incoming);
const keys = new Set([...Object.keys(existing), ...Object.keys(next)]);
if (keys.size === 0) return false;
for (const key of keys) {
if ((existing[key] ?? '') !== (next[key] ?? '')) {
return false;
}
}
return true;
}
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.agents || agents.agents.length === 0) return;
// Keep backward compatibility for the legacy default account.
if (accountId === '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.agents.some((entry) => entry.id === accountId)) {
await assignChannelAccountToAgent(accountId, storedChannelType, accountId);
}
}
interface GatewayChannelStatusPayload {
channelOrder?: string[];
channels?: Record<string, unknown>;
channelAccounts?: Record<string, Array<{
accountId?: string;
configured?: boolean;
connected?: boolean;
running?: boolean;
lastError?: string;
name?: string;
linked?: boolean;
lastConnectedAt?: number | null;
lastInboundAt?: number | null;
lastOutboundAt?: number | null;
lastProbeAt?: number | null;
probe?: {
ok?: boolean;
} | null;
}>>;
channelDefaultAccountId?: Record<string, string>;
}
interface ChannelAccountView {
accountId: string;
name: string;
configured: boolean;
connected: boolean;
running: boolean;
linked: boolean;
lastError?: string;
status: 'connected' | 'connecting' | 'disconnected' | 'error';
isDefault: boolean;
agentId?: string;
}
interface ChannelAccountsView {
channelType: string;
defaultAccountId: string;
status: 'connected' | 'connecting' | 'disconnected' | 'error';
accounts: ChannelAccountView[];
}
async function buildChannelAccountsView(ctx: HostApiContext): Promise<ChannelAccountsView[]> {
const [configuredChannels, configuredAccounts, openClawConfig, agentsSnapshot] = await Promise.all([
listConfiguredChannels(),
listConfiguredChannelAccounts(),
readOpenClawConfig(),
listAgentsSnapshot(),
]);
let gatewayStatus: GatewayChannelStatusPayload | null;
try {
gatewayStatus = await ctx.gatewayManager.rpc<GatewayChannelStatusPayload>('channels.status', { probe: true });
} catch {
gatewayStatus = null;
}
const channelTypes = new Set<string>([
...configuredChannels,
...Object.keys(configuredAccounts),
...Object.keys(gatewayStatus?.channelAccounts || {}),
]);
const channels: ChannelAccountsView[] = [];
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?.[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
: (sortedConfigAccountIds[0] || 'default');
const defaultAccountId = configuredAccounts[rawChannelType]?.defaultAccountId
?? gatewayStatus?.channelDefaultAccountId?.[rawChannelType]
?? fallbackDefault;
const runtimeAccounts = gatewayStatus?.channelAccounts?.[rawChannelType] ?? [];
const hasRuntimeConfigured = runtimeAccounts.some((account) => account.configured === true);
if (!hasLocalConfig && !hasRuntimeConfigured) {
continue;
}
const runtimeAccountIds = runtimeAccounts
.map((account) => account.accountId)
.filter((accountId): accountId is string => typeof accountId === 'string' && accountId.trim().length > 0);
const accountIds = Array.from(new Set([...channelAccountsFromConfig, ...runtimeAccountIds, defaultAccountId]));
const accounts: ChannelAccountView[] = accountIds.map((accountId) => {
const runtime = runtimeAccounts.find((item) => item.accountId === accountId);
const runtimeSnapshot: ChannelRuntimeAccountSnapshot = runtime ?? {};
const status = computeChannelRuntimeStatus(runtimeSnapshot);
return {
accountId,
name: runtime?.name || accountId,
configured: channelAccountsFromConfig.includes(accountId) || runtime?.configured === true,
connected: runtime?.connected === true,
running: runtime?.running === true,
linked: runtime?.linked === true,
lastError: typeof runtime?.lastError === 'string' ? runtime.lastError : undefined,
status,
isDefault: accountId === defaultAccountId,
agentId: agentsSnapshot.channelAccountOwners[`${rawChannelType}:${accountId}`],
};
}).sort((left, right) => {
if (left.accountId === defaultAccountId) return -1;
if (right.accountId === defaultAccountId) return 1;
return left.accountId.localeCompare(right.accountId);
});
channels.push({
channelType: uiChannelType,
defaultAccountId,
status: pickChannelRuntimeStatus(runtimeAccounts, channelSummary),
accounts,
});
}
return channels.sort((left, right) => left.channelType.localeCompare(right.channelType));
}
export async function handleChannelRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/channels/configured' && req.method === 'GET') {
const channels = await listConfiguredChannels();
sendJson(res, 200, { success: true, channels: Array.from(new Set(channels.map((channel) => toUiChannelType(channel)))) });
return true;
}
if (url.pathname === '/api/channels/accounts' && req.method === 'GET') {
try {
const channels = await buildChannelAccountsView(ctx);
sendJson(res, 200, { success: true, channels });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/channels/default-account' && req.method === 'PUT') {
try {
const body = await parseJsonBody<{ channelType: string; accountId: string }>(req);
await setChannelDefaultAccount(body.channelType, body.accountId);
scheduleGatewayChannelSaveRefresh(ctx, body.channelType, `channel:setDefaultAccount:${body.channelType}`);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
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, resolveStoredChannelType(body.channelType), body.accountId);
scheduleGatewayChannelSaveRefresh(ctx, body.channelType, `channel:setBinding:${body.channelType}`);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/channels/binding' && req.method === 'DELETE') {
try {
const body = await parseJsonBody<{ channelType: string; accountId: string }>(req);
await clearChannelBinding(resolveStoredChannelType(body.channelType), body.accountId);
scheduleGatewayChannelSaveRefresh(ctx, body.channelType, `channel:clearBinding:${body.channelType}`);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/channels/config/validate' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ channelType: string }>(req);
sendJson(res, 200, { success: true, ...(await validateChannelConfig(body.channelType)) });
} catch (error) {
sendJson(res, 500, { success: false, valid: false, errors: [String(error)], warnings: [] });
}
return true;
}
if (url.pathname === '/api/channels/credentials/validate' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ channelType: string; config: Record<string, string> }>(req);
sendJson(res, 200, { success: true, ...(await validateChannelCredentials(body.channelType, body.config)) });
} catch (error) {
sendJson(res, 500, { success: false, valid: false, errors: [String(error)], warnings: [] });
}
return true;
}
if (url.pathname === '/api/channels/whatsapp/start' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ accountId: string }>(req);
await whatsAppLoginManager.start(body.accountId);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/channels/whatsapp/cancel' && req.method === 'POST') {
try {
await whatsAppLoginManager.stop();
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
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);
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 (storedChannelType === 'wecom') {
const installResult = await ensureWeComPluginInstalled();
if (!installResult.installed) {
sendJson(res, 500, { success: false, error: installResult.warning || 'WeCom plugin install failed' });
return true;
}
}
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 (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);
sendJson(res, 200, { success: true, noChange: true });
return true;
}
await saveChannelConfig(body.channelType, body.config, body.accountId);
await ensureScopedChannelBinding(body.channelType, body.accountId);
scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:saveConfig:${storedChannelType}`);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/channels/config/enabled' && req.method === 'PUT') {
try {
const body = await parseJsonBody<{ channelType: string; enabled: boolean }>(req);
await setChannelEnabled(body.channelType, body.enabled);
scheduleGatewayChannelRestart(ctx, `channel:setEnabled:${resolveStoredChannelType(body.channelType)}`);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/channels/config/') && req.method === 'GET') {
try {
const channelType = decodeURIComponent(url.pathname.slice('/api/channels/config/'.length));
const accountId = url.searchParams.get('accountId') || undefined;
sendJson(res, 200, {
success: true,
values: await getChannelFormValues(channelType, accountId),
});
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname.startsWith('/api/channels/config/') && req.method === 'DELETE') {
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(storedChannelType, accountId);
scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:deleteAccount:${storedChannelType}`);
} else {
await deleteChannelConfig(channelType);
await clearAllBindingsForChannel(storedChannelType);
scheduleGatewayChannelRestart(ctx, `channel:deleteConfig:${storedChannelType}`);
}
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
void ctx;
return false;
}