import { readFile, readdir } from 'node:fs/promises'; import { extractSessionRecords } from '../../utils/session-util'; import type { IncomingMessage, ServerResponse } from 'http'; import { join } from 'node:path'; import { deleteChannelAccountConfig, deleteChannelConfig, cleanupDanglingWeChatPluginState, getChannelFormValues, listConfiguredChannelAccountsFromConfig, listConfiguredChannels, listConfiguredChannelsFromConfig, readOpenClawConfig, saveChannelConfig, setChannelDefaultAccount, setChannelEnabled, validateChannelConfig, validateChannelCredentials, } from '../../utils/channel-config'; import { assignChannelAccountToAgent, clearAllBindingsForChannel, clearChannelBinding, listAgentsSnapshot, listAgentsSnapshotFromConfig, } from '../../utils/agent-config'; import { ensureDingTalkPluginInstalled, ensureFeishuPluginInstalled, ensureWeChatPluginInstalled, ensureWeComPluginInstalled, } from '../../utils/plugin-install'; import { computeChannelRuntimeStatus, pickChannelRuntimeStatus, type ChannelConnectionStatus, type ChannelRuntimeAccountSnapshot, type GatewayHealthState, } from '../../utils/channel-status'; import { OPENCLAW_WECHAT_CHANNEL_TYPE, UI_WECHAT_CHANNEL_TYPE, buildQrChannelEventName, isCanonicalOpenClawAccountId, toOpenClawChannelType, toUiChannelType, } from '../../utils/channel-alias'; import { getOpenClawConfigDir } from '../../utils/paths'; import { cancelWeChatLoginSession, saveWeChatAccountState, startWeChatLoginSession, waitForWeChatLoginSession, } from '../../utils/wechat-login'; import { whatsAppLoginManager } from '../../utils/whatsapp-login'; import { proxyAwareFetch } from '../../utils/proxy-fetch'; import { listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryPeersFromConfig, normalizeDiscordMessagingTarget, listTelegramDirectoryGroupsFromConfig, listTelegramDirectoryPeersFromConfig, normalizeTelegramMessagingTarget, listSlackDirectoryGroupsFromConfig, listSlackDirectoryPeersFromConfig, normalizeSlackMessagingTarget, normalizeWhatsAppMessagingTarget, } from '../../utils/openclaw-sdk'; import { logger } from '../../utils/logger'; import { buildGatewayHealthSummary } from '../../utils/gateway-health'; import type { GatewayHealthSummary } from '../../gateway/manager'; // listWhatsAppDirectory*FromConfig were removed from openclaw's public exports // in 2026.3.23-1. No-op stubs; WhatsApp target picker uses session discovery. // eslint-disable-next-line @typescript-eslint/no-explicit-any async function listWhatsAppDirectoryGroupsFromConfig(_params: any): Promise { return []; } // eslint-disable-next-line @typescript-eslint/no-explicit-any async function listWhatsAppDirectoryPeersFromConfig(_params: any): Promise { return []; } import type { HostApiContext } from '../context'; import { parseJsonBody, sendJson } from '../route-utils'; const WECHAT_QR_TIMEOUT_MS = 8 * 60 * 1000; const activeQrLogins = new Map(); interface WebLoginStartResult { qrcodeUrl?: string; message?: string; sessionKey?: string; } function resolveStoredChannelType(channelType: string): string { return toOpenClawChannelType(channelType); } function buildQrLoginKey(channelType: string, accountId?: string): string { return `${toUiChannelType(channelType)}:${accountId?.trim() || '__new__'}`; } async function isLegacyConfiguredAccountId(channelType: string, accountId: string): Promise { const config = await readOpenClawConfig(); const configuredAccounts = listConfiguredChannelAccountsFromConfig(config) ?? {}; const storedChannelType = resolveStoredChannelType(channelType); const knownAccountIds = configuredAccounts[storedChannelType]?.accountIds ?? []; return knownAccountIds.includes(accountId); } async function validateCanonicalAccountId( channelType: string, accountId: string | undefined, options?: { allowLegacyConfiguredId?: boolean; required?: boolean }, ): Promise { if (!accountId) { return options?.required ? 'accountId is required' : null; } const trimmed = accountId.trim(); if (!trimmed) return 'accountId cannot be empty'; if (isCanonicalOpenClawAccountId(trimmed)) { return null; } if (options?.allowLegacyConfiguredId && await isLegacyConfiguredAccountId(channelType, trimmed)) { return null; } // Backward compatibility note: // existing legacy IDs can still be edited/bound if they already exist in config. // new account IDs must be canonical to match OpenClaw runtime routing behavior. return 'Invalid accountId format. Use lowercase letters, numbers, hyphens, or underscores only (max 64 chars, must start with a letter or number).'; } async function validateAccountIdOrReply( res: ServerResponse, channelType: string, accountId: string | undefined, options?: { required?: boolean }, ): Promise { const error = await validateCanonicalAccountId(channelType, accountId, { allowLegacyConfiguredId: true, required: options?.required, }); if (!error) { return true; } sendJson(res, 400, { success: false, error }); return false; } function setActiveQrLogin(channelType: string, sessionKey: string, accountId?: string): string { const loginKey = buildQrLoginKey(channelType, accountId); activeQrLogins.set(loginKey, sessionKey); return loginKey; } function isActiveQrLogin(loginKey: string, sessionKey: string): boolean { return activeQrLogins.get(loginKey) === sessionKey; } function clearActiveQrLogin(channelType: string, accountId?: string): void { activeQrLogins.delete(buildQrLoginKey(channelType, accountId)); } function emitChannelEvent( ctx: HostApiContext, channelType: string, event: 'qr' | 'success' | 'error', payload: unknown, ): void { const eventName = buildQrChannelEventName(channelType, event); ctx.eventBus.emit(eventName, payload); if (ctx.mainWindow && !ctx.mainWindow.isDestroyed()) { ctx.mainWindow.webContents.send(eventName, payload); } } async function startWeChatQrLogin(ctx: HostApiContext, accountId?: string): Promise { void ctx; return await startWeChatLoginSession({ ...(accountId ? { accountId } : {}), force: true, }); } async function awaitWeChatQrLogin( ctx: HostApiContext, sessionKey: string, loginKey: string, ): Promise { try { const result = await waitForWeChatLoginSession({ sessionKey, timeoutMs: WECHAT_QR_TIMEOUT_MS, onQrRefresh: async ({ qrcodeUrl }) => { if (!isActiveQrLogin(loginKey, sessionKey)) { return; } emitChannelEvent(ctx, UI_WECHAT_CHANNEL_TYPE, 'qr', { qr: qrcodeUrl, raw: qrcodeUrl, sessionKey, }); }, }); if (!isActiveQrLogin(loginKey, sessionKey)) { return; } if (!result.connected || !result.accountId || !result.botToken) { emitChannelEvent(ctx, UI_WECHAT_CHANNEL_TYPE, 'error', result.message || 'WeChat login did not complete'); return; } const normalizedAccountId = await saveWeChatAccountState(result.accountId, { token: result.botToken, baseUrl: result.baseUrl, userId: result.userId, }); await saveChannelConfig(UI_WECHAT_CHANNEL_TYPE, { enabled: true }, normalizedAccountId); await ensureScopedChannelBinding(UI_WECHAT_CHANNEL_TYPE, normalizedAccountId); scheduleGatewayChannelSaveRefresh(ctx, OPENCLAW_WECHAT_CHANNEL_TYPE, `wechat:loginSuccess:${normalizedAccountId}`); if (!isActiveQrLogin(loginKey, sessionKey)) { return; } emitChannelEvent(ctx, UI_WECHAT_CHANNEL_TYPE, 'success', { accountId: normalizedAccountId, rawAccountId: result.accountId, message: result.message, }); } catch (error) { if (!isActiveQrLogin(loginKey, sessionKey)) { return; } emitChannelEvent(ctx, UI_WECHAT_CHANNEL_TYPE, 'error', String(error)); } finally { if (isActiveQrLogin(loginKey, sessionKey)) { activeQrLogins.delete(loginKey); } await cancelWeChatLoginSession(sessionKey); } } function scheduleGatewayChannelRestart(ctx: HostApiContext, reason: string): void { if (ctx.gatewayManager.getStatus().state === 'stopped') { return; } 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). // OpenClaw 3.23+ does not reliably support in-process channel reload for any // channel type. All channel config saves must trigger a full Gateway process // restart to ensure the channel adapter properly initializes with the new config. const FORCE_RESTART_CHANNELS = new Set([ 'dingtalk', 'wecom', 'whatsapp', 'feishu', 'qqbot', OPENCLAW_WECHAT_CHANNEL_TYPE, 'discord', 'telegram', 'signal', 'imessage', 'matrix', 'line', 'msteams', 'googlechat', 'mattermost', ]); 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(150); void reason; return; } ctx.gatewayManager.debouncedReload(150); void reason; } function toComparableConfig(input: Record): Record { const next: Record = {}; 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 | undefined, incoming: Record, ): 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 { 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 migrateLegacyChannelWideBinding(storedChannelType); await assignChannelAccountToAgent(accountId, storedChannelType, accountId); return; } await migrateLegacyChannelWideBinding(storedChannelType); } async function migrateLegacyChannelWideBinding(channelType: string): Promise { const explicitDefaultOwner = await readChannelBindingOwner(channelType, 'default'); const legacyOwner = await readChannelBindingOwner(channelType); if (!legacyOwner) { return; } const agents = await listAgentsSnapshot(); const validAgentIds = new Set(agents.agents.map((agent) => agent.id)); const defaultOwner = explicitDefaultOwner && validAgentIds.has(explicitDefaultOwner) ? explicitDefaultOwner : (legacyOwner && validAgentIds.has(legacyOwner) ? legacyOwner : null); if (defaultOwner) { await assignChannelAccountToAgent(defaultOwner, channelType, 'default'); } // Remove the legacy channel-wide fallback so newly added non-default // accounts do not silently inherit default-agent routing. await clearChannelBinding(channelType); } async function readChannelBindingOwner(channelType: string, accountId?: string): Promise { const config = await readOpenClawConfig(); const bindings = Array.isArray((config as { bindings?: unknown }).bindings) ? (config as { bindings: unknown[] }).bindings : []; for (const binding of bindings) { if (!binding || typeof binding !== 'object') continue; const candidate = binding as { agentId?: unknown; match?: { channel?: unknown; accountId?: unknown } | unknown; }; if (typeof candidate.agentId !== 'string' || !candidate.agentId.trim()) continue; if (!candidate.match || typeof candidate.match !== 'object' || Array.isArray(candidate.match)) continue; const match = candidate.match as { channel?: unknown; accountId?: unknown }; if (match.channel !== channelType) continue; const bindingAccountId = typeof match.accountId === 'string' ? match.accountId.trim() : ''; if ((accountId?.trim() || '') !== bindingAccountId) continue; return candidate.agentId; } return null; } interface GatewayChannelStatusPayload { channelOrder?: string[]; channels?: Record; channelAccounts?: Record>; channelDefaultAccountId?: Record; } interface ChannelAccountView { accountId: string; name: string; configured: boolean; connected: boolean; running: boolean; linked: boolean; lastError?: string; status: ChannelConnectionStatus; statusReason?: string; isDefault: boolean; agentId?: string; } interface ChannelAccountsView { channelType: string; defaultAccountId: string; status: ChannelConnectionStatus; statusReason?: string; accounts: ChannelAccountView[]; } export function getChannelStatusDiagnostics(): { lastChannelsStatusOkAt?: number; lastChannelsStatusFailureAt?: number; } { return { lastChannelsStatusOkAt, lastChannelsStatusFailureAt, }; } function gatewayHealthStateForChannels( gatewayHealthState: GatewayHealthState, ): GatewayHealthState | undefined { return gatewayHealthState === 'healthy' ? undefined : gatewayHealthState; } function overlayStatusReason( gatewayHealth: GatewayHealthSummary, fallbackReason: string, ): string { return gatewayHealth.reasons[0] || fallbackReason; } function buildGatewayStatusSnapshot(status: GatewayChannelStatusPayload | null): string { if (!status?.channelAccounts) return 'none'; const entries = Object.entries(status.channelAccounts); if (entries.length === 0) return 'empty'; return entries .slice(0, 12) .map(([channelType, accounts]) => { const channelStatus = pickChannelRuntimeStatus(accounts); const flags = accounts.slice(0, 4).map((account) => { const accountId = typeof account.accountId === 'string' ? account.accountId : 'default'; const connected = account.connected === true ? '1' : '0'; const running = account.running === true ? '1' : '0'; const linked = account.linked === true ? '1' : '0'; const probeOk = account.probe?.ok === true ? '1' : '0'; const hasErr = typeof account.lastError === 'string' && account.lastError.trim().length > 0 ? '1' : '0'; return `${accountId}[c${connected}r${running}l${linked}p${probeOk}e${hasErr}]`; }).join('|'); return `${channelType}:${channelStatus}{${flags}}`; }) .join(', '); } function shouldIncludeRuntimeAccountId( accountId: string, configuredAccountIds: Set, runtimeAccount: { configured?: boolean }, ): boolean { if (configuredAccountIds.has(accountId)) { return true; } // Defensive filtering: channels.status can occasionally expose transient // runtime rows for stale sessions. Only include runtime-only accounts when // gateway marks them as configured. return runtimeAccount.configured === true; } interface ChannelTargetOptionView { value: string; label: string; kind: 'user' | 'group' | 'channel'; } interface QQBotKnownUserRecord { openid?: string; type?: 'c2c' | 'group'; nickname?: string; groupOpenid?: string; accountId?: string; lastSeenAt?: number; interactionCount?: number; } type JsonRecord = Record; type DirectoryEntry = { kind: 'user' | 'group' | 'channel'; id: string; name?: string; handle?: string; }; const CHANNEL_TARGET_CACHE_TTL_MS = 60_000; const CHANNEL_TARGET_CACHE_ENABLED = process.env.VITEST !== 'true'; const channelTargetCache = new Map(); let lastChannelsStatusOkAt: number | undefined; let lastChannelsStatusFailureAt: number | undefined; export async function buildChannelAccountsView( ctx: HostApiContext, options?: { probe?: boolean }, ): Promise<{ channels: ChannelAccountsView[]; gatewayHealth: GatewayHealthSummary }> { const startedAt = Date.now(); // Read config once and share across all sub-calls (was 5 readFile calls before). const openClawConfig = await readOpenClawConfig(); const [configuredChannels, configuredAccounts, agentsSnapshot] = await Promise.all([ listConfiguredChannelsFromConfig(openClawConfig), Promise.resolve(listConfiguredChannelAccountsFromConfig(openClawConfig)), listAgentsSnapshotFromConfig(openClawConfig), ]); let gatewayStatus: GatewayChannelStatusPayload | null; try { // probe=false uses cached runtime state (lighter); probe=true forces // adapter-level connectivity checks for faster post-restart convergence. const probe = options?.probe === true; // 8s timeout — fail fast when Gateway is busy with AI tasks. const rpcStartedAt = Date.now(); gatewayStatus = await ctx.gatewayManager.rpc( 'channels.status', { probe }, probe ? 5000 : 8000, ); lastChannelsStatusOkAt = Date.now(); logger.info( `[channels.accounts] channels.status probe=${probe ? '1' : '0'} elapsedMs=${Date.now() - rpcStartedAt} snapshot=${buildGatewayStatusSnapshot(gatewayStatus)}` ); } catch { const probe = options?.probe === true; lastChannelsStatusFailureAt = Date.now(); logger.warn( `[channels.accounts] channels.status probe=${probe ? '1' : '0'} failed after ${Date.now() - startedAt}ms` ); gatewayStatus = null; } const gatewayDiagnostics = ctx.gatewayManager.getDiagnostics?.() ?? { consecutiveHeartbeatMisses: 0, consecutiveRpcFailures: 0, }; const gatewayHealth = buildGatewayHealthSummary({ status: ctx.gatewayManager.getStatus(), diagnostics: gatewayDiagnostics, lastChannelsStatusOkAt, lastChannelsStatusFailureAt, platform: process.platform, }); const gatewayHealthState = gatewayHealthStateForChannels(gatewayHealth.state); const channelTypes = new Set([ ...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 configuredAccountIdSet = new Set(channelAccountsFromConfig); 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.reduce((acc, account) => { const accountId = typeof account.accountId === 'string' ? account.accountId.trim() : ''; if (!accountId) { return acc; } if (!shouldIncludeRuntimeAccountId(accountId, configuredAccountIdSet, account)) { return acc; } acc.push(accountId); return acc; }, []); 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, { gatewayHealthState, }); 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, statusReason: status === 'degraded' ? overlayStatusReason(gatewayHealth, 'gateway_degraded') : status === 'error' ? 'runtime_error' : undefined, 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); }); const visibleAccountSnapshots: ChannelRuntimeAccountSnapshot[] = accounts.map((account) => ({ connected: account.connected, running: account.running, linked: account.linked, lastError: account.lastError, })); const hasRuntimeError = visibleAccountSnapshots.some((account) => typeof account.lastError === 'string' && account.lastError.trim()) || Boolean(channelSummary?.error?.trim() || channelSummary?.lastError?.trim()); const baseGroupStatus = pickChannelRuntimeStatus(visibleAccountSnapshots, channelSummary); const groupStatus = !gatewayStatus && ctx.gatewayManager.getStatus().state === 'running' ? 'degraded' : gatewayHealthState && !hasRuntimeError && baseGroupStatus === 'connected' ? 'degraded' : pickChannelRuntimeStatus(visibleAccountSnapshots, channelSummary, { gatewayHealthState, }); channels.push({ channelType: uiChannelType, defaultAccountId, status: groupStatus, statusReason: !gatewayStatus && ctx.gatewayManager.getStatus().state === 'running' ? 'channels_status_timeout' : groupStatus === 'degraded' ? overlayStatusReason(gatewayHealth, 'gateway_degraded') : undefined, accounts, }); } const sorted = channels.sort((left, right) => left.channelType.localeCompare(right.channelType)); logger.info( `[channels.accounts] response probe=${options?.probe === true ? '1' : '0'} elapsedMs=${Date.now() - startedAt} view=${sorted.map((item) => `${item.channelType}:${item.status}`).join(',')}` ); return { channels: sorted, gatewayHealth }; } function buildChannelTargetLabel(baseLabel: string, value: string): string { const trimmed = baseLabel.trim(); return trimmed && trimmed !== value ? `${trimmed} (${value})` : value; } function buildDirectoryTargetOptions( entries: DirectoryEntry[], normalizeTarget: (target: string) => string | undefined, ): ChannelTargetOptionView[] { const results: ChannelTargetOptionView[] = []; const seen = new Set(); for (const entry of entries) { const normalized = normalizeTarget(entry.id) ?? entry.id; if (!normalized || seen.has(normalized)) continue; seen.add(normalized); results.push({ value: normalized, label: buildChannelTargetLabel(entry.name || entry.handle || entry.id, normalized), kind: entry.kind, }); } return results; } function mergeChannelAccountConfig( config: JsonRecord, channelType: string, accountId?: string, ): JsonRecord { const channels = (config.channels && typeof config.channels === 'object') ? config.channels as Record : undefined; const channelSection = channels?.[channelType]; if (!channelSection || typeof channelSection !== 'object') { return {}; } const section = channelSection as JsonRecord; const resolvedAccountId = accountId?.trim() || (typeof section.defaultAccount === 'string' && section.defaultAccount.trim() ? section.defaultAccount.trim() : 'default'); const accounts = section.accounts && typeof section.accounts === 'object' ? section.accounts as Record : undefined; const accountOverride = resolvedAccountId !== 'default' && accounts?.[resolvedAccountId] && typeof accounts[resolvedAccountId] === 'object' ? accounts[resolvedAccountId] as JsonRecord : undefined; const { accounts: _ignoredAccounts, ...baseConfig } = section; return accountOverride ? { ...baseConfig, ...accountOverride } : baseConfig; } function resolveFeishuApiOrigin(domain: unknown): string { if (typeof domain === 'string' && domain.trim().toLowerCase() === 'lark') { return 'https://open.larksuite.com'; } return 'https://open.feishu.cn'; } function normalizeFeishuTargetValue(raw: unknown): string | null { if (typeof raw !== 'string') return null; const trimmed = raw.trim(); if (!trimmed || trimmed === '*') return null; if (trimmed.startsWith('chat:') || trimmed.startsWith('user:')) return trimmed; if (trimmed.startsWith('open_id:')) return `user:${trimmed.slice('open_id:'.length)}`; if (trimmed.startsWith('feishu:')) return normalizeFeishuTargetValue(trimmed.slice('feishu:'.length)); if (trimmed.startsWith('oc_')) return `chat:${trimmed}`; if (trimmed.startsWith('ou_')) return `user:${trimmed}`; if (/^[a-zA-Z0-9]+$/.test(trimmed)) return `user:${trimmed}`; return null; } function inferFeishuTargetKind(target: string): ChannelTargetOptionView['kind'] { return target.startsWith('chat:') ? 'group' : 'user'; } function buildFeishuTargetOption( value: string, label?: string, kind?: ChannelTargetOptionView['kind'], ): ChannelTargetOptionView { const normalizedLabel = typeof label === 'string' && label.trim() ? label.trim() : value; return { value, label: buildChannelTargetLabel(normalizedLabel, value), kind: kind ?? inferFeishuTargetKind(value), }; } function mergeTargetOptions(...groups: ChannelTargetOptionView[][]): ChannelTargetOptionView[] { const seen = new Set(); const results: ChannelTargetOptionView[] = []; for (const group of groups) { for (const option of group) { if (!option.value || seen.has(option.value)) continue; seen.add(option.value); results.push(option); } } return results; } function readNonEmptyString(value: unknown): string | undefined { if (typeof value !== 'string') return undefined; const trimmed = value.trim(); return trimmed ? trimmed : undefined; } function inferTargetKindFromValue( channelType: string, target: string, chatType?: string, ): ChannelTargetOptionView['kind'] { const normalizedChatType = chatType?.trim().toLowerCase(); if (normalizedChatType === 'group') return 'group'; if (normalizedChatType === 'channel') return 'channel'; if (target.startsWith('chat:') || target.includes(':group:')) return 'group'; if (target.includes(':channel:')) return 'channel'; if (channelType === 'dingtalk' && target.startsWith('cid')) return 'group'; return 'user'; } function buildChannelTargetCacheKey(params: { channelType: string; accountId?: string; query?: string; }): string { return [ resolveStoredChannelType(params.channelType), params.accountId?.trim() || '', params.query?.trim().toLowerCase() || '', ].join('::'); } async function listSessionDerivedTargetOptions(params: { channelType: string; accountId?: string; query?: string; }): Promise { const storedChannelType = resolveStoredChannelType(params.channelType); const agentsDir = join(getOpenClawConfigDir(), 'agents'); const agentDirs = await readdir(agentsDir, { withFileTypes: true }).catch(() => []); const q = params.query?.trim().toLowerCase() || ''; const candidates: Array = []; const seen = new Set(); for (const entry of agentDirs) { if (!entry.isDirectory()) continue; const sessionsPath = join(agentsDir, entry.name, 'sessions', 'sessions.json'); const raw = await readFile(sessionsPath, 'utf8').catch(() => ''); if (!raw.trim()) continue; let parsed: JsonRecord; try { parsed = JSON.parse(raw) as JsonRecord; } catch { continue; } for (const session of extractSessionRecords(parsed)) { const deliveryContext = session.deliveryContext && typeof session.deliveryContext === 'object' ? session.deliveryContext as JsonRecord : undefined; const origin = session.origin && typeof session.origin === 'object' ? session.origin as JsonRecord : undefined; const sessionChannelType = readNonEmptyString(deliveryContext?.channel) || readNonEmptyString(session.lastChannel) || readNonEmptyString(session.channel) || readNonEmptyString(origin?.provider) || readNonEmptyString(origin?.surface); if (!sessionChannelType || resolveStoredChannelType(sessionChannelType) !== storedChannelType) { continue; } const sessionAccountId = readNonEmptyString(deliveryContext?.accountId) || readNonEmptyString(session.lastAccountId) || readNonEmptyString(origin?.accountId); if (params.accountId && sessionAccountId && sessionAccountId !== params.accountId) { continue; } if (params.accountId && !sessionAccountId) { continue; } const value = readNonEmptyString(deliveryContext?.to) || readNonEmptyString(session.lastTo) || readNonEmptyString(origin?.to); if (!value || seen.has(value)) continue; const labelBase = readNonEmptyString(session.displayName) || readNonEmptyString(session.subject) || readNonEmptyString(origin?.label) || value; const label = buildChannelTargetLabel(labelBase, value); if (q && !label.toLowerCase().includes(q) && !value.toLowerCase().includes(q)) { continue; } seen.add(value); candidates.push({ value, label, kind: inferTargetKindFromValue( storedChannelType, value, readNonEmptyString(session.chatType) || readNonEmptyString(origin?.chatType), ), updatedAt: typeof session.updatedAt === 'number' ? session.updatedAt : 0, }); } } return candidates .sort((left, right) => right.updatedAt - left.updatedAt || left.label.localeCompare(right.label)) .map(({ updatedAt: _updatedAt, ...option }) => option); } async function listWeComReqIdTargetOptions(accountId?: string, query?: string): Promise { const wecomDir = join(getOpenClawConfigDir(), 'wecom'); const files = await readdir(wecomDir, { withFileTypes: true }).catch(() => []); const q = query?.trim().toLowerCase() || ''; const options: ChannelTargetOptionView[] = []; const seen = new Set(); for (const file of files) { if (!file.isFile() || !file.name.startsWith('reqid-map-') || !file.name.endsWith('.json')) { continue; } const resolvedAccountId = file.name.slice('reqid-map-'.length, -'.json'.length); if (accountId && resolvedAccountId !== accountId) { continue; } const raw = await readFile(join(wecomDir, file.name), 'utf8').catch(() => ''); if (!raw.trim()) continue; let records: Record; try { records = JSON.parse(raw) as Record; } catch { continue; } for (const chatId of Object.keys(records)) { const trimmedChatId = chatId.trim(); if (!trimmedChatId) continue; const value = `wecom:${trimmedChatId}`; const label = buildChannelTargetLabel('WeCom chat', value); if (q && !label.toLowerCase().includes(q) && !value.toLowerCase().includes(q)) { continue; } if (seen.has(value)) continue; seen.add(value); options.push({ value, label, kind: 'channel' }); } } return options; } async function fetchFeishuTargetOptions(accountId?: string, query?: string): Promise { const config = await readOpenClawConfig() as JsonRecord; const accountConfig = mergeChannelAccountConfig(config, 'feishu', accountId); const appId = typeof accountConfig.appId === 'string' ? accountConfig.appId.trim() : ''; const appSecret = typeof accountConfig.appSecret === 'string' ? accountConfig.appSecret.trim() : ''; if (!appId || !appSecret) { return []; } const q = query?.trim().toLowerCase() || ''; const configuredTargets: ChannelTargetOptionView[] = []; const pushIfMatches = (value: string | null, label?: string, kind?: ChannelTargetOptionView['kind']) => { if (!value) return; const option = buildFeishuTargetOption(value, label, kind); if (q && !option.label.toLowerCase().includes(q) && !option.value.toLowerCase().includes(q)) return; configuredTargets.push(option); }; const allowFrom = Array.isArray(accountConfig.allowFrom) ? accountConfig.allowFrom : []; for (const entry of allowFrom) { pushIfMatches(normalizeFeishuTargetValue(entry)); } const dms = accountConfig.dms && typeof accountConfig.dms === 'object' ? accountConfig.dms as Record : undefined; if (dms) { for (const userId of Object.keys(dms)) { pushIfMatches(normalizeFeishuTargetValue(userId)); } } const groups = accountConfig.groups && typeof accountConfig.groups === 'object' ? accountConfig.groups as Record : undefined; if (groups) { for (const groupId of Object.keys(groups)) { pushIfMatches(normalizeFeishuTargetValue(groupId)); } } const origin = resolveFeishuApiOrigin(accountConfig.domain); const tokenResponse = await proxyAwareFetch(`${origin}/open-apis/auth/v3/tenant_access_token/internal`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ app_id: appId, app_secret: appSecret, }), }); const tokenPayload = await tokenResponse.json() as { code?: number; msg?: string; tenant_access_token?: string; }; if (!tokenResponse.ok || tokenPayload.code !== 0 || !tokenPayload.tenant_access_token) { return configuredTargets; } const headers = { Authorization: `Bearer ${tokenPayload.tenant_access_token}`, }; const liveTargets: ChannelTargetOptionView[] = []; try { const appResponse = await proxyAwareFetch(`${origin}/open-apis/application/v6/applications/${appId}?lang=zh_cn`, { headers, }); const appPayload = await appResponse.json() as { code?: number; data?: { app?: JsonRecord } & JsonRecord; app?: JsonRecord; }; if (appResponse.ok && appPayload.code === 0) { const app = (appPayload.data?.app ?? appPayload.app ?? appPayload.data) as JsonRecord | undefined; const owner = (app?.owner && typeof app.owner === 'object') ? app.owner as JsonRecord : undefined; const ownerType = owner?.owner_type ?? owner?.type; const ownerOpenId = typeof owner?.owner_id === 'string' ? owner.owner_id.trim() : ''; const creatorId = typeof app?.creator_id === 'string' ? app.creator_id.trim() : ''; const effectiveOwnerOpenId = ownerType === 2 && ownerOpenId ? ownerOpenId : (creatorId || ownerOpenId); pushIfMatches(effectiveOwnerOpenId ? `user:${effectiveOwnerOpenId}` : null, 'App Owner', 'user'); } } catch { // ignore } try { const userResponse = await proxyAwareFetch(`${origin}/open-apis/contact/v3/users?page_size=100`, { headers }); const userPayload = await userResponse.json() as { code?: number; data?: { items?: Array<{ open_id?: string; name?: string }> }; }; if (userResponse.ok && userPayload.code === 0) { for (const item of userPayload.data?.items ?? []) { const value = normalizeFeishuTargetValue(item.open_id); if (!value) continue; const option = buildFeishuTargetOption(value, item.name, 'user'); if (q && !option.label.toLowerCase().includes(q) && !option.value.toLowerCase().includes(q)) continue; liveTargets.push(option); } } } catch { // ignore } try { const chatResponse = await proxyAwareFetch(`${origin}/open-apis/im/v1/chats?page_size=100`, { headers }); const chatPayload = await chatResponse.json() as { code?: number; data?: { items?: Array<{ chat_id?: string; name?: string }> }; }; if (chatResponse.ok && chatPayload.code === 0) { for (const item of chatPayload.data?.items ?? []) { const value = normalizeFeishuTargetValue(item.chat_id); if (!value) continue; const option = buildFeishuTargetOption(value, item.name, 'group'); if (q && !option.label.toLowerCase().includes(q) && !option.value.toLowerCase().includes(q)) continue; liveTargets.push(option); } } } catch { // ignore } return mergeTargetOptions(configuredTargets, liveTargets); } async function listQQBotKnownTargetOptions(accountId?: string, query?: string): Promise { const knownUsersPath = join(getOpenClawConfigDir(), 'qqbot', 'data', 'known-users.json'); const raw = await readFile(knownUsersPath, 'utf8').catch(() => ''); if (!raw.trim()) return []; let records: QQBotKnownUserRecord[]; try { records = JSON.parse(raw) as QQBotKnownUserRecord[]; } catch { return []; } const q = query?.trim().toLowerCase() || ''; const options: ChannelTargetOptionView[] = []; const seen = new Set(); const filtered = records .filter((record) => !accountId || record.accountId === accountId) .sort((left, right) => (right.lastSeenAt ?? 0) - (left.lastSeenAt ?? 0)); for (const record of filtered) { if (record.type === 'group') { const groupId = (record.groupOpenid || record.openid || '').trim(); if (!groupId) continue; const value = `qqbot:group:${groupId}`; const label = buildChannelTargetLabel(record.nickname || groupId, value); if (q && !label.toLowerCase().includes(q) && !value.toLowerCase().includes(q)) continue; if (seen.has(value)) continue; seen.add(value); options.push({ value, label, kind: 'group' }); continue; } const userId = (record.openid || '').trim(); if (!userId) continue; const value = `qqbot:c2c:${userId}`; const label = buildChannelTargetLabel(record.nickname || userId, value); if (q && !label.toLowerCase().includes(q) && !value.toLowerCase().includes(q)) continue; if (seen.has(value)) continue; seen.add(value); options.push({ value, label, kind: 'user' }); } return options; } async function listWeComTargetOptions(accountId?: string, query?: string): Promise { const [reqIdTargets, sessionTargets] = await Promise.all([ listWeComReqIdTargetOptions(accountId, query), listSessionDerivedTargetOptions({ channelType: 'wecom', accountId, query }), ]); return mergeTargetOptions(sessionTargets, reqIdTargets); } async function listDingTalkTargetOptions(accountId?: string, query?: string): Promise { return await listSessionDerivedTargetOptions({ channelType: 'dingtalk', accountId, query }); } async function listWeChatTargetOptions(accountId?: string, query?: string): Promise { return await listSessionDerivedTargetOptions({ channelType: OPENCLAW_WECHAT_CHANNEL_TYPE, accountId, query }); } async function listConfigDirectoryTargetOptions(params: { channelType: 'discord' | 'telegram' | 'slack' | 'whatsapp'; accountId?: string; query?: string; }): Promise { const cfg = await readOpenClawConfig(); const commonParams = { cfg, accountId: params.accountId ?? null, query: params.query ?? null, limit: 100, }; if (params.channelType === 'discord') { const [users, groups] = await Promise.all([ listDiscordDirectoryPeersFromConfig(commonParams), listDiscordDirectoryGroupsFromConfig(commonParams), ]); return buildDirectoryTargetOptions( [...users, ...groups] as DirectoryEntry[], normalizeDiscordMessagingTarget, ); } if (params.channelType === 'telegram') { const [users, groups] = await Promise.all([ listTelegramDirectoryPeersFromConfig(commonParams), listTelegramDirectoryGroupsFromConfig(commonParams), ]); return buildDirectoryTargetOptions( [...users, ...groups] as DirectoryEntry[], normalizeTelegramMessagingTarget, ); } if (params.channelType === 'slack') { const [users, groups] = await Promise.all([ listSlackDirectoryPeersFromConfig(commonParams), listSlackDirectoryGroupsFromConfig(commonParams), ]); return buildDirectoryTargetOptions( [...users, ...groups] as DirectoryEntry[], normalizeSlackMessagingTarget, ); } const [users, groups] = await Promise.all([ listWhatsAppDirectoryPeersFromConfig(commonParams), listWhatsAppDirectoryGroupsFromConfig(commonParams), ]); return buildDirectoryTargetOptions( [...users, ...groups] as DirectoryEntry[], normalizeWhatsAppMessagingTarget, ); } async function listChannelTargetOptions(params: { channelType: string; accountId?: string; query?: string; }): Promise { const storedChannelType = resolveStoredChannelType(params.channelType); const cacheKey = buildChannelTargetCacheKey(params); if (CHANNEL_TARGET_CACHE_ENABLED) { const cached = channelTargetCache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) { return cached.targets; } if (cached) { channelTargetCache.delete(cacheKey); } } const targets = await (async (): Promise => { if (storedChannelType === 'feishu') { const [feishuTargets, sessionTargets] = await Promise.all([ fetchFeishuTargetOptions(params.accountId, params.query), listSessionDerivedTargetOptions(params), ]); return mergeTargetOptions(feishuTargets, sessionTargets); } if (storedChannelType === 'qqbot') { const [knownTargets, sessionTargets] = await Promise.all([ listQQBotKnownTargetOptions(params.accountId, params.query), listSessionDerivedTargetOptions(params), ]); return mergeTargetOptions(knownTargets, sessionTargets); } if (storedChannelType === 'wecom') { return await listWeComTargetOptions(params.accountId, params.query); } if (storedChannelType === 'dingtalk') { return await listDingTalkTargetOptions(params.accountId, params.query); } if (storedChannelType === OPENCLAW_WECHAT_CHANNEL_TYPE) { return await listWeChatTargetOptions(params.accountId, params.query); } if ( storedChannelType === 'discord' || storedChannelType === 'telegram' || storedChannelType === 'slack' || storedChannelType === 'whatsapp' ) { const [directoryTargets, sessionTargets] = await Promise.all([ listConfigDirectoryTargetOptions({ channelType: storedChannelType, accountId: params.accountId, query: params.query, }), listSessionDerivedTargetOptions(params), ]); return mergeTargetOptions(directoryTargets, sessionTargets); } return await listSessionDerivedTargetOptions(params); })(); if (CHANNEL_TARGET_CACHE_ENABLED) { channelTargetCache.set(cacheKey, { expiresAt: Date.now() + CHANNEL_TARGET_CACHE_TTL_MS, targets, }); } return targets; } export async function handleChannelRoutes( req: IncomingMessage, res: ServerResponse, url: URL, ctx: HostApiContext, ): Promise { 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 probe = url.searchParams.get('probe') === '1'; logger.info(`[channels.accounts] request probe=${probe ? '1' : '0'}`); const { channels, gatewayHealth } = await buildChannelAccountsView(ctx, { probe }); sendJson(res, 200, { success: true, channels, gatewayHealth }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); } return true; } if (url.pathname === '/api/channels/targets' && req.method === 'GET') { try { const channelType = url.searchParams.get('channelType')?.trim() || ''; const accountId = url.searchParams.get('accountId')?.trim() || undefined; const query = url.searchParams.get('query')?.trim() || undefined; if (!channelType) { sendJson(res, 400, { success: false, error: 'channelType is required' }); return true; } const targets = await listChannelTargetOptions({ channelType, accountId, query }); sendJson(res, 200, { success: true, channelType, accountId, targets }); } 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); const validAccountId = await validateAccountIdOrReply(res, body.channelType, body.accountId); if (!validAccountId) { return true; } 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); const validAccountId = await validateAccountIdOrReply(res, body.channelType, body.accountId, { required: true }); if (!validAccountId) { return true; } const agents = await listAgentsSnapshot(); if (!agents.agents.some((entry) => entry.id === body.agentId)) { throw new Error(`Agent "${body.agentId}" not found`); } const storedChannelType = resolveStoredChannelType(body.channelType); if (body.accountId !== 'default') { await migrateLegacyChannelWideBinding(storedChannelType); } await assignChannelAccountToAgent(body.agentId, storedChannelType, 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); const validAccountId = await validateAccountIdOrReply(res, body.channelType, body.accountId); if (!validAccountId) { return true; } 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 }>(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; accountId?: string }>(req); const validAccountId = await validateAccountIdOrReply(res, body.channelType, body.accountId); if (!validAccountId) { return true; } 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; } } // QQBot is a built-in channel since OpenClaw 3.31 — no plugin install needed 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; }