fix(cc-connect): mirror channel platforms

This commit is contained in:
zuolingxuan
2026-06-08 21:20:19 +08:00
parent 4de96d75bc
commit 788572ab5a
7 changed files with 527 additions and 70 deletions
+1 -1
View File
@@ -99,7 +99,7 @@ ClawX には runtime 抽象レイヤーもあります。OpenClaw は既定 runt
cc-connect mode では、Codex provider sync は OpenAI API key、OpenAI OAuth/Codex、Ollama、および Responses API を公開する OpenAI-compatible Custom provider をサポートします。ByteDance ModelHub-compatible Custom provider は Codex が使う `/api/modelhub/online` Responses endpoint に正規化され、管理 config には環境変数形式の header 参照だけを書き込むため、secret や sticky-session header は永続化されません。Chat Completions として設定された Custom provider は、Codex 0.137 が Responses wire API のみを受け付けるため、chat 配信前に unsupported として報告されます。
cc-connect はメッセージング platform bridge も担当します。cc-connect が active runtime の場合、channel status probe は OpenClaw Gateway に固定せず runtime abstraction 経由でルーティングされ、Developer Mode のサイドバーのページショートカットは cc-connect Web Admin を開きます。
cc-connect はメッセージング platform bridge も担当します。cc-connect が active runtime の場合、channel status probe は OpenClaw Gateway に固定せず runtime abstraction 経由でルーティングされ、設定済み channel account は cc-connect project platform blocks にミラーされます。channel の保存や削除では管理 cc-connect runtime を再起動し、platform 変更を反映します。Developer Mode のサイドバーのページショートカットは cc-connect Web Admin を開きます。
---
+1 -1
View File
@@ -99,7 +99,7 @@ ClawX also includes a runtime abstraction layer. OpenClaw remains the default ru
In cc-connect mode, Codex provider sync supports OpenAI API key, OpenAI OAuth/Codex, Ollama, and Custom OpenAI-compatible providers that expose the Responses API. ByteDance ModelHub-compatible Custom providers are normalized to Codex's `/api/modelhub/online` Responses endpoint and written with environment-variable header references so secrets and sticky-session headers are not persisted in managed config files. Custom providers configured for Chat Completions are reported as unsupported before chat delivery because Codex 0.137 accepts only the Responses wire API.
cc-connect also owns messaging platform bridges. When cc-connect is the active runtime, channel status probes are routed through the runtime abstraction instead of the OpenClaw Gateway, and the Developer Mode sidebar page shortcut opens cc-connect Web Admin.
cc-connect also owns messaging platform bridges. When cc-connect is the active runtime, channel status probes are routed through the runtime abstraction instead of the OpenClaw Gateway, configured channel accounts are mirrored into cc-connect project platform blocks, and channel saves/deletes restart the managed cc-connect runtime so platform changes take effect. The Developer Mode sidebar page shortcut opens cc-connect Web Admin.
---
+1 -1
View File
@@ -100,7 +100,7 @@ ClawX 现在也包含 runtime 抽象层。OpenClaw 仍是默认 runtime 和回
在 cc-connect 模式下,Codex provider 同步支持 OpenAI API Key、OpenAI OAuth/Codex、Ollama,以及暴露 Responses API 的 OpenAI-compatible Custom provider。ByteDance ModelHub-compatible Custom provider 会被规范化到 Codex 使用的 `/api/modelhub/online` Responses endpoint,并且托管配置中只写入环境变量形式的 header 引用,避免持久化密钥或 sticky-session header。配置为 Chat Completions 的 Custom provider 会在 chat 投递前被明确标记为不支持,因为 Codex 0.137 只接受 Responses wire API。
cc-connect 也负责消息平台桥接。当 cc-connect 是当前 runtime 时,频道状态探测会通过 runtime 抽象层路由,而不是继续固定查询 OpenClaw Gateway;开发者模式侧边栏的页面入口会打开 cc-connect Web Admin。
cc-connect 也负责消息平台桥接。当 cc-connect 是当前 runtime 时,频道状态探测会通过 runtime 抽象层路由,而不是继续固定查询 OpenClaw Gateway已配置的频道账号会同步为 cc-connect project platform blocks,频道保存/删除会重启托管 cc-connect runtime,让 platform 变更立即生效;开发者模式侧边栏的页面入口会打开 cc-connect Web Admin。
---
+359 -37
View File
@@ -33,6 +33,7 @@ import {
toPublicCodexProviderProfile,
type CodexProviderProfile,
} from './cc-connect-provider-profile';
import { readOpenClawConfig, type ChannelConfigData, type OpenClawConfig } from '../utils/channel-config';
type CcConnectRuntimeProviderOptions = {
binaryPath?: string;
@@ -50,6 +51,27 @@ const MAX_DOCTOR_OUTPUT_BYTES = 10 * 1024 * 1024;
const CLAWX_PROJECT_NAME = 'clawx-main';
const CC_CONNECT_BRIDGE_PORT = 9810;
const CLAWX_LOCAL_PLACEHOLDER_SECRET = 'clawx-local-placeholder';
const CC_CONNECT_SUPPORTED_CHANNELS = new Set([
'dingtalk',
'discord',
'feishu',
'lark',
'line',
'qq',
'qqbot',
'slack',
'telegram',
'wecom',
'weixin',
]);
type CcConnectChannelPlatform = {
channelType: string;
accountId: string;
platformType: string;
options: Record<string, string | number | boolean>;
error?: string;
};
function unsupported(method: string): never {
throw new Error(`cc-connect runtime does not support RPC method: ${method}`);
@@ -95,6 +117,7 @@ function defaultConfig(options: {
providerProfile?: CodexProviderProfile | null;
managementToken: string;
bridgeToken: string;
channelPlatforms?: CcConnectChannelPlatform[];
}): string {
const managedDir = getCcConnectManagedDir();
const dataDir = join(managedDir, 'data').replace(/\\/g, '\\\\');
@@ -135,6 +158,7 @@ function defaultConfig(options: {
...(model ? [`model = "${escapeToml(model)}"`] : []),
...ccConnectProviderConfig(options.providerProfile),
'',
...ccConnectPlatformConfig(options.channelPlatforms ?? []),
'# cc-connect requires at least one project platform before the bridge can start.',
'# ClawX GUI traffic is delivered by the local [bridge] adapter above; this LINE webhook',
'# placeholder listens only on an ephemeral local port and is filtered from channel status.',
@@ -358,10 +382,12 @@ export class CcConnectRuntimeProvider extends EventEmitter implements RuntimePro
running: boolean;
linked: boolean;
name: string;
lastError?: string;
}>>;
channelDefaultAccountId: Record<string, string>;
}> {
const configuredTypes = await this.listConfiguredPlatformTypes();
const openClawConfig = await readOpenClawConfig().catch(() => ({} as OpenClawConfig));
const configuredPlatforms = collectCcConnectChannelPlatforms(openClawConfig);
const running = this.status.state === 'running';
const channels: Record<string, { configured: boolean; running: boolean }> = {};
const channelAccounts: Record<string, Array<{
@@ -371,20 +397,27 @@ export class CcConnectRuntimeProvider extends EventEmitter implements RuntimePro
running: boolean;
linked: boolean;
name: string;
lastError?: string;
}>> = {};
const channelDefaultAccountId: Record<string, string> = {};
for (const channelType of configuredTypes) {
channels[channelType] = { configured: true, running };
channelAccounts[channelType] = [{
accountId: 'default',
for (const platform of configuredPlatforms) {
channels[platform.channelType] = { configured: true, running: running && !platform.error };
const accounts = channelAccounts[platform.channelType] ?? [];
accounts.push({
accountId: platform.accountId,
configured: true,
connected: running,
running,
linked: true,
name: channelType,
}];
channelDefaultAccountId[channelType] = 'default';
connected: running && !platform.error,
running: running && !platform.error,
linked: !platform.error,
name: platform.platformType,
...(platform.error ? { lastError: platform.error } : {}),
});
channelAccounts[platform.channelType] = accounts;
channelDefaultAccountId[platform.channelType] = getDefaultChannelAccountId(
openClawConfig,
platform.channelType,
);
}
return { channels, channelAccounts, channelDefaultAccountId };
@@ -401,7 +434,7 @@ export class CcConnectRuntimeProvider extends EventEmitter implements RuntimePro
`[cc-connect] providerProfile=${getCcConnectProviderProfilePath()}`,
`[codex] sessions=${this.codexBridge.getSessionsDir()}`,
'',
content,
redactCcConnectConfigForLogs(content),
].join('\n'),
};
}
@@ -515,11 +548,13 @@ export class CcConnectRuntimeProvider extends EventEmitter implements RuntimePro
private async ensureManagedConfig(providerProfile: CodexProviderProfile | null, codexPath: string): Promise<string> {
const configPath = getCcConnectConfigPath();
await mkdir(dirname(configPath), { recursive: true });
const openClawConfig = await readOpenClawConfig().catch(() => ({} as OpenClawConfig));
await writeFile(configPath, defaultConfig({
codexPath,
providerProfile,
managementToken: this.managementToken,
bridgeToken: this.bridgeToken,
channelPlatforms: collectCcConnectChannelPlatforms(openClawConfig).filter((platform) => !platform.error),
}), 'utf8');
return configPath;
}
@@ -572,23 +607,6 @@ export class CcConnectRuntimeProvider extends EventEmitter implements RuntimePro
return jobs.map((job) => transformCcConnectCronJob(job));
}
private async listConfiguredPlatformTypes(): Promise<string[]> {
const configPath = getCcConnectConfigPath();
const content = existsSync(configPath)
? await readFile(configPath, 'utf8').catch(() => '')
: '';
if (!content.trim()) return [];
const platformTypes = new Set<string>();
for (const block of content.split(/\[\[projects\.platforms\]\]/g).slice(1)) {
if (isClawxLocalPlaceholderPlatform(block)) continue;
const match = block.match(/^\s*type\s*=\s*"([^"]+)"/m);
const channelType = match?.[1]?.trim();
if (channelType) platformTypes.add(channelType);
}
return [...platformTypes].sort();
}
private async createCronJob(payload: unknown): Promise<CronJob> {
const input = isRecord(payload) ? payload as unknown as CronJobCreateInput : {} as CronJobCreateInput;
const schedule = cronExprFromInput(input.schedule);
@@ -694,6 +712,318 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function tomlValue(value: string | number | boolean): string {
if (typeof value === 'boolean') return value ? 'true' : 'false';
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
return `"${escapeToml(String(value))}"`;
}
function ccConnectPlatformConfig(platforms: CcConnectChannelPlatform[]): string[] {
return platforms.flatMap((platform) => [
'[[projects.platforms]]',
`type = "${escapeToml(platform.platformType)}"`,
'',
'[projects.platforms.options]',
...Object.entries(platform.options).map(([key, value]) => `${key} = ${tomlValue(value)}`),
'',
]);
}
function collectCcConnectChannelPlatforms(config: OpenClawConfig): CcConnectChannelPlatform[] {
const channels = config.channels;
if (!channels || typeof channels !== 'object') return [];
const platforms: CcConnectChannelPlatform[] = [];
for (const [channelType, section] of Object.entries(channels)) {
if (!section || section.enabled === false) continue;
const accounts = getCcConnectChannelAccounts(section);
for (const [accountId, accountConfig] of accounts) {
platforms.push(buildCcConnectChannelPlatform(channelType, accountId, accountConfig));
}
}
return platforms.sort((left, right) =>
left.channelType.localeCompare(right.channelType) || left.accountId.localeCompare(right.accountId)
);
}
function getCcConnectChannelAccounts(section: ChannelConfigData): Array<[string, ChannelConfigData]> {
const accounts = isRecord(section.accounts) ? section.accounts as Record<string, ChannelConfigData> : undefined;
if (accounts && Object.keys(accounts).length > 0) {
return Object.entries(accounts)
.filter(([, account]) => account && account.enabled !== false);
}
const legacyAccount: ChannelConfigData = {};
for (const [key, value] of Object.entries(section)) {
if (key === 'accounts' || key === 'defaultAccount' || key === 'enabled') continue;
legacyAccount[key] = value;
}
return Object.keys(legacyAccount).length > 0 && section.enabled !== false
? [['default', legacyAccount]]
: [];
}
function getDefaultChannelAccountId(config: OpenClawConfig, channelType: string): string {
const section = config.channels?.[channelType];
if (section && typeof section.defaultAccount === 'string' && section.defaultAccount.trim()) {
return section.defaultAccount.trim();
}
const firstAccount = section ? getCcConnectChannelAccounts(section)[0]?.[0] : undefined;
return firstAccount ?? 'default';
}
function buildCcConnectChannelPlatform(
channelType: string,
accountId: string,
accountConfig: ChannelConfigData,
): CcConnectChannelPlatform {
const platformType = resolveCcConnectPlatformType(channelType, accountConfig);
if (!CC_CONNECT_SUPPORTED_CHANNELS.has(platformType)) {
return {
channelType,
accountId,
platformType,
options: {},
error: `cc-connect does not support channel "${channelType}" yet`,
};
}
const options = mapCcConnectPlatformOptions(platformType, accountConfig);
const missing = getMissingRequiredOptions(platformType, options);
return {
channelType,
accountId,
platformType,
options,
...(missing.length > 0 ? { error: `Missing cc-connect channel option(s): ${missing.join(', ')}` } : {}),
};
}
function resolveCcConnectPlatformType(channelType: string, accountConfig: ChannelConfigData): string {
if (channelType === 'openclaw-weixin' || channelType === 'wechat') return 'weixin';
if (channelType === 'feishu' && isLarkAccount(accountConfig)) return 'lark';
return channelType;
}
function isLarkAccount(accountConfig: ChannelConfigData): boolean {
const domain = getStringOption(accountConfig, 'domain');
return Boolean(domain && (domain.toLowerCase() === 'lark' || domain.includes('larksuite.com')));
}
function getStringOption(record: Record<string, unknown>, ...keys: string[]): string | undefined {
for (const key of keys) {
const value = record[key];
if (typeof value === 'string' && value.trim()) return value.trim();
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
}
return undefined;
}
function getBooleanOption(record: Record<string, unknown>, ...keys: string[]): boolean | undefined {
for (const key of keys) {
if (typeof record[key] === 'boolean') return record[key] as boolean;
}
return undefined;
}
function getAllowFromOption(record: Record<string, unknown>): string | undefined {
const value = record.allowFrom ?? record.allow_from;
if (Array.isArray(value)) {
const entries = value.map((item) => typeof item === 'string' ? item.trim() : '').filter(Boolean);
return entries.length > 0 ? entries.join(',') : undefined;
}
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
function setStringOption(
target: Record<string, string | number | boolean>,
targetKey: string,
source: Record<string, unknown>,
...sourceKeys: string[]
): void {
const value = getStringOption(source, ...sourceKeys);
if (value) target[targetKey] = value;
}
function setBooleanOption(
target: Record<string, string | number | boolean>,
targetKey: string,
source: Record<string, unknown>,
...sourceKeys: string[]
): void {
const value = getBooleanOption(source, ...sourceKeys);
if (value !== undefined) target[targetKey] = value;
}
function setAllowFromOption(
target: Record<string, string | number | boolean>,
source: Record<string, unknown>,
): void {
const value = getAllowFromOption(source);
if (value) target.allow_from = value;
}
function setCommonSessionOptions(
target: Record<string, string | number | boolean>,
source: Record<string, unknown>,
): void {
setAllowFromOption(target, source);
setBooleanOption(target, 'share_session_in_channel', source, 'shareSessionInChannel', 'share_session_in_channel');
}
function mapCcConnectPlatformOptions(
platformType: string,
accountConfig: ChannelConfigData,
): Record<string, string | number | boolean> {
const options: Record<string, string | number | boolean> = {};
switch (platformType) {
case 'feishu':
case 'lark':
setStringOption(options, 'app_id', accountConfig, 'appId', 'app_id');
setStringOption(options, 'app_secret', accountConfig, 'appSecret', 'app_secret');
setFeishuDomainOption(options, platformType, accountConfig);
setBooleanOption(options, 'enable_feishu_card', accountConfig, 'enableFeishuCard', 'enable_feishu_card');
setCommonSessionOptions(options, accountConfig);
break;
case 'dingtalk':
setStringOption(options, 'client_id', accountConfig, 'clientId', 'client_id');
setStringOption(options, 'client_secret', accountConfig, 'clientSecret', 'client_secret');
setCommonSessionOptions(options, accountConfig);
break;
case 'telegram':
setStringOption(options, 'token', accountConfig, 'token', 'botToken', 'bot_token');
setCommonSessionOptions(options, accountConfig);
break;
case 'slack':
setStringOption(options, 'bot_token', accountConfig, 'botToken', 'bot_token');
setStringOption(options, 'app_token', accountConfig, 'appToken', 'app_token');
setCommonSessionOptions(options, accountConfig);
break;
case 'discord':
setStringOption(options, 'token', accountConfig, 'token', 'botToken', 'bot_token');
setStringOption(options, 'guild_id', accountConfig, 'guildId', 'guild_id');
setStringOption(options, 'channel_id', accountConfig, 'channelId', 'channel_id');
setDiscordGuildOptions(options, accountConfig);
setBooleanOption(options, 'group_reply_all', accountConfig, 'groupReplyAll', 'group_reply_all');
setCommonSessionOptions(options, accountConfig);
break;
case 'line':
setStringOption(options, 'channel_secret', accountConfig, 'channelSecret', 'channel_secret');
setStringOption(options, 'channel_token', accountConfig, 'channelToken', 'channel_token');
setStringOption(options, 'port', accountConfig, 'port');
setStringOption(options, 'callback_path', accountConfig, 'callbackPath', 'callback_path');
break;
case 'wecom':
setWeComOptions(options, accountConfig);
setCommonSessionOptions(options, accountConfig);
break;
case 'weixin':
setStringOption(options, 'token', accountConfig, 'token', 'botToken', 'bot_token');
setStringOption(options, 'base_url', accountConfig, 'baseUrl', 'base_url');
setStringOption(options, 'cdn_base_url', accountConfig, 'cdnBaseUrl', 'cdn_base_url');
setCommonSessionOptions(options, accountConfig);
break;
case 'qq':
setStringOption(options, 'ws_url', accountConfig, 'wsUrl', 'ws_url');
setStringOption(options, 'token', accountConfig, 'token');
setCommonSessionOptions(options, accountConfig);
break;
case 'qqbot':
setStringOption(options, 'app_id', accountConfig, 'appId', 'app_id');
setStringOption(options, 'app_secret', accountConfig, 'appSecret', 'app_secret');
setBooleanOption(options, 'sandbox', accountConfig, 'sandbox');
setCommonSessionOptions(options, accountConfig);
break;
default:
break;
}
return options;
}
function setFeishuDomainOption(
target: Record<string, string | number | boolean>,
platformType: string,
source: Record<string, unknown>,
): void {
const domain = getStringOption(source, 'domain');
if (!domain) return;
if (domain.toLowerCase() === 'lark') {
target.domain = 'https://open.larksuite.com';
return;
}
if (domain.toLowerCase() === 'feishu') {
target.domain = 'https://open.feishu.cn';
return;
}
target.domain = domain;
if (platformType === 'lark' && !domain.includes('larksuite.com')) {
target.domain = 'https://open.larksuite.com';
}
}
function setDiscordGuildOptions(
target: Record<string, string | number | boolean>,
source: Record<string, unknown>,
): void {
if (target.guild_id) return;
if (!isRecord(source.guilds)) return;
const guildId = Object.keys(source.guilds)[0];
if (!guildId) return;
target.guild_id = guildId;
const guild = source.guilds[guildId];
if (!isRecord(guild) || !isRecord(guild.channels)) return;
const channelId = Object.keys(guild.channels).find((id) => id !== '*');
if (channelId) target.channel_id = channelId;
}
function setWeComOptions(
target: Record<string, string | number | boolean>,
source: Record<string, unknown>,
): void {
setStringOption(target, 'mode', source, 'mode');
setStringOption(target, 'bot_id', source, 'botId', 'bot_id');
setStringOption(target, 'bot_secret', source, 'botSecret', 'bot_secret');
setStringOption(target, 'corp_id', source, 'corpId', 'corp_id');
setStringOption(target, 'corp_secret', source, 'corpSecret', 'corp_secret');
setStringOption(target, 'agent_id', source, 'agentId', 'agent_id');
setStringOption(target, 'callback_token', source, 'callbackToken', 'callback_token');
setStringOption(target, 'callback_aes_key', source, 'callbackAesKey', 'callback_aes_key');
setStringOption(target, 'port', source, 'port');
setStringOption(target, 'callback_path', source, 'callbackPath', 'callback_path');
if (!target.mode && target.bot_id && target.bot_secret) {
target.mode = 'websocket';
}
}
function getMissingRequiredOptions(
platformType: string,
options: Record<string, string | number | boolean>,
): string[] {
const requiredByPlatform: Record<string, string[]> = {
dingtalk: ['client_id', 'client_secret'],
discord: ['token'],
feishu: ['app_id', 'app_secret'],
lark: ['app_id', 'app_secret'],
line: ['channel_secret', 'channel_token'],
qq: ['ws_url'],
qqbot: ['app_id', 'app_secret'],
slack: ['bot_token', 'app_token'],
telegram: ['token'],
weixin: ['token'],
};
if (platformType === 'wecom') {
const websocketReady = Boolean(options.bot_id && options.bot_secret);
const webhookReady = Boolean(options.corp_id && options.corp_secret && options.agent_id);
return websocketReady || webhookReady ? [] : ['bot_id/bot_secret or corp_id/corp_secret/agent_id'];
}
return (requiredByPlatform[platformType] ?? []).filter((key) => !options[key]);
}
function redactCcConnectConfigForLogs(content: string): string {
const sensitiveKeyPattern = /^(?<prefix>\s*(?:api_key|app_id|app_secret|app_token|bot_id|bot_secret|bot_token|callback_aes_key|callback_token|channel_secret|channel_token|client_id|client_secret|corp_id|corp_secret|agent_id|token|ws_url)\s*=\s*)"[^"]*"/i;
return content.split('\n').map((line) => line.replace(sensitiveKeyPattern, '$<prefix>"<redacted>"')).join('\n');
}
function getSessionKey(payload: unknown): string {
if (typeof payload === 'string' && payload.trim()) return payload.trim();
if (isRecord(payload)) {
@@ -741,14 +1071,6 @@ function toProviderSyncPayload(payload: unknown): { providerId?: string; reason?
};
}
function isClawxLocalPlaceholderPlatform(block: string): boolean {
const type = block.match(/^\s*type\s*=\s*"([^"]+)"/m)?.[1]?.trim();
if (type !== 'line') return false;
return block.includes(`channel_secret = "${CLAWX_LOCAL_PLACEHOLDER_SECRET}"`)
&& block.includes(`channel_token = "${CLAWX_LOCAL_PLACEHOLDER_SECRET}"`)
&& block.includes('port = "0"');
}
function cronExprFromInput(schedule: unknown): string {
if (typeof schedule === 'string') return schedule.trim();
if (!isRecord(schedule)) return '';
+27 -11
View File
@@ -989,13 +989,29 @@ async function ensureScopedChannelBinding(channelType: string, accountId?: strin
await migrateLegacyChannelWideBinding(storedChannelType);
}
function scheduleGatewayChannelRestart(ctx: ChannelsApiContext, reason: string): void {
async function isCcConnectRuntime(ctx: ChannelsApiContext): Promise<boolean> {
return ctx.runtimeManager ? await ctx.runtimeManager.getActiveKind() === 'cc-connect' : false;
}
async function scheduleGatewayChannelRestart(ctx: ChannelsApiContext, reason: string): Promise<void> {
if (await isCcConnectRuntime(ctx)) {
if (ctx.runtimeManager?.getStatus().state === 'stopped') return;
await ctx.runtimeManager?.restart();
void reason;
return;
}
if (ctx.gatewayManager.getStatus().state === 'stopped') return;
ctx.gatewayManager.debouncedRestart();
void reason;
}
function scheduleGatewayChannelSaveRefresh(ctx: ChannelsApiContext, channelType: string, reason: string): void {
async function scheduleGatewayChannelSaveRefresh(ctx: ChannelsApiContext, channelType: string, reason: string): Promise<void> {
if (await isCcConnectRuntime(ctx)) {
if (ctx.runtimeManager?.getStatus().state === 'stopped') return;
await ctx.runtimeManager?.restart();
void reason;
return;
}
const storedChannelType = resolveStoredChannelType(channelType);
if (ctx.gatewayManager.getStatus().state === 'stopped') return;
if (FORCE_RESTART_CHANNELS.has(storedChannelType)) {
@@ -1076,7 +1092,7 @@ async function awaitWeChatQrLogin(
});
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}`);
await scheduleGatewayChannelSaveRefresh(ctx, OPENCLAW_WECHAT_CHANNEL_TYPE, `wechat:loginSuccess:${normalizedAccountId}`);
if (activeQrLogins.get(loginKey) !== sessionKey) return;
emitChannelEvent(ctx, UI_WECHAT_CHANNEL_TYPE, 'success', {
@@ -1139,7 +1155,7 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
const accountId = requireString(payload, 'accountId');
await validateCanonicalAccountId(channelType, accountId, { allowLegacyConfiguredId: true });
await setChannelDefaultAccount(channelType, accountId);
scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:setDefaultAccount:${channelType}`);
await scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:setDefaultAccount:${channelType}`);
return { success: true };
},
bindingSave: async (payload) => {
@@ -1156,7 +1172,7 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
await migrateLegacyChannelWideBinding(storedChannelType);
}
await assignChannelAccountToAgent(agentId, storedChannelType, accountId);
scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:setBinding:${channelType}`);
await scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:setBinding:${channelType}`);
return { success: true };
},
bindingDelete: async (payload) => {
@@ -1164,7 +1180,7 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
const accountId = optionalString(payload, 'accountId');
await validateCanonicalAccountId(channelType, accountId, { allowLegacyConfiguredId: true });
await clearChannelBinding(resolveStoredChannelType(channelType), accountId);
scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:clearBinding:${channelType}`);
await scheduleGatewayChannelSaveRefresh(ctx, channelType, `channel:clearBinding:${channelType}`);
return { success: true };
},
validateConfig: async (payload) => {
@@ -1186,19 +1202,19 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
const existingValues = await getChannelFormValues(channelType, accountId);
if (isSameConfigValues(existingValues, config)) {
await ensureScopedChannelBinding(channelType, accountId);
scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:saveConfigNoChange:${storedChannelType}`);
await scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:saveConfigNoChange:${storedChannelType}`);
return { success: true, noChange: true };
}
await saveChannelConfig(channelType, config, accountId);
await ensureScopedChannelBinding(channelType, accountId);
scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:saveConfig:${storedChannelType}`);
await scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:saveConfig:${storedChannelType}`);
return { success: true };
},
setEnabled: async (payload) => {
const channelType = requireString(payload, 'channelType');
const enabled = isRecord(payload) && payload.enabled === true;
await setChannelEnabled(channelType, enabled);
scheduleGatewayChannelRestart(ctx, `channel:setEnabled:${resolveStoredChannelType(channelType)}`);
await scheduleGatewayChannelRestart(ctx, `channel:setEnabled:${resolveStoredChannelType(channelType)}`);
return { success: true };
},
formValues: async (payload) => {
@@ -1213,11 +1229,11 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
if (accountId) {
await deleteChannelAccountConfig(channelType, accountId);
await clearChannelBinding(storedChannelType, accountId);
scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:deleteAccount:${storedChannelType}`);
await scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:deleteAccount:${storedChannelType}`);
} else {
await deleteChannelConfig(channelType);
await clearAllBindingsForChannel(storedChannelType);
scheduleGatewayChannelRestart(ctx, `channel:deleteConfig:${storedChannelType}`);
await scheduleGatewayChannelRestart(ctx, `channel:deleteConfig:${storedChannelType}`);
}
return { success: true };
},
+98 -19
View File
@@ -6,11 +6,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
const forkMock = vi.fn();
const appPath = new Map<string, string>();
const { readOpenClawConfigMock } = vi.hoisted(() => ({
readOpenClawConfigMock: vi.fn(),
}));
vi.mock('node:child_process', () => ({
spawn: forkMock,
}));
vi.mock('@electron/utils/channel-config', () => ({
readOpenClawConfig: (...args: unknown[]) => readOpenClawConfigMock(...args),
}));
vi.mock('electron', () => ({
app: {
isPackaged: false,
@@ -57,6 +64,7 @@ describe('CcConnectRuntimeProvider', () => {
forkMock.mockReset();
tempDir = await mkdtemp(join(tmpdir(), 'clawx-cc-connect-'));
appPath.set('userData', tempDir);
readOpenClawConfigMock.mockResolvedValue({});
});
afterEach(async () => {
@@ -285,6 +293,93 @@ describe('CcConnectRuntimeProvider', () => {
});
});
it('mirrors configured OpenClaw channel accounts into cc-connect platform blocks', async () => {
readOpenClawConfigMock.mockResolvedValue({
channels: {
telegram: {
defaultAccount: 'ops_bot',
accounts: {
ops_bot: {
token: 'telegram-secret-token',
allowFrom: ['12345', '67890'],
shareSessionInChannel: true,
},
},
},
feishu: {
defaultAccount: 'lark_bot',
accounts: {
lark_bot: {
appId: 'cli_lark',
appSecret: 'lark-secret',
domain: 'lark',
enableFeishuCard: false,
},
},
},
},
});
const binaryPath = join(tempDir, 'cc-connect');
await writeFile(binaryPath, '#!/bin/sh\n', { mode: 0o755 });
const { CcConnectRuntimeProvider } = await import('@electron/runtime/cc-connect-provider');
const provider = new CcConnectRuntimeProvider({
binaryPath,
codexPath: join(tempDir, 'codex'),
codexBridge: createBridgeMock() as never,
bridgeAdapter: createBridgeAdapterMock() as never,
skillSyncer: vi.fn(async () => ({ skills: [] })),
providerProfileLoader: vi.fn(async () => createProviderProfile()) as never,
});
const child = createChild();
forkMock.mockReturnValueOnce(child);
const startPromise = provider.start();
await vi.waitFor(() => expect(forkMock).toHaveBeenCalledOnce());
child.emit('spawn');
await startPromise;
const config = await readFile(join(tempDir, 'runtimes', 'cc-connect', 'config.toml'), 'utf8');
expect(config).toContain('type = "telegram"');
expect(config).toContain('token = "telegram-secret-token"');
expect(config).toContain('allow_from = "12345,67890"');
expect(config).toContain('share_session_in_channel = true');
expect(config).toContain('type = "lark"');
expect(config).toContain('app_id = "cli_lark"');
expect(config).toContain('app_secret = "lark-secret"');
expect(config).toContain('domain = "https://open.larksuite.com"');
expect(config).toContain('enable_feishu_card = false');
const logs = await provider.listLogs();
expect(logs.content).not.toContain('telegram-secret-token');
expect(logs.content).not.toContain('lark-secret');
expect(logs.content).toContain('token = "<redacted>"');
expect(logs.content).toContain('app_secret = "<redacted>"');
await expect(provider.rpc('channels.status')).resolves.toMatchObject({
channelAccounts: {
telegram: [{
accountId: 'ops_bot',
configured: true,
connected: true,
running: true,
linked: true,
}],
feishu: [{
accountId: 'lark_bot',
configured: true,
connected: true,
running: true,
linked: true,
name: 'lark',
}],
},
channelDefaultAccountId: {
telegram: 'ops_bot',
feishu: 'lark_bot',
},
});
});
it('does not expose the managed local placeholder as a user channel', async () => {
const configPath = join(tempDir, 'runtimes', 'cc-connect', 'config.toml');
await mkdir(join(tempDir, 'runtimes', 'cc-connect'), { recursive: true });
@@ -313,25 +408,9 @@ describe('CcConnectRuntimeProvider', () => {
});
await expect(provider.rpc('channels.status')).resolves.toEqual({
channels: {
feishu: {
configured: true,
running: false,
},
},
channelAccounts: {
feishu: [{
accountId: 'default',
configured: true,
connected: false,
linked: true,
name: 'feishu',
running: false,
}],
},
channelDefaultAccountId: {
feishu: 'default',
},
channels: {},
channelAccounts: {},
channelDefaultAccountId: {},
});
});
+40
View File
@@ -799,6 +799,46 @@ describe('host services', () => {
expect(gatewayManager.debouncedRestart).toHaveBeenCalledWith(150);
});
it('restarts active cc-connect runtime after saving channel config', async () => {
listAgentsSnapshotMock.mockResolvedValue({
agents: [{ id: 'main', name: 'Main' }],
defaultAgentId: 'main',
defaultModelRef: null,
configuredChannelTypes: ['feishu'],
channelOwners: {},
channelAccountOwners: {},
});
getChannelFormValuesMock.mockResolvedValue({ appId: 'old', appSecret: 'old-secret' });
const gatewayManager = {
getStatus: vi.fn(() => ({ state: 'running', port: 18789 })),
debouncedRestart: vi.fn(),
debouncedReload: vi.fn(),
};
const runtimeManager = {
getActiveKind: vi.fn(async () => 'cc-connect'),
getStatus: vi.fn(() => ({
state: 'running',
port: 9820,
runtimeKind: 'cc-connect',
})),
restart: vi.fn(async () => undefined),
};
const { createChannelsApi } = await import('@electron/services/channels-api');
await expect(createChannelsApi({
gatewayManager: gatewayManager as never,
runtimeManager: runtimeManager as never,
}).saveConfig({
channelType: 'feishu',
accountId: 'default',
config: { appId: 'cli_new', appSecret: 'new-secret' },
})).resolves.toEqual({ success: true });
expect(runtimeManager.restart).toHaveBeenCalledTimes(1);
expect(gatewayManager.debouncedRestart).not.toHaveBeenCalled();
expect(gatewayManager.debouncedReload).not.toHaveBeenCalled();
});
it('deletes agents by restarting gateway, removing workspace, and returning snapshot', async () => {
const snapshot = {
agents: [],