mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 00:48:10 +00:00
fix(channels): restore supported channel connectivity (#1232)
This commit is contained in:
@@ -965,6 +965,40 @@ function emitChannelEvent(
|
||||
}
|
||||
}
|
||||
|
||||
const CHANNEL_PLUGIN_INSTALLERS: Record<
|
||||
string,
|
||||
() => MaybePromise<{ installed: boolean; warning?: string }>
|
||||
> = {
|
||||
dingtalk: ensureDingTalkPluginInstalled,
|
||||
wecom: ensureWeComPluginInstalled,
|
||||
discord: ensureDiscordPluginInstalled,
|
||||
qqbot: ensureQQBotPluginInstalled,
|
||||
whatsapp: ensureWhatsAppPluginInstalled,
|
||||
feishu: ensureFeishuPluginInstalled,
|
||||
[OPENCLAW_WECHAT_CHANNEL_TYPE]: ensureWeChatPluginInstalled,
|
||||
};
|
||||
|
||||
function isPluginBackedChannel(storedChannelType: string): boolean {
|
||||
return Object.hasOwn(CHANNEL_PLUGIN_INSTALLERS, storedChannelType);
|
||||
}
|
||||
|
||||
function shouldRestartRunningGateway(ctx: ChannelsApiContext, storedChannelType: string): boolean {
|
||||
return isPluginBackedChannel(storedChannelType)
|
||||
&& ctx.gatewayManager.getStatus().state === 'running';
|
||||
}
|
||||
|
||||
function scheduleGatewayRestartForPluginChannel(
|
||||
ctx: ChannelsApiContext,
|
||||
storedChannelType: string,
|
||||
): void {
|
||||
logger.info(`[channels.saveConfig] scheduling Gateway restart to activate plugin channel=${storedChannelType}`);
|
||||
// The config and scoped binding are already committed. Let the host request
|
||||
// return while the guarded lifecycle path performs stop/start/readiness.
|
||||
// GatewayManager owns error logging, status propagation, and restart
|
||||
// coalescing, so the Channels page can show the normal connecting state.
|
||||
ctx.gatewayManager.debouncedRestart(0);
|
||||
}
|
||||
|
||||
async function awaitWeChatQrLogin(
|
||||
ctx: ChannelsApiContext,
|
||||
sessionKey: string,
|
||||
@@ -991,8 +1025,12 @@ async function awaitWeChatQrLogin(
|
||||
baseUrl: result.baseUrl,
|
||||
userId: result.userId,
|
||||
});
|
||||
const restartGateway = shouldRestartRunningGateway(ctx, OPENCLAW_WECHAT_CHANNEL_TYPE);
|
||||
await saveChannelConfig(UI_WECHAT_CHANNEL_TYPE, { enabled: true }, normalizedAccountId);
|
||||
await ensureScopedChannelBinding(UI_WECHAT_CHANNEL_TYPE, normalizedAccountId);
|
||||
if (restartGateway) {
|
||||
scheduleGatewayRestartForPluginChannel(ctx, OPENCLAW_WECHAT_CHANNEL_TYPE);
|
||||
}
|
||||
|
||||
if (activeQrLogins.get(loginKey) !== sessionKey) return;
|
||||
emitChannelEvent(ctx, UI_WECHAT_CHANNEL_TYPE, 'success', {
|
||||
@@ -1010,16 +1048,7 @@ async function awaitWeChatQrLogin(
|
||||
}
|
||||
|
||||
async function ensureChannelPluginInstalled(storedChannelType: string): Promise<void> {
|
||||
const installers: Record<string, () => MaybePromise<{ installed: boolean; warning?: string }>> = {
|
||||
dingtalk: ensureDingTalkPluginInstalled,
|
||||
wecom: ensureWeComPluginInstalled,
|
||||
discord: ensureDiscordPluginInstalled,
|
||||
qqbot: ensureQQBotPluginInstalled,
|
||||
whatsapp: ensureWhatsAppPluginInstalled,
|
||||
feishu: ensureFeishuPluginInstalled,
|
||||
[OPENCLAW_WECHAT_CHANNEL_TYPE]: ensureWeChatPluginInstalled,
|
||||
};
|
||||
const install = installers[storedChannelType];
|
||||
const install = CHANNEL_PLUGIN_INSTALLERS[storedChannelType];
|
||||
if (!install) return;
|
||||
const result = await install();
|
||||
if (!result.installed) {
|
||||
@@ -1101,15 +1130,24 @@ export function createChannelsApi(ctx: ChannelsApiContext): CompleteHostServiceR
|
||||
const accountId = optionalString(payload, 'accountId');
|
||||
await validateCanonicalAccountId(channelType, accountId, { allowLegacyConfiguredId: true });
|
||||
const storedChannelType = resolveStoredChannelType(channelType);
|
||||
await ensureChannelPluginInstalled(storedChannelType);
|
||||
const existingValues = await getChannelFormValues(channelType, accountId);
|
||||
const restartGateway = shouldRestartRunningGateway(ctx, storedChannelType);
|
||||
const [, existingValues] = await Promise.all([
|
||||
ensureChannelPluginInstalled(storedChannelType),
|
||||
getChannelFormValues(channelType, accountId),
|
||||
]);
|
||||
if (isSameConfigValues(existingValues, config)) {
|
||||
await ensureScopedChannelBinding(channelType, accountId);
|
||||
return { success: true, noChange: true };
|
||||
if (restartGateway) {
|
||||
scheduleGatewayRestartForPluginChannel(ctx, storedChannelType);
|
||||
}
|
||||
return { success: true, noChange: true, ...(restartGateway ? { activationPending: true } : {}) };
|
||||
}
|
||||
await saveChannelConfig(channelType, config, accountId);
|
||||
await ensureScopedChannelBinding(channelType, accountId);
|
||||
return { success: true };
|
||||
if (restartGateway) {
|
||||
scheduleGatewayRestartForPluginChannel(ctx, storedChannelType);
|
||||
}
|
||||
return { success: true, ...(restartGateway ? { activationPending: true } : {}) };
|
||||
},
|
||||
setEnabled: async (payload) => {
|
||||
const channelType = requireString(payload, 'channelType');
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
|
||||
const OPENCLAW_DIR = join(homedir(), '.openclaw');
|
||||
const WECOM_PLUGIN_ID = 'wecom';
|
||||
// Note: QQBot is a built-in channel since OpenClaw 3.31 — no plugin ID needed.
|
||||
const WECHAT_PLUGIN_ID = OPENCLAW_WECHAT_CHANNEL_TYPE;
|
||||
const FEISHU_PLUGIN_ID_CANDIDATES = ['openclaw-lark', 'feishu-openclaw-plugin'] as const;
|
||||
const DEFAULT_ACCOUNT_ID = 'default';
|
||||
@@ -60,23 +59,13 @@ const WECHAT_ACCOUNTS_DIR = join(WECHAT_STATE_DIR, 'accounts');
|
||||
const LEGACY_WECHAT_CREDENTIALS_DIR = join(OPENCLAW_DIR, 'credentials', WECHAT_PLUGIN_ID);
|
||||
const LEGACY_WECHAT_SYNC_DIR = join(OPENCLAW_DIR, 'agents', 'default', 'sessions', '.openclaw-weixin-sync');
|
||||
|
||||
// Channels that are managed as plugins (config goes under plugins.entries, not channels)
|
||||
// External plugins whose activation lives in plugins.entries while account
|
||||
// configuration remains exclusively under channels.<id>.
|
||||
const PLUGIN_CHANNELS: string[] = ['discord', 'qqbot', 'whatsapp'];
|
||||
const LEGACY_BUILTIN_CHANNEL_PLUGIN_IDS = new Set<string>();
|
||||
const BUILTIN_CHANNEL_IDS = new Set([
|
||||
'discord',
|
||||
'telegram',
|
||||
'whatsapp',
|
||||
'slack',
|
||||
'signal',
|
||||
'imessage',
|
||||
'matrix',
|
||||
'line',
|
||||
'msteams',
|
||||
'googlechat',
|
||||
'mattermost',
|
||||
'qqbot',
|
||||
]);
|
||||
// OpenClaw 2026.7.1 bundles only these channel extensions. All other ClawX
|
||||
// channels must retain their explicit external plugin allowlist entries.
|
||||
const BUILTIN_CHANNEL_IDS = new Set(['telegram', 'imessage']);
|
||||
|
||||
// Unique credential key per channel type – used for duplicate bot detection.
|
||||
// Maps each channel type to the field that uniquely identifies a bot/account.
|
||||
@@ -156,6 +145,13 @@ function sanitizeDiscordGuilds(config: unknown): void {
|
||||
* Call before committing channel-config mutations.
|
||||
*/
|
||||
function sanitizeChannelSectionsBeforeWrite(config: OpenClawConfig): void {
|
||||
for (const pluginId of PLUGIN_CHANNELS) {
|
||||
const pluginEntry = config.plugins?.entries?.[pluginId];
|
||||
if (!pluginEntry) continue;
|
||||
delete pluginEntry.accounts;
|
||||
delete pluginEntry.defaultAccount;
|
||||
}
|
||||
|
||||
if (!config.channels) return;
|
||||
for (const channelType of CHANNELS_OMIT_DEFAULT_ACCOUNT_KEY) {
|
||||
const section = config.channels[channelType];
|
||||
@@ -361,52 +357,24 @@ function ensurePluginRegistration(currentConfig: OpenClawConfig, pluginId: strin
|
||||
if (!currentConfig.plugins.entries[pluginId]) {
|
||||
currentConfig.plugins.entries[pluginId] = {};
|
||||
}
|
||||
currentConfig.plugins.entries[pluginId].enabled = true;
|
||||
const pluginEntry = currentConfig.plugins.entries[pluginId];
|
||||
// PluginEntryConfig contains plugin activation/config metadata, not channel
|
||||
// accounts. Older ClawX versions mirrored credentials here, which OpenClaw
|
||||
// 2026.7.1 rejects as an invalid plugins.entries.<id> shape.
|
||||
delete pluginEntry.accounts;
|
||||
delete pluginEntry.defaultAccount;
|
||||
pluginEntry.enabled = true;
|
||||
}
|
||||
|
||||
function syncPluginChannelAccountMirror(currentConfig: OpenClawConfig, channelType: string): void {
|
||||
function syncPluginChannelRegistration(currentConfig: OpenClawConfig, channelType: string): void {
|
||||
if (!PLUGIN_CHANNELS.includes(channelType)) return;
|
||||
const channelSection = currentConfig.channels?.[channelType];
|
||||
if (!channelSection) {
|
||||
removePluginRegistration(currentConfig, channelType);
|
||||
return;
|
||||
}
|
||||
const pluginEntry = currentConfig.plugins?.entries?.[channelType];
|
||||
if (!pluginEntry) return;
|
||||
const accounts = getChannelAccountsMap(channelSection);
|
||||
pluginEntry.enabled = channelSection.enabled;
|
||||
pluginEntry.defaultAccount = channelSection.defaultAccount;
|
||||
if (accounts && Object.keys(accounts).length > 0) {
|
||||
pluginEntry.accounts = structuredClone(accounts);
|
||||
} else {
|
||||
delete pluginEntry.accounts;
|
||||
}
|
||||
}
|
||||
|
||||
function deletePluginChannelAccountMirror(
|
||||
currentConfig: OpenClawConfig,
|
||||
channelType: string,
|
||||
accountId: string,
|
||||
): boolean {
|
||||
if (!PLUGIN_CHANNELS.includes(channelType)) return false;
|
||||
const pluginEntry = currentConfig.plugins?.entries?.[channelType];
|
||||
if (!pluginEntry) return false;
|
||||
const accounts = getChannelAccountsMap(pluginEntry);
|
||||
if (!accounts?.[accountId]) return false;
|
||||
|
||||
delete accounts[accountId];
|
||||
const remainingAccountIds = Object.keys(accounts).sort((a, b) => {
|
||||
if (a === DEFAULT_ACCOUNT_ID) return -1;
|
||||
if (b === DEFAULT_ACCOUNT_ID) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
if (remainingAccountIds.length === 0) {
|
||||
delete pluginEntry.accounts;
|
||||
delete pluginEntry.defaultAccount;
|
||||
} else if (pluginEntry.defaultAccount === accountId) {
|
||||
pluginEntry.defaultAccount = remainingAccountIds[0];
|
||||
}
|
||||
return true;
|
||||
ensurePluginRegistration(currentConfig, channelType);
|
||||
currentConfig.plugins!.entries![channelType].enabled = channelSection.enabled !== false;
|
||||
}
|
||||
|
||||
function cleanupLegacyBuiltInChannelPluginRegistration(
|
||||
@@ -516,10 +484,6 @@ async function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType:
|
||||
ensurePluginRegistration(currentConfig, channelType);
|
||||
}
|
||||
|
||||
if (channelType === 'discord' || channelType === 'qqbot' || channelType === 'whatsapp') {
|
||||
ensurePluginRegistration(currentConfig, channelType);
|
||||
}
|
||||
|
||||
if (channelType === 'feishu') {
|
||||
const feishuPluginId = await resolveFeishuPluginId();
|
||||
if (!currentConfig.plugins) {
|
||||
@@ -610,8 +574,6 @@ async function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType:
|
||||
}
|
||||
}
|
||||
|
||||
// Note: QQBot is a built-in channel since OpenClaw 3.31 — no plugin registration needed.
|
||||
|
||||
if (channelType === WECHAT_PLUGIN_ID) {
|
||||
if (!currentConfig.plugins) {
|
||||
currentConfig.plugins = {
|
||||
@@ -854,8 +816,8 @@ export async function saveChannelConfig(
|
||||
await ensurePluginAllowlist(currentConfig, resolvedChannelType);
|
||||
syncBuiltinChannelsWithPluginAllowlist(currentConfig, [resolvedChannelType]);
|
||||
|
||||
// Plugin-based channels are mirrored into plugins.entries.<id> below,
|
||||
// but ClawX still keeps channels.<id> as the local account-list source.
|
||||
// Channel credentials always live under channels.<id>. External plugin
|
||||
// entries carry activation metadata only.
|
||||
|
||||
if (!currentConfig.channels) {
|
||||
currentConfig.channels = {};
|
||||
@@ -903,20 +865,7 @@ export async function saveChannelConfig(
|
||||
// read channels.<type>.enabled still work.
|
||||
channelSection.enabled = transformedConfig.enabled ?? channelSection.enabled ?? true;
|
||||
|
||||
// Plugin-backed channel packages read their activation/config from
|
||||
// plugins.entries.<id>. Mirror the enabled flag and account map there
|
||||
// while preserving channels.<id> for ClawX's account list UI.
|
||||
if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
|
||||
ensurePluginRegistration(currentConfig, resolvedChannelType);
|
||||
const pluginEntry = currentConfig.plugins!.entries![resolvedChannelType];
|
||||
const pluginAccounts = ensureChannelAccountsMap(pluginEntry);
|
||||
pluginEntry.defaultAccount = channelSection.defaultAccount;
|
||||
pluginEntry.enabled = channelSection.enabled;
|
||||
pluginAccounts[resolvedAccountId] = {
|
||||
...pluginAccounts[resolvedAccountId],
|
||||
...accounts[resolvedAccountId],
|
||||
};
|
||||
}
|
||||
syncPluginChannelRegistration(currentConfig, resolvedChannelType);
|
||||
|
||||
// Most OpenClaw channel plugins/built-ins also read the default
|
||||
// account's credentials from the top level of `channels.<type>`
|
||||
@@ -1024,21 +973,13 @@ export async function deleteChannelAccountConfig(channelType: string, accountId:
|
||||
deleteWeChatAccount = false;
|
||||
deletedAccount = false;
|
||||
const currentConfig = snapshot as OpenClawConfig;
|
||||
const deletedPluginAccount = deletePluginChannelAccountMirror(
|
||||
currentConfig,
|
||||
resolvedChannelType,
|
||||
accountId,
|
||||
);
|
||||
const channelSection = currentConfig.channels?.[resolvedChannelType];
|
||||
if (!channelSection) {
|
||||
if (isWechatChannelType(resolvedChannelType)) {
|
||||
removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID);
|
||||
deleteWeChatAccount = true;
|
||||
}
|
||||
if (deletedPluginAccount) {
|
||||
deletedAccount = true;
|
||||
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
|
||||
sanitizeChannelSectionsBeforeWrite(currentConfig);
|
||||
} else if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
|
||||
removePluginRegistration(currentConfig, resolvedChannelType);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1046,14 +987,7 @@ export async function deleteChannelAccountConfig(channelType: string, accountId:
|
||||
const existingAccounts = getChannelAccountsMap(channelSection);
|
||||
const targetsLegacyDefault = accountId === DEFAULT_ACCOUNT_ID
|
||||
&& Object.keys(getLegacyChannelPayload(channelSection)).length > 0;
|
||||
if (!existingAccounts?.[accountId] && !targetsLegacyDefault) {
|
||||
if (deletedPluginAccount) {
|
||||
deletedAccount = true;
|
||||
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
|
||||
sanitizeChannelSectionsBeforeWrite(currentConfig);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!existingAccounts?.[accountId] && !targetsLegacyDefault) return;
|
||||
const currentDefaultAccountId = typeof channelSection.defaultAccount === 'string'
|
||||
&& channelSection.defaultAccount.trim()
|
||||
? channelSection.defaultAccount.trim()
|
||||
@@ -1069,6 +1003,8 @@ export async function deleteChannelAccountConfig(channelType: string, accountId:
|
||||
delete currentConfig.channels![resolvedChannelType];
|
||||
if (isWechatChannelType(resolvedChannelType)) {
|
||||
removePluginRegistration(currentConfig, WECHAT_PLUGIN_ID);
|
||||
} else if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
|
||||
removePluginRegistration(currentConfig, resolvedChannelType);
|
||||
}
|
||||
} else {
|
||||
if (channelSection.defaultAccount === accountId) {
|
||||
@@ -1096,7 +1032,7 @@ export async function deleteChannelAccountConfig(channelType: string, accountId:
|
||||
}
|
||||
}
|
||||
|
||||
syncPluginChannelAccountMirror(currentConfig, resolvedChannelType);
|
||||
syncPluginChannelRegistration(currentConfig, resolvedChannelType);
|
||||
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
|
||||
sanitizeChannelSectionsBeforeWrite(currentConfig);
|
||||
if (isWechatChannelType(resolvedChannelType)) {
|
||||
@@ -1144,6 +1080,9 @@ export async function deleteChannelConfig(channelType: string): Promise<void> {
|
||||
if (resolvedChannelType === 'wecom') {
|
||||
removePluginRegistration(currentConfig, WECOM_PLUGIN_ID);
|
||||
}
|
||||
if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
|
||||
removePluginRegistration(currentConfig, resolvedChannelType);
|
||||
}
|
||||
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
|
||||
if (isWechatChannelType(resolvedChannelType)) {
|
||||
deleteWeChat = true;
|
||||
@@ -1335,6 +1274,41 @@ export async function deleteAgentChannelAccounts(agentId: string, ownedChannelAc
|
||||
modified = false;
|
||||
const currentConfig = snapshot as OpenClawConfig;
|
||||
const channels = currentConfig.channels ?? {};
|
||||
|
||||
// Older ClawX releases could leave the only copy of Discord, QQBot,
|
||||
// or WhatsApp account credentials under plugins.entries.<id>. Migrate
|
||||
// that invalid legacy shape into channels.<id> before deleting the
|
||||
// owned account, so sibling accounts survive while PluginEntryConfig
|
||||
// is normalized back to activation metadata only.
|
||||
const legacyPluginChannelTypes = ownedChannelAccounts
|
||||
? [...ownedChannelAccounts]
|
||||
.filter((channelAccountKey) => channelAccountKey.endsWith(`:${accountId}`))
|
||||
.map((channelAccountKey) => channelAccountKey.slice(0, -accountId.length - 1))
|
||||
: PLUGIN_CHANNELS;
|
||||
for (const channelType of legacyPluginChannelTypes) {
|
||||
if (!PLUGIN_CHANNELS.includes(channelType)) continue;
|
||||
const pluginEntry = currentConfig.plugins?.entries?.[channelType];
|
||||
const pluginAccounts = pluginEntry ? getChannelAccountsMap(pluginEntry) : undefined;
|
||||
if (!pluginEntry || !pluginAccounts?.[accountId]) continue;
|
||||
|
||||
const section = channels[channelType] ?? {
|
||||
enabled: pluginEntry.enabled !== false,
|
||||
};
|
||||
const channelAccounts = ensureChannelAccountsMap(section);
|
||||
for (const [legacyAccountId, legacyAccountConfig] of Object.entries(pluginAccounts)) {
|
||||
if (!channelAccounts[legacyAccountId]) {
|
||||
channelAccounts[legacyAccountId] = structuredClone(legacyAccountConfig);
|
||||
}
|
||||
}
|
||||
if (typeof section.defaultAccount !== 'string' || !section.defaultAccount.trim()) {
|
||||
section.defaultAccount = typeof pluginEntry.defaultAccount === 'string'
|
||||
? pluginEntry.defaultAccount
|
||||
: DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
channels[channelType] = section;
|
||||
currentConfig.channels = channels;
|
||||
}
|
||||
|
||||
for (const channelType of Object.keys(channels)) {
|
||||
if (ownedChannelAccounts && !ownedChannelAccounts.has(`${channelType}:${accountId}`)) continue;
|
||||
const section = channels[channelType];
|
||||
@@ -1378,21 +1352,10 @@ export async function deleteAgentChannelAccounts(agentId: string, ownedChannelAc
|
||||
}
|
||||
}
|
||||
}
|
||||
syncPluginChannelAccountMirror(currentConfig, channelType);
|
||||
syncPluginChannelRegistration(currentConfig, channelType);
|
||||
modified = true;
|
||||
}
|
||||
|
||||
const pluginChannelTypes = ownedChannelAccounts
|
||||
? [...ownedChannelAccounts]
|
||||
.filter((channelAccountKey) => channelAccountKey.endsWith(`:${accountId}`))
|
||||
.map((channelAccountKey) => channelAccountKey.slice(0, -accountId.length - 1))
|
||||
: Object.keys(currentConfig.plugins?.entries ?? {});
|
||||
for (const channelType of pluginChannelTypes) {
|
||||
if (deletePluginChannelAccountMirror(currentConfig, channelType, accountId)) {
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
sanitizeChannelSectionsBeforeWrite(currentConfig);
|
||||
}
|
||||
@@ -1421,19 +1384,8 @@ export async function setChannelEnabled(channelType: string, enabled: boolean):
|
||||
|
||||
if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
|
||||
pluginChannel = true;
|
||||
if (enabled) {
|
||||
ensurePluginRegistration(currentConfig, resolvedChannelType);
|
||||
} else {
|
||||
const plugins = currentConfig.plugins ?? (currentConfig.plugins = {});
|
||||
const entries = plugins.entries ?? (plugins.entries = {});
|
||||
entries[resolvedChannelType] ??= {};
|
||||
}
|
||||
const entries = currentConfig.plugins?.entries;
|
||||
const pluginEntry = entries?.[resolvedChannelType];
|
||||
if (!pluginEntry) throw new Error(`Plugin entry not initialized: ${resolvedChannelType}`);
|
||||
pluginEntry.enabled = enabled;
|
||||
syncBuiltinChannelsWithPluginAllowlist(currentConfig);
|
||||
return;
|
||||
ensurePluginRegistration(currentConfig, resolvedChannelType);
|
||||
currentConfig.plugins!.entries![resolvedChannelType].enabled = enabled;
|
||||
}
|
||||
|
||||
if (!currentConfig.channels) currentConfig.channels = {};
|
||||
|
||||
+114
-33
@@ -534,20 +534,10 @@ const FEISHU_PLUGIN_ID_CANDIDATES = ['openclaw-lark', 'feishu-openclaw-plugin']
|
||||
const VALID_COMPACTION_MODES = new Set(['default', 'safeguard']);
|
||||
/** Matches OpenClaw's 200k+ context-window recommendation (see computeContextAwareReserveTokensFloor). */
|
||||
const DEFAULT_COMPACTION_RESERVE_TOKENS_FLOOR = 50_000;
|
||||
const BUILTIN_CHANNEL_IDS = new Set([
|
||||
'discord',
|
||||
'telegram',
|
||||
'whatsapp',
|
||||
'slack',
|
||||
'signal',
|
||||
'imessage',
|
||||
'matrix',
|
||||
'line',
|
||||
'msteams',
|
||||
'googlechat',
|
||||
'mattermost',
|
||||
'qqbot',
|
||||
]);
|
||||
// OpenClaw 2026.7.1 bundles these channel extensions. Discord, WhatsApp,
|
||||
// QQBot, and the remaining catalog channels are external plugins and their
|
||||
// explicit allowlist registrations must be preserved.
|
||||
const BUILTIN_CHANNEL_IDS = new Set(['telegram', 'imessage']);
|
||||
const OPTIONAL_PROVIDER_LIKE_BUNDLED_PLUGIN_IDS = new Set([
|
||||
'alibaba',
|
||||
'deepgram',
|
||||
@@ -2946,8 +2936,27 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
|
||||
}
|
||||
|
||||
// ── plugins section ──────────────────────────────────────────────
|
||||
// OpenClaw 2026.7.1 moved these formerly bundled channels to external
|
||||
// plugins. Recover old channel-only configs before plugin sanitization.
|
||||
let plugins = config.plugins;
|
||||
if (!plugins && isPlainRecord(config.channels)) {
|
||||
const channels = config.channels as Record<string, unknown>;
|
||||
const externalChannelIds = ['discord', 'whatsapp', 'qqbot'].filter((channelId) => {
|
||||
const section = channels[channelId];
|
||||
return isPlainRecord(section) && section.enabled !== false && Object.keys(section).length > 0;
|
||||
});
|
||||
if (externalChannelIds.length > 0) {
|
||||
plugins = {
|
||||
enabled: true,
|
||||
allow: externalChannelIds,
|
||||
entries: Object.fromEntries(externalChannelIds.map((channelId) => [channelId, { enabled: true }])),
|
||||
};
|
||||
config.plugins = plugins;
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove absolute paths in plugins that no longer exist or are bundled (preventing hardlink validation errors)
|
||||
const plugins = config.plugins;
|
||||
if (plugins) {
|
||||
if (Array.isArray(plugins)) {
|
||||
const validPlugins: unknown[] = [];
|
||||
@@ -3417,18 +3426,96 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
|
||||
modified = true;
|
||||
}
|
||||
|
||||
// ── qqbot built-in channel cleanup ──────────────────────────
|
||||
// OpenClaw 3.31 moved qqbot from a third-party plugin to a built-in
|
||||
// channel. Clean up legacy plugin entries (both bare "qqbot" and
|
||||
// manifest-declared "openclaw-qqbot") from plugins.entries.
|
||||
// plugins.allow is left untouched — having openclaw-qqbot there is harmless.
|
||||
// The channel config under channels.qqbot is preserved and works
|
||||
// identically with the built-in channel.
|
||||
const QQBOT_PLUGIN_IDS = ['qqbot', 'openclaw-qqbot'] as const;
|
||||
for (const qqbotId of QQBOT_PLUGIN_IDS) {
|
||||
if (pEntries?.[qqbotId]) {
|
||||
delete pEntries[qqbotId];
|
||||
console.log(`[sanitize] Removed built-in channel plugin from plugins.entries: ${qqbotId}`);
|
||||
// ── external channel plugin registration cleanup ────────────
|
||||
// Channel account configuration belongs under channels.<id>. OpenClaw's
|
||||
// PluginEntryConfig rejects ClawX's legacy accounts/defaultAccount mirror.
|
||||
// Migrate first: some older configs have no channels.<id> copy, and
|
||||
// deleting the plugin account map directly would lose their credentials.
|
||||
for (const pluginId of ['discord', 'whatsapp', 'qqbot'] as const) {
|
||||
const pluginEntry = pEntries[pluginId];
|
||||
if (!pluginEntry) continue;
|
||||
|
||||
const legacyAccounts = isPlainRecord(pluginEntry.accounts)
|
||||
? pluginEntry.accounts as Record<string, Record<string, unknown>>
|
||||
: null;
|
||||
if (legacyAccounts && Object.keys(legacyAccounts).length > 0) {
|
||||
const channels = isPlainRecord(config.channels)
|
||||
? config.channels as Record<string, Record<string, unknown>>
|
||||
: {};
|
||||
const existingSection = isPlainRecord(channels[pluginId])
|
||||
? channels[pluginId]
|
||||
: {};
|
||||
const channelAccounts = isPlainRecord(existingSection.accounts)
|
||||
? existingSection.accounts as Record<string, Record<string, unknown>>
|
||||
: {};
|
||||
let migratedAccount = false;
|
||||
|
||||
for (const [accountId, accountConfig] of Object.entries(legacyAccounts)) {
|
||||
if (!isPlainRecord(accountConfig) || channelAccounts[accountId]) continue;
|
||||
channelAccounts[accountId] = structuredClone(accountConfig);
|
||||
migratedAccount = true;
|
||||
}
|
||||
|
||||
if (migratedAccount) {
|
||||
existingSection.accounts = channelAccounts;
|
||||
if (existingSection.enabled === undefined) {
|
||||
existingSection.enabled = pluginEntry.enabled !== false;
|
||||
}
|
||||
if (typeof existingSection.defaultAccount !== 'string' || !existingSection.defaultAccount.trim()) {
|
||||
const legacyDefaultAccount = typeof pluginEntry.defaultAccount === 'string'
|
||||
&& channelAccounts[pluginEntry.defaultAccount]
|
||||
? pluginEntry.defaultAccount
|
||||
: Object.keys(channelAccounts).sort((a, b) => {
|
||||
if (a === 'default') return -1;
|
||||
if (b === 'default') return 1;
|
||||
return a.localeCompare(b);
|
||||
})[0];
|
||||
if (legacyDefaultAccount) {
|
||||
existingSection.defaultAccount = legacyDefaultAccount;
|
||||
}
|
||||
}
|
||||
channels[pluginId] = existingSection;
|
||||
config.channels = channels;
|
||||
modified = true;
|
||||
console.log(`[sanitize] Migrated legacy plugins.entries.${pluginId}.accounts to channels.${pluginId}.accounts`);
|
||||
}
|
||||
}
|
||||
|
||||
if ('accounts' in pluginEntry) {
|
||||
delete pluginEntry.accounts;
|
||||
modified = true;
|
||||
}
|
||||
if ('defaultAccount' in pluginEntry) {
|
||||
delete pluginEntry.defaultAccount;
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
// QQBot is an external @openclaw/qqbot plugin in OpenClaw 2026.7.1.
|
||||
// Migrate the legacy manifest id and keep one canonical active entry.
|
||||
const legacyQQBotId = 'openclaw-qqbot';
|
||||
const legacyQQBotAllowIndex = allowArr.indexOf(legacyQQBotId);
|
||||
if (legacyQQBotAllowIndex !== -1) {
|
||||
allowArr.splice(legacyQQBotAllowIndex, 1);
|
||||
modified = true;
|
||||
}
|
||||
if (pEntries[legacyQQBotId]) {
|
||||
delete pEntries[legacyQQBotId];
|
||||
modified = true;
|
||||
}
|
||||
const qqbotChannel = (config.channels as Record<string, Record<string, unknown>> | undefined)?.qqbot;
|
||||
const isQQBotConfigured = Boolean(
|
||||
qqbotChannel
|
||||
&& qqbotChannel.enabled !== false
|
||||
&& Object.keys(qqbotChannel).length > 0
|
||||
);
|
||||
if (isQQBotConfigured) {
|
||||
if (!allowArr.includes('qqbot')) {
|
||||
allowArr.push('qqbot');
|
||||
modified = true;
|
||||
}
|
||||
if (!pEntries.qqbot || pEntries.qqbot.enabled !== true) {
|
||||
pEntries.qqbot = { ...(pEntries.qqbot || {}), enabled: true };
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
@@ -3511,12 +3598,6 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
if (pEntries.whatsapp) {
|
||||
delete pEntries.whatsapp;
|
||||
console.log('[sanitize] Removed legacy plugins.entries.whatsapp for built-in channel');
|
||||
modified = true;
|
||||
}
|
||||
|
||||
// Discover all bundled extension IDs so we can clean stale bundled
|
||||
// allowlist entries from older OpenClaw versions. Re-add only the
|
||||
// ClawX-critical bundled plugins, active provider plugins, and explicitly
|
||||
|
||||
@@ -8,6 +8,10 @@ appliesTo:
|
||||
|
||||
When channel plugin ownership changes between bundled OpenClaw extensions and external `~/.openclaw/extensions/*` installs, ClawX must normalize configuration to one active plugin identity per channel.
|
||||
|
||||
The ClawX channel configuration catalog is intentionally limited to `telegram`, `discord`, `whatsapp`, `wechat`, `dingtalk`, `feishu`, `wecom`, and `qqbot`. OpenClaw may report other channel ids, but the ClawX Channels page must not expose them as configurable or editable channel groups. Filtering an unsupported runtime channel is presentation-only and must not delete or rewrite that channel's underlying OpenClaw configuration.
|
||||
|
||||
Channel credentials and account maps must remain under `channels.<id>`; `plugins.entries.<id>` is activation metadata and must not contain ClawX-generated `accounts` or `defaultAccount` fields. Discord, WhatsApp, and QQBot are external plugins in the pinned OpenClaw runtime and must retain explicit `plugins.allow` and `{ enabled }` entries. Saving any supported external plugin channel while Gateway is running must start the guarded full restart path after the coordinated config and scoped-binding commits, including no-change retries and successful WeChat QR completion, so a newly copied or previously undiscovered plugin is loaded. The host save response may return while that restart is still pending, provided it explicitly reports the pending activation state and restart failures are caught and surfaced through normal Gateway status/logging.
|
||||
|
||||
For Feishu/Lark specifically:
|
||||
|
||||
- a configured Feishu channel must not leave both the bundled `feishu` plugin and the legacy external `openclaw-lark` / `feishu-openclaw-plugin` registrations active at the same time
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
id: fix-supported-channel-connectivity
|
||||
title: Restore supported channel configuration and plugin activation
|
||||
type: ai-coding-task
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Make every ClawX-supported plugin channel persist schema-valid configuration and become visible to the running Gateway after save or QR login.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/fix-supported-channel-connectivity.md
|
||||
- harness/specs/tasks/remove-unsupported-channel-catalog-entries.md
|
||||
- harness/specs/rules/channel-plugin-migration-guards.md
|
||||
- shared/types/channel.ts
|
||||
- shared/i18n/locales/en/channels.json
|
||||
- shared/i18n/locales/zh/channels.json
|
||||
- shared/i18n/locales/ja/channels.json
|
||||
- shared/i18n/locales/ru/channels.json
|
||||
- src/pages/Channels/index.tsx
|
||||
- electron/services/channels-api.ts
|
||||
- electron/utils/channel-config.ts
|
||||
- electron/utils/openclaw-auth.ts
|
||||
- electron/utils/plugin-install-index.ts
|
||||
- tests/unit/channels-page.test.tsx
|
||||
- tests/unit/channel-config.test.ts
|
||||
- tests/unit/openclaw-auth.test.ts
|
||||
- tests/unit/host-services.test.ts
|
||||
- tests/unit/plugin-install-index.test.ts
|
||||
- tests/e2e/channels-supported-catalog.spec.ts
|
||||
- tests/e2e/channels-plugin-save.spec.ts
|
||||
expectedUserBehavior:
|
||||
- Discord, WhatsApp, and QQBot save account credentials under channels.<id> without schema-invalid account mirrors under plugins.entries.<id>.
|
||||
- DingTalk, WeCom, Feishu/Lark, WeChat, Discord, WhatsApp, and QQBot trigger a guarded full Gateway restart when saved while the Gateway is running.
|
||||
- A no-change retry of a plugin-backed channel still performs the restart needed to discover an already copied plugin.
|
||||
- Telegram remains on the native OpenClaw config reload path without an extra ClawX restart.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
- e2e
|
||||
requiredRules:
|
||||
- channel-plugin-migration-guards
|
||||
- openclaw-config-delivery
|
||||
- gateway-readiness-policy
|
||||
- renderer-main-boundary
|
||||
- backend-communication-boundary
|
||||
- e2e-parallel-isolation
|
||||
- comms-regression
|
||||
- docs-sync
|
||||
requiredTests:
|
||||
- tests/unit/channel-config.test.ts
|
||||
- tests/unit/openclaw-auth.test.ts
|
||||
- tests/unit/host-services.test.ts
|
||||
- tests/unit/plugin-install-index.test.ts
|
||||
- tests/e2e/channels-plugin-save.spec.ts
|
||||
acceptance:
|
||||
- Plugin entries contain activation metadata only and never channel account credentials.
|
||||
- Discord, WhatsApp, and QQBot output passes the OpenClaw 2026.7.1 plugin-entry schema shape.
|
||||
- External channel plugin ids are retained in plugins.allow even when no unrelated plugin is present.
|
||||
- Trusted plugin install metadata targets OpenClaw's active state/openclaw.sqlite database.
|
||||
- Plugin-backed saves await the guarded Gateway restart path when the Gateway was running at request start.
|
||||
- No Renderer transport or direct Gateway request is added.
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
id: optimize-channel-delete-latency
|
||||
title: Make channel deletion responsive while preserving durable cleanup
|
||||
type: ai-coding-task
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Remove a deleted channel or account from the Channels UI immediately after confirmation while Main completes the durable OpenClaw configuration and binding cleanup.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/optimize-channel-delete-latency.md
|
||||
- harness/specs/tasks/optimize-channel-save-latency.md
|
||||
- harness/specs/tasks/fix-supported-channel-connectivity.md
|
||||
- harness/specs/tasks/remove-unsupported-channel-catalog-entries.md
|
||||
- harness/specs/rules/channel-plugin-migration-guards.md
|
||||
- shared/host-api/contract.ts
|
||||
- shared/types/channel.ts
|
||||
- shared/i18n/locales/en/channels.json
|
||||
- shared/i18n/locales/zh/channels.json
|
||||
- shared/i18n/locales/ja/channels.json
|
||||
- shared/i18n/locales/ru/channels.json
|
||||
- electron/services/channels-api.ts
|
||||
- electron/utils/channel-config.ts
|
||||
- electron/utils/openclaw-auth.ts
|
||||
- src/components/channels/ChannelConfigModal.tsx
|
||||
- src/pages/Channels/index.tsx
|
||||
- tests/unit/agent-config.test.ts
|
||||
- tests/unit/channel-config.test.ts
|
||||
- tests/unit/host-services.test.ts
|
||||
- tests/unit/openclaw-auth.test.ts
|
||||
- tests/unit/channels-page.test.tsx
|
||||
- tests/e2e/channels-plugin-save.spec.ts
|
||||
- tests/e2e/channels-supported-catalog.spec.ts
|
||||
- tests/e2e/channels-delete-latency.spec.ts
|
||||
expectedUserBehavior:
|
||||
- Confirming deletion closes the confirmation dialog and removes the target row immediately instead of blocking on Gateway/OpenClaw configuration delivery.
|
||||
- Main still durably deletes the channel configuration and associated binding.
|
||||
- A failed deletion reports an error and refreshes the file-backed channel view to restore the actual state.
|
||||
- Runtime convergence refresh remains asynchronous and does not block the delete interaction.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
- e2e
|
||||
requiredRules:
|
||||
- channel-plugin-migration-guards
|
||||
- openclaw-config-delivery
|
||||
- gateway-readiness-policy
|
||||
- renderer-main-boundary
|
||||
- backend-communication-boundary
|
||||
- e2e-parallel-isolation
|
||||
- comms-regression
|
||||
- docs-sync
|
||||
requiredTests:
|
||||
- tests/unit/channels-page.test.tsx
|
||||
- tests/e2e/channels-delete-latency.spec.ts
|
||||
acceptance:
|
||||
- The UI applies deletion optimistically before the host delete promise settles.
|
||||
- The host delete request remains the only mutation path; Renderer does not edit OpenClaw configuration directly.
|
||||
- Failure triggers a config-only refresh rather than leaving stale optimistic state.
|
||||
- No direct Renderer Gateway request or new transport path is added.
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
id: optimize-channel-save-latency
|
||||
title: Return promptly after durable channel saves while activation continues
|
||||
type: ai-coding-task
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Reduce the time the channel configuration modal remains blocked by returning after configuration and binding commits, while a required plugin Gateway restart continues through the guarded Main-process lifecycle path.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/optimize-channel-save-latency.md
|
||||
- harness/specs/tasks/fix-supported-channel-connectivity.md
|
||||
- harness/specs/tasks/remove-unsupported-channel-catalog-entries.md
|
||||
- harness/specs/rules/channel-plugin-migration-guards.md
|
||||
- shared/host-api/contract.ts
|
||||
- shared/types/channel.ts
|
||||
- shared/i18n/locales/en/channels.json
|
||||
- shared/i18n/locales/zh/channels.json
|
||||
- shared/i18n/locales/ja/channels.json
|
||||
- shared/i18n/locales/ru/channels.json
|
||||
- electron/services/channels-api.ts
|
||||
- electron/utils/channel-config.ts
|
||||
- electron/utils/openclaw-auth.ts
|
||||
- src/components/channels/ChannelConfigModal.tsx
|
||||
- src/pages/Channels/index.tsx
|
||||
- tests/unit/channel-config.test.ts
|
||||
- tests/unit/agent-config.test.ts
|
||||
- tests/unit/host-services.test.ts
|
||||
- tests/unit/openclaw-auth.test.ts
|
||||
- tests/unit/channels-page.test.tsx
|
||||
- tests/e2e/channels-plugin-save.spec.ts
|
||||
- tests/e2e/channels-supported-catalog.spec.ts
|
||||
expectedUserBehavior:
|
||||
- Saving a plugin-backed channel returns as soon as its configuration and scoped binding are durably committed instead of waiting for Gateway stop, startup, and readiness.
|
||||
- The Channels page immediately reloads the committed local configuration and then converges to runtime connection state after the scheduled Gateway restart.
|
||||
- Required plugin activation still uses the guarded full Gateway restart path, including no-change retries and successful WeChat QR completion.
|
||||
- Restart failures remain visible through normal Gateway status and logging rather than becoming unhandled promise rejections.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
- e2e
|
||||
requiredRules:
|
||||
- channel-plugin-migration-guards
|
||||
- openclaw-config-delivery
|
||||
- gateway-readiness-policy
|
||||
- renderer-main-boundary
|
||||
- backend-communication-boundary
|
||||
- e2e-parallel-isolation
|
||||
- comms-regression
|
||||
- docs-sync
|
||||
requiredTests:
|
||||
- tests/unit/agent-config.test.ts
|
||||
- tests/unit/host-services.test.ts
|
||||
- tests/unit/channels-page.test.tsx
|
||||
- tests/e2e/channels-plugin-save.spec.ts
|
||||
acceptance:
|
||||
- The save response exposes when plugin activation is pending.
|
||||
- A running Gateway restart is started only after the channel config and scoped binding commits complete.
|
||||
- The save response does not await Gateway restart readiness.
|
||||
- Immediate post-save refresh is config-only and does not issue an expensive runtime probe while Gateway is restarting.
|
||||
- No Renderer transport or direct Gateway request is added.
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
id: remove-unsupported-channel-catalog-entries
|
||||
title: Remove unsupported channels from the ClawX channel catalog
|
||||
type: ai-coding-task
|
||||
scenario: gateway-backend-communication
|
||||
taskType: runtime-bridge
|
||||
intent: Keep the ClawX channel configuration UI limited to the eight integrations that ClawX installs and supports.
|
||||
touchedAreas:
|
||||
- harness/specs/tasks/remove-unsupported-channel-catalog-entries.md
|
||||
- harness/specs/rules/channel-plugin-migration-guards.md
|
||||
- shared/types/channel.ts
|
||||
- shared/i18n/locales/en/channels.json
|
||||
- shared/i18n/locales/zh/channels.json
|
||||
- shared/i18n/locales/ja/channels.json
|
||||
- shared/i18n/locales/ru/channels.json
|
||||
- src/pages/Channels/index.tsx
|
||||
- tests/unit/channels-page.test.tsx
|
||||
- tests/e2e/channels-supported-catalog.spec.ts
|
||||
expectedUserBehavior:
|
||||
- The Channels page offers only Telegram, Discord, WhatsApp, WeChat, DingTalk, Feishu/Lark, WeCom, and QQBot.
|
||||
- Signal, iMessage, Matrix, LINE, Microsoft Teams, Google Chat, and Mattermost are not shown as configurable or configured ClawX channels.
|
||||
- Runtime reports for unknown OpenClaw channels do not create editable cards in the ClawX Channels page.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
- comms
|
||||
- e2e
|
||||
requiredRules:
|
||||
- channel-plugin-migration-guards
|
||||
- renderer-main-boundary
|
||||
- backend-communication-boundary
|
||||
- e2e-parallel-isolation
|
||||
- docs-sync
|
||||
requiredTests:
|
||||
- tests/unit/channels-page.test.tsx
|
||||
- tests/e2e/channels-supported-catalog.spec.ts
|
||||
acceptance:
|
||||
- The shared ChannelType and channel metadata catalog contain exactly the eight ClawX-supported channel ids.
|
||||
- Unsupported channel metadata and translations are removed from every supported locale.
|
||||
- The Channels page ignores unsupported channel groups returned by the runtime without deleting their OpenClaw configuration.
|
||||
- No new direct IPC or Gateway transport is introduced.
|
||||
docs:
|
||||
required: false
|
||||
---
|
||||
@@ -243,6 +243,8 @@ export type ChannelSaveConfigPayload = ChannelTypePayload & {
|
||||
};
|
||||
export type ChannelSaveConfigResult = HostSuccess & {
|
||||
noChange?: boolean;
|
||||
/** Configuration is committed; a guarded Gateway restart is continuing asynchronously. */
|
||||
activationPending?: boolean;
|
||||
warning?: string;
|
||||
};
|
||||
export type ChannelConfiguredResult = HostSuccess & { channels?: Array<string | JsonRecord> };
|
||||
|
||||
@@ -214,21 +214,6 @@
|
||||
"Fill in Client ID (AppKey) and Client Secret (AppSecret)"
|
||||
]
|
||||
},
|
||||
"signal": {
|
||||
"description": "Connect Signal using signal-cli",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/signal",
|
||||
"fields": {
|
||||
"phoneNumber": {
|
||||
"label": "Phone Number",
|
||||
"placeholder": "+1234567890"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Install signal-cli on your system",
|
||||
"Register or link your phone number",
|
||||
"Enter your phone number below"
|
||||
]
|
||||
},
|
||||
"feishu": {
|
||||
"description": "Connect Feishu/Lark bot via WebSocket",
|
||||
"docsUrl": "https://icnnp7d0dymg.feishu.cn/wiki/GKn8wOvHnibpPNkNkPzcAvGlnzK#GdHUdp9t9oqyegxwV8ScLvVGn1c",
|
||||
@@ -268,118 +253,6 @@
|
||||
"Enter your Bot ID (or Corp ID) and Secret to establish connection"
|
||||
]
|
||||
},
|
||||
"imessage": {
|
||||
"description": "Connect iMessage via BlueBubbles (macOS)",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/bluebubbles",
|
||||
"fields": {
|
||||
"serverUrl": {
|
||||
"label": "BlueBubbles Server URL",
|
||||
"placeholder": "http://localhost:1234"
|
||||
},
|
||||
"password": {
|
||||
"label": "Server Password",
|
||||
"placeholder": "Your server password"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Install BlueBubbles server on your Mac",
|
||||
"Note the server URL and password",
|
||||
"Enter the connection details below"
|
||||
]
|
||||
},
|
||||
"matrix": {
|
||||
"description": "Connect to Matrix protocol",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/matrix",
|
||||
"fields": {
|
||||
"homeserver": {
|
||||
"label": "Homeserver URL",
|
||||
"placeholder": "https://matrix.org"
|
||||
},
|
||||
"accessToken": {
|
||||
"label": "Access Token",
|
||||
"placeholder": "Your access token"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Create a Matrix account or use existing",
|
||||
"Get an access token from your client",
|
||||
"Enter the homeserver and token below"
|
||||
]
|
||||
},
|
||||
"line": {
|
||||
"description": "Connect LINE Messaging API",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/line",
|
||||
"fields": {
|
||||
"channelAccessToken": {
|
||||
"label": "Channel Access Token",
|
||||
"placeholder": "Your LINE channel access token"
|
||||
},
|
||||
"channelSecret": {
|
||||
"label": "Channel Secret",
|
||||
"placeholder": "Your LINE channel secret"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Go to LINE Developers Console",
|
||||
"Create a Messaging API channel",
|
||||
"Get Channel Access Token and Secret"
|
||||
]
|
||||
},
|
||||
"msteams": {
|
||||
"description": "Connect Microsoft Teams via Bot Framework",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/msteams",
|
||||
"fields": {
|
||||
"appId": {
|
||||
"label": "App ID",
|
||||
"placeholder": "Your Microsoft App ID"
|
||||
},
|
||||
"appPassword": {
|
||||
"label": "App Password",
|
||||
"placeholder": "Your Microsoft App Password"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Go to Azure Portal",
|
||||
"Register a new Bot application",
|
||||
"Get App ID and create a password",
|
||||
"Configure Teams channel"
|
||||
]
|
||||
},
|
||||
"googlechat": {
|
||||
"description": "Connect Google Chat via webhook",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/googlechat",
|
||||
"fields": {
|
||||
"serviceAccountKey": {
|
||||
"label": "Service Account JSON Path",
|
||||
"placeholder": "/path/to/service-account.json"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Create a Google Cloud project",
|
||||
"Enable Google Chat API",
|
||||
"Create a service account",
|
||||
"Download the JSON key file"
|
||||
]
|
||||
},
|
||||
"mattermost": {
|
||||
"description": "Connect Mattermost via Bot API",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/mattermost",
|
||||
"fields": {
|
||||
"serverUrl": {
|
||||
"label": "Server URL",
|
||||
"placeholder": "https://your-mattermost.com"
|
||||
},
|
||||
"botToken": {
|
||||
"label": "Bot Access Token",
|
||||
"placeholder": "Your bot access token"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Go to Mattermost Integrations",
|
||||
"Create a new Bot Account",
|
||||
"Copy the access token"
|
||||
]
|
||||
},
|
||||
"qqbot": {
|
||||
"description": "Connect QQ Bot channel (built-in since OpenClaw 3.31)",
|
||||
"docsUrl": "https://icnnp7d0dymg.feishu.cn/wiki/KPIJwlyiGiupMrkiS9ice39Zn2c",
|
||||
|
||||
@@ -214,21 +214,6 @@
|
||||
"Client ID (AppKey) と Client Secret (AppSecret) を入力します"
|
||||
]
|
||||
},
|
||||
"signal": {
|
||||
"description": "signal-cli を使用して Signal に接続します",
|
||||
"fields": {
|
||||
"phoneNumber": {
|
||||
"label": "電話番号",
|
||||
"placeholder": "+1234567890"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"システムに signal-cli をインストールします",
|
||||
"電話番号を登録またはリンクします",
|
||||
"以下に電話番号を入力します"
|
||||
],
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/signal"
|
||||
},
|
||||
"feishu": {
|
||||
"description": "WebSocket 経由で Feishu/Lark ボットに接続します",
|
||||
"docsUrl": "https://icnnp7d0dymg.feishu.cn/wiki/GKn8wOvHnibpPNkNkPzcAvGlnzK#GdHUdp9t9oqyegxwV8ScLvVGn1c",
|
||||
@@ -268,118 +253,6 @@
|
||||
"ボット ID (または 企業 ID) とシークレットを入力して接続を確立します"
|
||||
]
|
||||
},
|
||||
"imessage": {
|
||||
"description": "BlueBubbles (macOS) 経由で iMessage に接続します",
|
||||
"fields": {
|
||||
"serverUrl": {
|
||||
"label": "BlueBubbles サーバーURL",
|
||||
"placeholder": "http://localhost:1234"
|
||||
},
|
||||
"password": {
|
||||
"label": "サーバーパスワード",
|
||||
"placeholder": "サーバーのパスワード"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Mac に BlueBubbles サーバーをインストールします",
|
||||
"サーバーURLとパスワードをメモします",
|
||||
"以下に接続詳細を入力します"
|
||||
],
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/bluebubbles"
|
||||
},
|
||||
"matrix": {
|
||||
"description": "Matrix プロトコルに接続します",
|
||||
"fields": {
|
||||
"homeserver": {
|
||||
"label": "ホームサーバー URL",
|
||||
"placeholder": "https://matrix.org"
|
||||
},
|
||||
"accessToken": {
|
||||
"label": "アクセストークン",
|
||||
"placeholder": "アクセストークン"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Matrix アカウントを作成するか、既存のものを使用します",
|
||||
"クライアントからアクセストークンを取得します",
|
||||
"以下にホームサーバーとトークンを入力します"
|
||||
],
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/matrix"
|
||||
},
|
||||
"line": {
|
||||
"description": "LINE Messaging API に接続します",
|
||||
"fields": {
|
||||
"channelAccessToken": {
|
||||
"label": "チャンネルアクセストークン",
|
||||
"placeholder": "LINE チャンネルアクセストークン"
|
||||
},
|
||||
"channelSecret": {
|
||||
"label": "チャンネルシークレット",
|
||||
"placeholder": "LINE チャンネルシークレット"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"LINE Developers Console に移動します",
|
||||
"Messaging API チャンネルを作成します",
|
||||
"チャンネルアクセストークンとシークレットを取得します"
|
||||
],
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/line"
|
||||
},
|
||||
"msteams": {
|
||||
"description": "Bot Framework 経由で Microsoft Teams に接続します",
|
||||
"fields": {
|
||||
"appId": {
|
||||
"label": "App ID",
|
||||
"placeholder": "Microsoft App ID"
|
||||
},
|
||||
"appPassword": {
|
||||
"label": "App Password",
|
||||
"placeholder": "Microsoft App Password"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Azure Portal に移動します",
|
||||
"新しい Bot アプリケーションを登録します",
|
||||
"App ID を取得し、パスワードを作成します",
|
||||
"Teams チャンネルを設定します"
|
||||
],
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/msteams"
|
||||
},
|
||||
"googlechat": {
|
||||
"description": "Webhook 経由で Google Chat に接続します",
|
||||
"fields": {
|
||||
"serviceAccountKey": {
|
||||
"label": "サービスアカウント JSON パス",
|
||||
"placeholder": "/path/to/service-account.json"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Google Cloud プロジェクトを作成します",
|
||||
"Google Chat API を有効にします",
|
||||
"サービスアカウントを作成します",
|
||||
"JSON キーファイルをダウンロードします"
|
||||
],
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/googlechat"
|
||||
},
|
||||
"mattermost": {
|
||||
"description": "Bot API 経由で Mattermost に接続します",
|
||||
"fields": {
|
||||
"serverUrl": {
|
||||
"label": "サーバー URL",
|
||||
"placeholder": "https://your-mattermost.com"
|
||||
},
|
||||
"botToken": {
|
||||
"label": "ボットアクセストークン",
|
||||
"placeholder": "ボットアクセストークン"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Mattermost Integrations に移動します",
|
||||
"新しい Bot アカウントを作成します",
|
||||
"アクセストークンをコピーします"
|
||||
],
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/mattermost"
|
||||
},
|
||||
"qqbot": {
|
||||
"description": "QQ ボットチャンネルに接続します(OpenClaw 3.31 より内蔵)",
|
||||
"fields": {
|
||||
|
||||
@@ -214,21 +214,6 @@
|
||||
"Заполните Client ID (AppKey) и Client Secret (AppSecret)"
|
||||
]
|
||||
},
|
||||
"signal": {
|
||||
"description": "Подключите Signal через signal-cli",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/signal",
|
||||
"fields": {
|
||||
"phoneNumber": {
|
||||
"label": "Номер телефона",
|
||||
"placeholder": "+1234567890"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Установите signal-cli в вашей системе",
|
||||
"Зарегистрируйте или привяжите ваш номер телефона",
|
||||
"Введите ваш номер телефона ниже"
|
||||
]
|
||||
},
|
||||
"feishu": {
|
||||
"description": "Подключите бота Feishu/Lark через WebSocket",
|
||||
"docsUrl": "https://icnnp7d0dymg.feishu.cn/wiki/GKn8wOvHnibpPNkNkPzcAvGlnzK#GdHUdp9t9oqyegxwV8ScLvVGn1c",
|
||||
@@ -268,118 +253,6 @@
|
||||
"Введите ваш Bot ID (или Corp ID) и Secret для установления соединения"
|
||||
]
|
||||
},
|
||||
"imessage": {
|
||||
"description": "Подключите iMessage через BlueBubbles (macOS)",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/bluebubbles",
|
||||
"fields": {
|
||||
"serverUrl": {
|
||||
"label": "URL сервера BlueBubbles",
|
||||
"placeholder": "http://localhost:1234"
|
||||
},
|
||||
"password": {
|
||||
"label": "Пароль сервера",
|
||||
"placeholder": "Пароль вашего сервера"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Установите сервер BlueBubbles на ваш Mac",
|
||||
"Запишите URL сервера и пароль",
|
||||
"Введите данные подключения ниже"
|
||||
]
|
||||
},
|
||||
"matrix": {
|
||||
"description": "Подключитесь к протоколу Matrix",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/matrix",
|
||||
"fields": {
|
||||
"homeserver": {
|
||||
"label": "URL Homeserver",
|
||||
"placeholder": "https://matrix.org"
|
||||
},
|
||||
"accessToken": {
|
||||
"label": "Access Token",
|
||||
"placeholder": "Ваш access token"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Создайте учётную запись Matrix или используйте существующую",
|
||||
"Получите access token из вашего клиента",
|
||||
"Введите homeserver и token ниже"
|
||||
]
|
||||
},
|
||||
"line": {
|
||||
"description": "Подключите LINE Messaging API",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/line",
|
||||
"fields": {
|
||||
"channelAccessToken": {
|
||||
"label": "Channel Access Token",
|
||||
"placeholder": "Ваш LINE channel access token"
|
||||
},
|
||||
"channelSecret": {
|
||||
"label": "Channel Secret",
|
||||
"placeholder": "Ваш LINE channel secret"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Перейдите в LINE Developers Console",
|
||||
"Создайте канал Messaging API",
|
||||
"Получите Channel Access Token и Secret"
|
||||
]
|
||||
},
|
||||
"msteams": {
|
||||
"description": "Подключите Microsoft Teams через Bot Framework",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/msteams",
|
||||
"fields": {
|
||||
"appId": {
|
||||
"label": "App ID",
|
||||
"placeholder": "Ваш Microsoft App ID"
|
||||
},
|
||||
"appPassword": {
|
||||
"label": "App Password",
|
||||
"placeholder": "Ваш Microsoft App Password"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Перейдите на Azure Portal",
|
||||
"Зарегистрируйте новое приложение бота",
|
||||
"Получите App ID и создайте пароль",
|
||||
"Настройте канал Teams"
|
||||
]
|
||||
},
|
||||
"googlechat": {
|
||||
"description": "Подключите Google Chat через webhook",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/googlechat",
|
||||
"fields": {
|
||||
"serviceAccountKey": {
|
||||
"label": "Путь к JSON файлу сервисного аккаунта",
|
||||
"placeholder": "/path/to/service-account.json"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Создайте проект Google Cloud",
|
||||
"Включите Google Chat API",
|
||||
"Создайте сервисный аккаунт",
|
||||
"Скачайте файл ключа JSON"
|
||||
]
|
||||
},
|
||||
"mattermost": {
|
||||
"description": "Подключите Mattermost через Bot API",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/mattermost",
|
||||
"fields": {
|
||||
"serverUrl": {
|
||||
"label": "URL сервера",
|
||||
"placeholder": "https://your-mattermost.com"
|
||||
},
|
||||
"botToken": {
|
||||
"label": "Bot Access Token",
|
||||
"placeholder": "Ваш bot access token"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"Перейдите в Mattermost Integrations",
|
||||
"Создайте новую учётную запись бота",
|
||||
"Скопируйте access token"
|
||||
]
|
||||
},
|
||||
"qqbot": {
|
||||
"description": "Подключите канал QQ Bot (встроенный с OpenClaw 3.31)",
|
||||
"docsUrl": "https://icnnp7d0dymg.feishu.cn/wiki/KPIJwlyiGiupMrkiS9ice39Zn2c",
|
||||
|
||||
@@ -214,21 +214,6 @@
|
||||
"填写 Client ID (AppKey) 和 Client Secret (AppSecret)"
|
||||
]
|
||||
},
|
||||
"signal": {
|
||||
"description": "使用 signal-cli 连接 Signal",
|
||||
"docsUrl": "https://docs.openclaw.ai/zh-CN/channels/signal",
|
||||
"fields": {
|
||||
"phoneNumber": {
|
||||
"label": "手机号码",
|
||||
"placeholder": "+1234567890"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"在您的系统上安装 signal-cli",
|
||||
"注册或链接您的手机号码",
|
||||
"在下方输入您的手机号码"
|
||||
]
|
||||
},
|
||||
"feishu": {
|
||||
"description": "通过飞书官方推出的 OpenClaw 插件连接飞书/Lark 机器人",
|
||||
"docsUrl": "https://icnnp7d0dymg.feishu.cn/wiki/GKn8wOvHnibpPNkNkPzcAvGlnzK#Py88dTltfoJc1jxAhIBcW3Pkn7b",
|
||||
@@ -269,118 +254,6 @@
|
||||
"填写 Bot ID(可选企业 ID 或者直接使用机器人专属 ID)及 Secret 即可建立连接"
|
||||
]
|
||||
},
|
||||
"imessage": {
|
||||
"description": "通过 BlueBubbles (macOS) 连接 iMessage",
|
||||
"docsUrl": "https://docs.openclaw.ai/zh-CN/channels/bluebubbles",
|
||||
"fields": {
|
||||
"serverUrl": {
|
||||
"label": "BlueBubbles 服务器地址",
|
||||
"placeholder": "http://localhost:1234"
|
||||
},
|
||||
"password": {
|
||||
"label": "服务器密码",
|
||||
"placeholder": "您的服务器密码"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"在您的 Mac 上安装 BlueBubbles 服务器",
|
||||
"记下服务器地址和密码",
|
||||
"在下方输入连接详情"
|
||||
]
|
||||
},
|
||||
"matrix": {
|
||||
"description": "连接到 Matrix 协议",
|
||||
"docsUrl": "https://docs.openclaw.ai/zh-CN/channels/matrix",
|
||||
"fields": {
|
||||
"homeserver": {
|
||||
"label": "Homeserver 地址",
|
||||
"placeholder": "https://matrix.org"
|
||||
},
|
||||
"accessToken": {
|
||||
"label": "访问令牌 (Access Token)",
|
||||
"placeholder": "您的访问令牌"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"创建一个 Matrix 账户或使用现有账户",
|
||||
"从您的客户端获取访问令牌",
|
||||
"在下方输入 Homeserver 地址和令牌"
|
||||
]
|
||||
},
|
||||
"line": {
|
||||
"description": "连接 LINE Messaging API",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/line",
|
||||
"fields": {
|
||||
"channelAccessToken": {
|
||||
"label": "频道访问令牌",
|
||||
"placeholder": "您的 LINE 频道访问令牌"
|
||||
},
|
||||
"channelSecret": {
|
||||
"label": "频道密钥",
|
||||
"placeholder": "您的 LINE 频道密钥"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"前往 LINE 开发者控制台",
|
||||
"创建一个 Messaging API 频道",
|
||||
"获取频道访问令牌和密钥"
|
||||
]
|
||||
},
|
||||
"msteams": {
|
||||
"description": "通过 Bot Framework 连接 Microsoft Teams",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/msteams",
|
||||
"fields": {
|
||||
"appId": {
|
||||
"label": "应用 ID",
|
||||
"placeholder": "您的 Microsoft 应用 ID"
|
||||
},
|
||||
"appPassword": {
|
||||
"label": "应用密码",
|
||||
"placeholder": "您的 Microsoft 应用密码"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"前往 Azure 门户",
|
||||
"注册一个新的 Bot 应用",
|
||||
"获取应用 ID 并创建密码",
|
||||
"配置 Teams 频道"
|
||||
]
|
||||
},
|
||||
"googlechat": {
|
||||
"description": "通过 Webhook 连接 Google Chat",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/googlechat",
|
||||
"fields": {
|
||||
"serviceAccountKey": {
|
||||
"label": "服务账号 JSON 路径",
|
||||
"placeholder": "/path/to/service-account.json"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"创建 Google Cloud 项目",
|
||||
"启用 Google Chat API",
|
||||
"创建服务账号",
|
||||
"下载 JSON 密钥文件"
|
||||
]
|
||||
},
|
||||
"mattermost": {
|
||||
"description": "通过 Bot API 连接 Mattermost",
|
||||
"docsUrl": "https://docs.openclaw.ai/channels/mattermost",
|
||||
"fields": {
|
||||
"serverUrl": {
|
||||
"label": "服务器地址",
|
||||
"placeholder": "https://your-mattermost.com"
|
||||
},
|
||||
"botToken": {
|
||||
"label": "机器人访问令牌",
|
||||
"placeholder": "您的机器人访问令牌"
|
||||
}
|
||||
},
|
||||
"instructions": [
|
||||
"前往 Mattermost 集成",
|
||||
"创建一个新的 Bot 账户",
|
||||
"复制访问令牌"
|
||||
]
|
||||
},
|
||||
"qqbot": {
|
||||
"description": "连接 QQ 机器人频道(OpenClaw 3.31 起内置)",
|
||||
"docsUrl": "https://icnnp7d0dymg.feishu.cn/wiki/KPIJwlyiGiupMrkiS9ice39Zn2c",
|
||||
|
||||
+16
-236
@@ -6,22 +6,18 @@
|
||||
/**
|
||||
* Supported channel types
|
||||
*/
|
||||
export type ChannelType =
|
||||
| 'whatsapp'
|
||||
| 'wechat'
|
||||
| 'dingtalk'
|
||||
| 'telegram'
|
||||
| 'discord'
|
||||
| 'signal'
|
||||
| 'feishu'
|
||||
| 'wecom'
|
||||
| 'imessage'
|
||||
| 'matrix'
|
||||
| 'line'
|
||||
| 'msteams'
|
||||
| 'googlechat'
|
||||
| 'mattermost'
|
||||
| 'qqbot';
|
||||
export const SUPPORTED_CHANNEL_TYPES = [
|
||||
'telegram',
|
||||
'discord',
|
||||
'whatsapp',
|
||||
'wechat',
|
||||
'dingtalk',
|
||||
'feishu',
|
||||
'wecom',
|
||||
'qqbot',
|
||||
] as const;
|
||||
|
||||
export type ChannelType = (typeof SUPPORTED_CHANNEL_TYPES)[number];
|
||||
|
||||
/**
|
||||
* Channel connection status
|
||||
@@ -86,15 +82,8 @@ export const CHANNEL_ICONS: Record<ChannelType, string> = {
|
||||
dingtalk: '💬',
|
||||
telegram: '✈️',
|
||||
discord: '🎮',
|
||||
signal: '🔒',
|
||||
feishu: '🐦',
|
||||
wecom: '💼',
|
||||
imessage: '💬',
|
||||
matrix: '🔗',
|
||||
line: '🟢',
|
||||
msteams: '👔',
|
||||
googlechat: '💭',
|
||||
mattermost: '💠',
|
||||
qqbot: '🐧',
|
||||
};
|
||||
|
||||
@@ -107,15 +96,8 @@ export const CHANNEL_NAMES: Record<ChannelType, string> = {
|
||||
dingtalk: 'DingTalk',
|
||||
telegram: 'Telegram',
|
||||
discord: 'Discord',
|
||||
signal: 'Signal',
|
||||
feishu: 'Feishu / Lark',
|
||||
wecom: 'WeCom',
|
||||
imessage: 'iMessage',
|
||||
matrix: 'Matrix',
|
||||
line: 'LINE',
|
||||
msteams: 'Microsoft Teams',
|
||||
googlechat: 'Google Chat',
|
||||
mattermost: 'Mattermost',
|
||||
qqbot: 'QQ Bot',
|
||||
};
|
||||
|
||||
@@ -319,28 +301,6 @@ export const CHANNEL_META: Record<ChannelType, ChannelMeta> = {
|
||||
],
|
||||
isPlugin: true,
|
||||
},
|
||||
signal: {
|
||||
id: 'signal',
|
||||
name: 'Signal',
|
||||
icon: '🔒',
|
||||
description: 'channels:meta.signal.description',
|
||||
connectionType: 'token',
|
||||
docsUrl: 'channels:meta.signal.docsUrl',
|
||||
configFields: [
|
||||
{
|
||||
key: 'phoneNumber',
|
||||
label: 'channels:meta.signal.fields.phoneNumber.label',
|
||||
type: 'text',
|
||||
placeholder: 'channels:meta.signal.fields.phoneNumber.placeholder',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
instructions: [
|
||||
'channels:meta.signal.instructions.0',
|
||||
'channels:meta.signal.instructions.1',
|
||||
'channels:meta.signal.instructions.2',
|
||||
],
|
||||
},
|
||||
feishu: {
|
||||
id: 'feishu',
|
||||
name: 'Feishu / Lark',
|
||||
@@ -374,195 +334,15 @@ export const CHANNEL_META: Record<ChannelType, ChannelMeta> = {
|
||||
],
|
||||
isPlugin: true,
|
||||
},
|
||||
imessage: {
|
||||
id: 'imessage',
|
||||
name: 'iMessage',
|
||||
icon: '💬',
|
||||
description: 'channels:meta.imessage.description',
|
||||
connectionType: 'token',
|
||||
docsUrl: 'channels:meta.imessage.docsUrl',
|
||||
configFields: [
|
||||
{
|
||||
key: 'serverUrl',
|
||||
label: 'channels:meta.imessage.fields.serverUrl.label',
|
||||
type: 'text',
|
||||
placeholder: 'channels:meta.imessage.fields.serverUrl.placeholder',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'password',
|
||||
label: 'channels:meta.imessage.fields.password.label',
|
||||
type: 'password',
|
||||
placeholder: 'channels:meta.imessage.fields.password.placeholder',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
instructions: [
|
||||
'channels:meta.imessage.instructions.0',
|
||||
'channels:meta.imessage.instructions.1',
|
||||
'channels:meta.imessage.instructions.2',
|
||||
],
|
||||
},
|
||||
matrix: {
|
||||
id: 'matrix',
|
||||
name: 'Matrix',
|
||||
icon: '🔗',
|
||||
description: 'channels:meta.matrix.description',
|
||||
connectionType: 'token',
|
||||
docsUrl: 'channels:meta.matrix.docsUrl',
|
||||
configFields: [
|
||||
{
|
||||
key: 'homeserver',
|
||||
label: 'channels:meta.matrix.fields.homeserver.label',
|
||||
type: 'text',
|
||||
placeholder: 'channels:meta.matrix.fields.homeserver.placeholder',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'accessToken',
|
||||
label: 'channels:meta.matrix.fields.accessToken.label',
|
||||
type: 'password',
|
||||
placeholder: 'channels:meta.matrix.fields.accessToken.placeholder',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
instructions: [
|
||||
'channels:meta.matrix.instructions.0',
|
||||
'channels:meta.matrix.instructions.1',
|
||||
'channels:meta.matrix.instructions.2',
|
||||
],
|
||||
isPlugin: true,
|
||||
},
|
||||
line: {
|
||||
id: 'line',
|
||||
name: 'LINE',
|
||||
icon: '🟢',
|
||||
description: 'channels:meta.line.description',
|
||||
connectionType: 'token',
|
||||
docsUrl: 'channels:meta.line.docsUrl',
|
||||
configFields: [
|
||||
{
|
||||
key: 'channelAccessToken',
|
||||
label: 'channels:meta.line.fields.channelAccessToken.label',
|
||||
type: 'password',
|
||||
placeholder: 'channels:meta.line.fields.channelAccessToken.placeholder',
|
||||
required: true,
|
||||
envVar: 'LINE_CHANNEL_ACCESS_TOKEN',
|
||||
},
|
||||
{
|
||||
key: 'channelSecret',
|
||||
label: 'channels:meta.line.fields.channelSecret.label',
|
||||
type: 'password',
|
||||
placeholder: 'channels:meta.line.fields.channelSecret.placeholder',
|
||||
required: true,
|
||||
envVar: 'LINE_CHANNEL_SECRET',
|
||||
},
|
||||
],
|
||||
instructions: [
|
||||
'channels:meta.line.instructions.0',
|
||||
'channels:meta.line.instructions.1',
|
||||
'channels:meta.line.instructions.2',
|
||||
],
|
||||
isPlugin: true,
|
||||
},
|
||||
msteams: {
|
||||
id: 'msteams',
|
||||
name: 'Microsoft Teams',
|
||||
icon: '👔',
|
||||
description: 'channels:meta.msteams.description',
|
||||
connectionType: 'token',
|
||||
docsUrl: 'channels:meta.msteams.docsUrl',
|
||||
configFields: [
|
||||
{
|
||||
key: 'appId',
|
||||
label: 'channels:meta.msteams.fields.appId.label',
|
||||
type: 'text',
|
||||
placeholder: 'channels:meta.msteams.fields.appId.placeholder',
|
||||
required: true,
|
||||
envVar: 'MSTEAMS_APP_ID',
|
||||
},
|
||||
{
|
||||
key: 'appPassword',
|
||||
label: 'channels:meta.msteams.fields.appPassword.label',
|
||||
type: 'password',
|
||||
placeholder: 'channels:meta.msteams.fields.appPassword.placeholder',
|
||||
required: true,
|
||||
envVar: 'MSTEAMS_APP_PASSWORD',
|
||||
},
|
||||
],
|
||||
instructions: [
|
||||
'channels:meta.msteams.instructions.0',
|
||||
'channels:meta.msteams.instructions.1',
|
||||
'channels:meta.msteams.instructions.2',
|
||||
'channels:meta.msteams.instructions.3',
|
||||
],
|
||||
isPlugin: true,
|
||||
},
|
||||
googlechat: {
|
||||
id: 'googlechat',
|
||||
name: 'Google Chat',
|
||||
icon: '💭',
|
||||
description: 'channels:meta.googlechat.description',
|
||||
connectionType: 'webhook',
|
||||
docsUrl: 'channels:meta.googlechat.docsUrl',
|
||||
configFields: [
|
||||
{
|
||||
key: 'serviceAccountKey',
|
||||
label: 'channels:meta.googlechat.fields.serviceAccountKey.label',
|
||||
type: 'text',
|
||||
placeholder: 'channels:meta.googlechat.fields.serviceAccountKey.placeholder',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
instructions: [
|
||||
'channels:meta.googlechat.instructions.0',
|
||||
'channels:meta.googlechat.instructions.1',
|
||||
'channels:meta.googlechat.instructions.2',
|
||||
'channels:meta.googlechat.instructions.3',
|
||||
],
|
||||
},
|
||||
mattermost: {
|
||||
id: 'mattermost',
|
||||
name: 'Mattermost',
|
||||
icon: '💠',
|
||||
description: 'channels:meta.mattermost.description',
|
||||
connectionType: 'token',
|
||||
docsUrl: 'channels:meta.mattermost.docsUrl',
|
||||
configFields: [
|
||||
{
|
||||
key: 'serverUrl',
|
||||
label: 'channels:meta.mattermost.fields.serverUrl.label',
|
||||
type: 'text',
|
||||
placeholder: 'channels:meta.mattermost.fields.serverUrl.placeholder',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'botToken',
|
||||
label: 'channels:meta.mattermost.fields.botToken.label',
|
||||
type: 'password',
|
||||
placeholder: 'channels:meta.mattermost.fields.botToken.placeholder',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
instructions: [
|
||||
'channels:meta.mattermost.instructions.0',
|
||||
'channels:meta.mattermost.instructions.1',
|
||||
'channels:meta.mattermost.instructions.2',
|
||||
],
|
||||
isPlugin: true,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get primary supported channels (non-plugin, commonly used)
|
||||
* Get the channel integrations supported by ClawX.
|
||||
*/
|
||||
export function getPrimaryChannels(): ChannelType[] {
|
||||
return ['telegram', 'discord', 'whatsapp', 'wechat', 'dingtalk', 'feishu', 'wecom', 'qqbot'];
|
||||
return [...SUPPORTED_CHANNEL_TYPES];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available channels including plugins
|
||||
*/
|
||||
export function getAllChannels(): ChannelType[] {
|
||||
return Object.keys(CHANNEL_META) as ChannelType[];
|
||||
export function isSupportedChannelType(channelType: string): channelType is ChannelType {
|
||||
return (SUPPORTED_CHANNEL_TYPES as readonly string[]).includes(channelType);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useChannelsStore } from '@/stores/channels';
|
||||
|
||||
import { hostApi } from '@/lib/host-api';
|
||||
import { hostEvents } from '@/lib/host-events';
|
||||
@@ -81,7 +80,6 @@ export function ChannelConfigModal({
|
||||
onChannelSaved,
|
||||
}: ChannelConfigModalProps) {
|
||||
const { t } = useTranslation('channels');
|
||||
const { fetchChannels } = useChannelsStore();
|
||||
const [selectedType, setSelectedType] = useState<ChannelType | null>(initialSelectedType);
|
||||
const [configValues, setConfigValues] = useState<Record<string, string>>({});
|
||||
const [channelName, setChannelName] = useState('');
|
||||
@@ -192,9 +190,8 @@ export function ChannelConfigModal({
|
||||
}, [selectedType, loadingConfig, showChannelName]);
|
||||
|
||||
const finishSave = useCallback(async (channelType: ChannelType) => {
|
||||
await fetchChannels();
|
||||
await onChannelSaved?.(channelType);
|
||||
}, [fetchChannels, onChannelSaved]);
|
||||
}, [onChannelSaved]);
|
||||
|
||||
const finishSaveRef = useRef(finishSave);
|
||||
const onCloseRef = useRef(onClose);
|
||||
@@ -408,7 +405,6 @@ export function ChannelConfigModal({
|
||||
|
||||
toast.success(t('toast.channelSaved', { name: meta.name }));
|
||||
toast.success(t('toast.channelConnecting', { name: meta.name }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast.error(t('toast.configFailed', { error: String(error) }));
|
||||
|
||||
@@ -10,7 +10,14 @@ import { hostEvents } from '@/lib/host-events';
|
||||
import { ChannelConfigModal } from '@/components/channels/ChannelConfigModal';
|
||||
import { isGatewayStopped } from '@/lib/gateway-status';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CHANNEL_ICONS, CHANNEL_NAMES, CHANNEL_META, getPrimaryChannels, type ChannelType } from '@/types/channel';
|
||||
import {
|
||||
CHANNEL_ICONS,
|
||||
CHANNEL_NAMES,
|
||||
CHANNEL_META,
|
||||
getPrimaryChannels,
|
||||
isSupportedChannelType,
|
||||
type ChannelType,
|
||||
} from '@/types/channel';
|
||||
import { usesPluginManagedQrAccounts } from '@/lib/channel-alias';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
@@ -131,7 +138,14 @@ export function Channels() {
|
||||
const displayedGatewayHealth = isStaleNotRunningHealthForRunningGateway(gatewayHealth, gatewayStatus.state)
|
||||
? DEFAULT_GATEWAY_HEALTH
|
||||
: gatewayHealth;
|
||||
const visibleChannelGroups = channelGroups;
|
||||
const visibleChannelGroups = useMemo(
|
||||
() => channelGroups.filter(
|
||||
(group): group is ChannelGroupItem & { channelType: ChannelType } => (
|
||||
isSupportedChannelType(group.channelType)
|
||||
),
|
||||
),
|
||||
[channelGroups],
|
||||
);
|
||||
const visibleAgents = agents;
|
||||
const hasStableValue = visibleChannelGroups.length > 0 || visibleAgents.length > 0;
|
||||
const isUsingStableValue = hasStableValue && (loading || Boolean(error));
|
||||
@@ -326,14 +340,10 @@ export function Channels() {
|
||||
}, [visibleChannelGroups]);
|
||||
|
||||
const configuredGroups = useMemo(() => {
|
||||
const known = displayedChannelTypes
|
||||
return displayedChannelTypes
|
||||
.map((type) => groupedByType[type])
|
||||
.filter((group): group is ChannelGroupItem => Boolean(group));
|
||||
const unknown = visibleChannelGroups.filter(
|
||||
(group) => !displayedChannelTypes.includes(group.channelType as ChannelType),
|
||||
);
|
||||
return [...known, ...unknown];
|
||||
}, [visibleChannelGroups, displayedChannelTypes, groupedByType]);
|
||||
.filter((group): group is ChannelGroupItem & { channelType: ChannelType } => Boolean(group));
|
||||
}, [displayedChannelTypes, groupedByType]);
|
||||
|
||||
const unsupportedGroups = displayedChannelTypes.filter((type) => !configuredTypes.includes(type));
|
||||
|
||||
@@ -439,19 +449,23 @@ export function Channels() {
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
const target = deleteTarget;
|
||||
|
||||
// Close the dialog and update the list before waiting for OpenClaw's
|
||||
// coordinated config delivery. Main still owns the durable mutation; on
|
||||
// failure, reload the file-backed view to restore the actual state.
|
||||
setDeleteTarget(null);
|
||||
setChannelGroups((prev) => removeDeletedTarget(prev, target));
|
||||
|
||||
try {
|
||||
await hostApi.channels.deleteConfig(deleteTarget.channelType, deleteTarget.accountId);
|
||||
setChannelGroups((prev) => removeDeletedTarget(prev, deleteTarget));
|
||||
toast.success(deleteTarget.accountId ? t('toast.accountDeleted') : t('toast.channelDeleted'));
|
||||
// Channel reload is debounced in main process; pull again shortly to
|
||||
// converge with runtime state without flashing deleted rows back in.
|
||||
await hostApi.channels.deleteConfig(target.channelType, target.accountId);
|
||||
toast.success(target.accountId ? t('toast.accountDeleted') : t('toast.channelDeleted'));
|
||||
window.setTimeout(() => {
|
||||
void fetchPageData();
|
||||
}, 1200);
|
||||
} catch (deleteError) {
|
||||
toast.error(t('toast.configFailed', { error: String(deleteError) }));
|
||||
} finally {
|
||||
setDeleteTarget(null);
|
||||
void fetchPageData({ configOnly: true });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -841,7 +855,10 @@ export function Channels() {
|
||||
setInitialConfigValuesForModal(undefined);
|
||||
}}
|
||||
onChannelSaved={async () => {
|
||||
await fetchPageData({ probe: true });
|
||||
// The host may still be restarting Gateway for plugin activation.
|
||||
// Read the committed file-backed view immediately and let the
|
||||
// existing convergence loop refresh runtime status asynchronously.
|
||||
await fetchPageData({ configOnly: true });
|
||||
scheduleConvergenceRefresh();
|
||||
setShowConfigModal(false);
|
||||
setSelectedChannelType(null);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { completeSetup, expect, test } from './fixtures/electron';
|
||||
|
||||
const configuredChannels = {
|
||||
success: true,
|
||||
channels: [{
|
||||
channelType: 'feishu',
|
||||
defaultAccountId: 'default',
|
||||
status: 'connected',
|
||||
accounts: [{
|
||||
accountId: 'default',
|
||||
name: 'Primary Account',
|
||||
configured: true,
|
||||
status: 'connected',
|
||||
isDefault: true,
|
||||
}],
|
||||
}],
|
||||
};
|
||||
|
||||
test.describe('Channel deletion responsiveness', () => {
|
||||
test('closes the confirmation and removes the channel before host cleanup settles', async ({ electronApp, page }) => {
|
||||
await electronApp.evaluate(({ ipcMain }, response) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).__clawxDeletePending = false;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).__clawxResolveDelete = null;
|
||||
const originalHostInvoke = (ipcMain as unknown as {
|
||||
_invokeHandlers?: Map<string, (event: unknown, request: unknown) => Promise<unknown>>;
|
||||
})._invokeHandlers?.get('host:invoke');
|
||||
const respond = (id: unknown, data: unknown) => ({
|
||||
id: typeof id === 'string' ? id : undefined,
|
||||
ok: true,
|
||||
data,
|
||||
});
|
||||
|
||||
ipcMain.removeHandler('host:invoke');
|
||||
ipcMain.handle('host:invoke', async (event, request: {
|
||||
id?: string;
|
||||
module?: string;
|
||||
action?: string;
|
||||
}) => {
|
||||
if (request?.module === 'channels' && request.action === 'accounts') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const pending = (globalThis as any).__clawxDeletePending === true;
|
||||
return respond(request.id, pending ? { success: true, channels: [] } : response);
|
||||
}
|
||||
if (request?.module === 'agents' && request.action === 'list') {
|
||||
return respond(request.id, { success: true, agents: [] });
|
||||
}
|
||||
if (request?.module === 'channels' && request.action === 'deleteConfig') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).__clawxDeletePending = true;
|
||||
return await new Promise((resolve) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).__clawxResolveDelete = () => resolve(respond(request.id, { success: true }));
|
||||
});
|
||||
}
|
||||
return originalHostInvoke?.(event, request) ?? respond(request?.id, {});
|
||||
});
|
||||
}, configuredChannels);
|
||||
|
||||
await completeSetup(page);
|
||||
await page.getByTestId('sidebar-nav-channels').click();
|
||||
|
||||
const channelsPage = page.getByTestId('channels-page');
|
||||
await expect(channelsPage.getByTitle('Delete channel')).toBeVisible();
|
||||
await channelsPage.getByTitle('Delete channel').click();
|
||||
await page.getByTestId('confirm-dialog-confirm-button').click();
|
||||
|
||||
await expect(page.getByTestId('confirm-dialog-confirm-button')).toBeHidden();
|
||||
await expect(channelsPage.getByTitle('Delete channel')).toHaveCount(0);
|
||||
await expect.poll(async () => electronApp.evaluate(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (globalThis as any).__clawxDeletePending;
|
||||
})).toBe(true);
|
||||
|
||||
await electronApp.evaluate(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).__clawxResolveDelete?.();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { completeSetup, expect, test } from './fixtures/electron';
|
||||
|
||||
const responses = {
|
||||
channels: { success: true, channels: [] },
|
||||
agents: { success: true, agents: [] },
|
||||
validation: { success: true, valid: true, warnings: [] },
|
||||
};
|
||||
|
||||
test.describe('Plugin-backed channel save', () => {
|
||||
test('submits QQBot credentials through the typed Channels host API', async ({ electronApp, page }) => {
|
||||
await electronApp.evaluate(({ ipcMain }, fixtures) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).__clawxPluginChannelSavePayload = null;
|
||||
const originalHostInvoke = (ipcMain as unknown as {
|
||||
_invokeHandlers?: Map<string, (event: unknown, request: unknown) => Promise<unknown>>;
|
||||
})._invokeHandlers?.get('host:invoke');
|
||||
const respond = (id: unknown, data: unknown) => ({
|
||||
id: typeof id === 'string' ? id : undefined,
|
||||
ok: true,
|
||||
data,
|
||||
});
|
||||
|
||||
ipcMain.removeHandler('host:invoke');
|
||||
ipcMain.handle('host:invoke', async (event, request: {
|
||||
id?: string;
|
||||
module?: string;
|
||||
action?: string;
|
||||
payload?: unknown;
|
||||
}) => {
|
||||
if (request?.module === 'channels' && request.action === 'accounts') {
|
||||
return respond(request.id, fixtures.channels);
|
||||
}
|
||||
if (request?.module === 'agents' && request.action === 'list') {
|
||||
return respond(request.id, fixtures.agents);
|
||||
}
|
||||
if (request?.module === 'channels' && request.action === 'validateCredentials') {
|
||||
return respond(request.id, fixtures.validation);
|
||||
}
|
||||
if (request?.module === 'channels' && request.action === 'saveConfig') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).__clawxPluginChannelSavePayload = request.payload;
|
||||
return respond(request.id, { success: true, activationPending: true });
|
||||
}
|
||||
return originalHostInvoke?.(event, request) ?? respond(request?.id, {});
|
||||
});
|
||||
}, responses);
|
||||
|
||||
await completeSetup(page);
|
||||
await page.getByTestId('sidebar-nav-channels').click();
|
||||
|
||||
const channelsPage = page.getByTestId('channels-page');
|
||||
await expect(channelsPage).toBeVisible();
|
||||
await channelsPage.getByRole('button', { name: /QQ Bot/ }).click();
|
||||
|
||||
await page.locator('#appId').fill('qq-app-id');
|
||||
await page.locator('#clientSecret').fill('qq-client-secret');
|
||||
await page.getByRole('button', { name: /Save & Connect|dialog\.saveAndConnect/i }).click();
|
||||
|
||||
await expect.poll(async () => electronApp.evaluate(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (globalThis as any).__clawxPluginChannelSavePayload;
|
||||
})).toEqual({
|
||||
channelType: 'qqbot',
|
||||
config: {
|
||||
appId: 'qq-app-id',
|
||||
clientSecret: 'qq-client-secret',
|
||||
},
|
||||
});
|
||||
await expect(page.getByText(/Configure QQ Bot|dialog\.configureTitle/i)).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { completeSetup, expect, test } from './fixtures/electron';
|
||||
|
||||
const unsupportedChannelTypes = [
|
||||
'signal',
|
||||
'imessage',
|
||||
'matrix',
|
||||
'line',
|
||||
'msteams',
|
||||
'googlechat',
|
||||
'mattermost',
|
||||
];
|
||||
|
||||
const channelsResponse = {
|
||||
success: true,
|
||||
channels: [
|
||||
{
|
||||
channelType: 'feishu',
|
||||
defaultAccountId: 'default',
|
||||
status: 'connected',
|
||||
accounts: [],
|
||||
},
|
||||
...unsupportedChannelTypes.map((channelType) => ({
|
||||
channelType,
|
||||
defaultAccountId: 'default',
|
||||
status: 'connected',
|
||||
accounts: [{
|
||||
accountId: 'default',
|
||||
name: `unsupported-${channelType}`,
|
||||
configured: true,
|
||||
status: 'connected',
|
||||
isDefault: true,
|
||||
}],
|
||||
})),
|
||||
],
|
||||
};
|
||||
|
||||
test.describe('ClawX supported channel catalog', () => {
|
||||
test('does not expose unsupported runtime channels as configurable integrations', async ({ electronApp, page }) => {
|
||||
await electronApp.evaluate(({ ipcMain }, response) => {
|
||||
const originalHostInvoke = (ipcMain as unknown as {
|
||||
_invokeHandlers?: Map<string, (event: unknown, request: unknown) => Promise<unknown>>;
|
||||
})._invokeHandlers?.get('host:invoke');
|
||||
const respond = (id: unknown, data: unknown) => ({
|
||||
id: typeof id === 'string' ? id : undefined,
|
||||
ok: true,
|
||||
data,
|
||||
});
|
||||
|
||||
ipcMain.removeHandler('host:invoke');
|
||||
ipcMain.handle('host:invoke', async (event, request: {
|
||||
id?: string;
|
||||
module?: string;
|
||||
action?: string;
|
||||
}) => {
|
||||
if (request?.module === 'channels' && request.action === 'accounts') {
|
||||
return respond(request.id, response);
|
||||
}
|
||||
if (request?.module === 'agents' && request.action === 'list') {
|
||||
return respond(request.id, { success: true, agents: [] });
|
||||
}
|
||||
return originalHostInvoke?.(event, request) ?? respond(request?.id, {});
|
||||
});
|
||||
}, channelsResponse);
|
||||
|
||||
await completeSetup(page);
|
||||
await page.getByTestId('sidebar-nav-channels').click();
|
||||
|
||||
const channelsPage = page.getByTestId('channels-page');
|
||||
await expect(channelsPage).toBeVisible();
|
||||
await expect(channelsPage.getByText('Feishu / Lark')).toBeVisible();
|
||||
await expect(channelsPage.getByText('Telegram', { exact: true })).toBeVisible();
|
||||
|
||||
for (const channelType of unsupportedChannelTypes) {
|
||||
await expect(channelsPage.getByText(channelType, { exact: true })).toHaveCount(0);
|
||||
await expect(channelsPage.getByText(`unsupported-${channelType}`)).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -505,7 +505,7 @@ describe('agent config lifecycle', () => {
|
||||
expect((config.channels as Record<string, unknown>).telegram).toBeUndefined();
|
||||
});
|
||||
|
||||
it('deletes owned plugin-only credentials while preserving sibling accounts', async () => {
|
||||
it('migrates legacy plugin-only credentials while deleting the owned account', async () => {
|
||||
await writeOpenClawJson({
|
||||
agents: {
|
||||
list: [
|
||||
@@ -537,14 +537,20 @@ describe('agent config lifecycle', () => {
|
||||
await deleteAgentConfig('test2');
|
||||
|
||||
const config = await readOpenClawJson();
|
||||
const discord = ((config.plugins as {
|
||||
entries: Record<string, Record<string, unknown>>;
|
||||
}).entries).discord;
|
||||
expect(discord.defaultAccount).toBe('test3');
|
||||
expect(discord.accounts).toEqual({
|
||||
const discordChannel = (config.channels as {
|
||||
discord: Record<string, unknown>;
|
||||
}).discord;
|
||||
expect(discordChannel.defaultAccount).toBe('test3');
|
||||
expect(discordChannel.accounts).toEqual({
|
||||
test3: { enabled: true, token: 'discord-token-3' },
|
||||
});
|
||||
expect(JSON.stringify(discord)).not.toContain('discord-token-2');
|
||||
expect(discordChannel.token).toBe('discord-token-3');
|
||||
|
||||
const discordPlugin = ((config.plugins as {
|
||||
entries: Record<string, Record<string, unknown>>;
|
||||
}).entries).discord;
|
||||
expect(discordPlugin).toEqual({ enabled: true });
|
||||
expect(JSON.stringify(config)).not.toContain('discord-token-2');
|
||||
});
|
||||
|
||||
it('allows the same agent to bind multiple different channels', async () => {
|
||||
|
||||
@@ -201,14 +201,13 @@ describe('WeCom plugin configuration', () => {
|
||||
|
||||
const config = await readOpenClawJson();
|
||||
const channels = config.channels as Record<string, { enabled?: boolean; defaultAccount?: string; accounts?: Record<string, { enabled?: boolean }> }>;
|
||||
const plugins = config.plugins as { allow: string[]; entries: Record<string, { enabled?: boolean; defaultAccount?: string; accounts?: Record<string, { enabled?: boolean }> }> };
|
||||
const plugins = config.plugins as { allow: string[]; entries: Record<string, Record<string, unknown>> };
|
||||
|
||||
expect(channels.whatsapp.enabled).toBe(true);
|
||||
expect(channels.whatsapp.defaultAccount).toBe('default');
|
||||
expect(channels.whatsapp.accounts?.default?.enabled).toBe(true);
|
||||
expect(plugins.allow).toContain('whatsapp');
|
||||
expect(plugins.entries.whatsapp.enabled).toBe(true);
|
||||
expect(plugins.entries.whatsapp.accounts?.default?.enabled).toBe(true);
|
||||
expect(plugins.entries.whatsapp).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('keeps whatsapp plugin registration when saving plugin-backed config', async () => {
|
||||
@@ -244,12 +243,15 @@ describe('WeCom plugin configuration', () => {
|
||||
|
||||
const config = await readOpenClawJson();
|
||||
const channels = config.channels as Record<string, { accounts?: Record<string, unknown> }>;
|
||||
const plugins = config.plugins as { entries?: Record<string, { accounts?: Record<string, unknown> }> };
|
||||
const plugins = config.plugins as { allow?: string[]; entries?: Record<string, Record<string, unknown>> };
|
||||
|
||||
expect(channels.discord.accounts?.default).toBeDefined();
|
||||
expect(channels.qqbot.accounts?.default).toBeDefined();
|
||||
expect(plugins.entries?.discord?.accounts?.default).toBeDefined();
|
||||
expect(plugins.entries?.qqbot?.accounts?.default).toBeDefined();
|
||||
expect(plugins.entries?.whatsapp?.accounts?.default).toBeDefined();
|
||||
expect(channels.whatsapp.accounts?.default).toBeDefined();
|
||||
expect(plugins.allow).toEqual(expect.arrayContaining(['discord', 'qqbot', 'whatsapp']));
|
||||
expect(plugins.entries?.discord).toEqual({ enabled: true });
|
||||
expect(plugins.entries?.qqbot).toEqual({ enabled: true });
|
||||
expect(plugins.entries?.whatsapp).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('saves discord guild channel allowlist without schema-invalid allow flags', async () => {
|
||||
@@ -364,7 +366,7 @@ describe('WeCom plugin configuration', () => {
|
||||
expect((config.channels as Record<string, unknown>).telegram).toBeUndefined();
|
||||
});
|
||||
|
||||
it('removes deleted plugin-backed account credentials from the plugin mirror', async () => {
|
||||
it('removes legacy plugin account mirrors when deleting a channel account', async () => {
|
||||
await writeOpenClawJson({
|
||||
channels: {
|
||||
discord: {
|
||||
@@ -402,14 +404,12 @@ describe('WeCom plugin configuration', () => {
|
||||
expect(channel.accounts).toEqual({
|
||||
'agent-b': { token: 'discord-token-b', enabled: true },
|
||||
});
|
||||
expect(plugin.defaultAccount).toBe('agent-b');
|
||||
expect(plugin.accounts).toEqual({
|
||||
'agent-b': { token: 'discord-token-b', enabled: true },
|
||||
});
|
||||
expect(plugin).toEqual({ enabled: true });
|
||||
expect(JSON.stringify(plugin)).not.toContain('discord-token-a');
|
||||
expect(JSON.stringify(plugin)).not.toContain('discord-token-b');
|
||||
});
|
||||
|
||||
it('removes plugin-only account credentials while preserving sibling accounts', async () => {
|
||||
it('removes a legacy plugin-only registration without canonical channel config', async () => {
|
||||
await writeOpenClawJson({
|
||||
plugins: {
|
||||
allow: ['discord'],
|
||||
@@ -430,14 +430,7 @@ describe('WeCom plugin configuration', () => {
|
||||
await deleteChannelAccountConfig('discord', 'agent-a');
|
||||
|
||||
const config = await readOpenClawJson();
|
||||
const plugin = ((config.plugins as {
|
||||
entries: Record<string, Record<string, unknown>>;
|
||||
}).entries).discord;
|
||||
expect(plugin.defaultAccount).toBe('agent-b');
|
||||
expect(plugin.accounts).toEqual({
|
||||
'agent-b': { token: 'discord-token-b', enabled: true },
|
||||
});
|
||||
expect(JSON.stringify(plugin)).not.toContain('discord-token-a');
|
||||
expect(config.plugins).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { Channels } from '@/pages/Channels/index';
|
||||
import { CHANNEL_META, SUPPORTED_CHANNEL_TYPES } from '@shared/types/channel';
|
||||
|
||||
const hostApiCallMock = vi.fn();
|
||||
const subscribeHostEventMock = vi.fn();
|
||||
@@ -74,10 +75,12 @@ vi.mock('sonner', () => ({
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
const promise = new Promise<T>((res) => {
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve };
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe('Channels page status refresh', () => {
|
||||
@@ -129,6 +132,63 @@ describe('Channels page status refresh', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('defines exactly the eight ClawX-supported channel integrations', () => {
|
||||
expect(Object.keys(CHANNEL_META).sort()).toEqual([...SUPPORTED_CHANNEL_TYPES].sort());
|
||||
});
|
||||
|
||||
it('filters runtime channel groups that ClawX does not support', async () => {
|
||||
subscribeHostEventMock.mockImplementation(() => vi.fn());
|
||||
const unsupportedChannelTypes = [
|
||||
'signal',
|
||||
'imessage',
|
||||
'matrix',
|
||||
'line',
|
||||
'msteams',
|
||||
'googlechat',
|
||||
'mattermost',
|
||||
];
|
||||
hostApiCallMock.mockImplementation(async (path: string) => {
|
||||
if (path === 'channels.accounts') {
|
||||
return {
|
||||
success: true,
|
||||
channels: [
|
||||
{
|
||||
channelType: 'feishu',
|
||||
defaultAccountId: 'default',
|
||||
status: 'connected',
|
||||
accounts: [],
|
||||
},
|
||||
...unsupportedChannelTypes.map((channelType) => ({
|
||||
channelType,
|
||||
defaultAccountId: 'default',
|
||||
status: 'connected',
|
||||
accounts: [{
|
||||
accountId: 'default',
|
||||
name: `unsupported-${channelType}`,
|
||||
configured: true,
|
||||
status: 'connected',
|
||||
isDefault: true,
|
||||
}],
|
||||
})),
|
||||
],
|
||||
};
|
||||
}
|
||||
if (path === 'agents.list') return { success: true, agents: [] };
|
||||
throw new Error(`Unexpected host API path: ${path}`);
|
||||
});
|
||||
|
||||
render(<Channels />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Feishu / Lark')).toBeInTheDocument();
|
||||
expect(screen.getByText('Telegram')).toBeInTheDocument();
|
||||
});
|
||||
for (const channelType of unsupportedChannelTypes) {
|
||||
expect(screen.queryByText(channelType, { exact: true })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(`unsupported-${channelType}`)).not.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('blocks saving when custom account ID is non-canonical', async () => {
|
||||
subscribeHostEventMock.mockImplementation(() => vi.fn());
|
||||
hostApiCallMock.mockImplementation(async (path: string) => {
|
||||
@@ -211,6 +271,145 @@ describe('Channels page status refresh', () => {
|
||||
expect(saveCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('uses a config-only refresh immediately after a channel save', async () => {
|
||||
subscribeHostEventMock.mockImplementation(() => vi.fn());
|
||||
hostApiCallMock.mockImplementation(async (path: string) => {
|
||||
if (path === 'channels.accounts') {
|
||||
return { success: true, channels: [] };
|
||||
}
|
||||
if (path === 'agents.list') return { success: true, agents: [] };
|
||||
if (path === 'channels.validateCredentials') {
|
||||
return { success: true, valid: true, warnings: [] };
|
||||
}
|
||||
if (path === 'channels.saveConfig') {
|
||||
return { success: true, activationPending: true };
|
||||
}
|
||||
throw new Error(`Unexpected host API path: ${path}`);
|
||||
});
|
||||
|
||||
render(<Channels />);
|
||||
await screen.findByRole('button', { name: /QQ Bot/ });
|
||||
hostApiCallMock.mockClear();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /QQ Bot/ }));
|
||||
fireEvent.change(document.getElementById('appId') as HTMLInputElement, {
|
||||
target: { value: 'qq-app-id' },
|
||||
});
|
||||
fireEvent.change(document.getElementById('clientSecret') as HTMLInputElement, {
|
||||
target: { value: 'qq-client-secret' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'dialog.saveAndConnect' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('dialog.configureTitle')).not.toBeInTheDocument();
|
||||
});
|
||||
const postSaveAccountCalls = hostApiCallMock.mock.calls.filter(
|
||||
([path]) => path === 'channels.accounts',
|
||||
);
|
||||
expect(postSaveAccountCalls).toEqual([
|
||||
['channels.accounts', expect.objectContaining({ mode: 'config', probe: false })],
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes a channel optimistically before the host delete settles', async () => {
|
||||
subscribeHostEventMock.mockImplementation(() => vi.fn());
|
||||
const deleteDeferred = createDeferred<{ success: true }>();
|
||||
hostApiCallMock.mockImplementation(async (path: string) => {
|
||||
if (path === 'channels.accounts') {
|
||||
return {
|
||||
success: true,
|
||||
channels: [{
|
||||
channelType: 'feishu',
|
||||
defaultAccountId: 'default',
|
||||
status: 'connected',
|
||||
accounts: [{
|
||||
accountId: 'default',
|
||||
name: 'Primary Account',
|
||||
configured: true,
|
||||
status: 'connected',
|
||||
isDefault: true,
|
||||
}],
|
||||
}],
|
||||
};
|
||||
}
|
||||
if (path === 'agents.list') return { success: true, agents: [] };
|
||||
if (path === 'channels.deleteConfig') return deleteDeferred.promise;
|
||||
throw new Error(`Unexpected host API path: ${path}`);
|
||||
});
|
||||
|
||||
render(<Channels />);
|
||||
await screen.findByTitle('account.deleteChannel');
|
||||
fireEvent.click(screen.getByTitle('account.deleteChannel'));
|
||||
fireEvent.click(await screen.findByTestId('confirm-dialog-confirm-button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('confirm-dialog-confirm-button')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle('account.deleteChannel')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(toastSuccessMock).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
deleteDeferred.resolve({ success: true });
|
||||
await deleteDeferred.promise;
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(toastSuccessMock).toHaveBeenCalledWith('toast.channelDeleted');
|
||||
});
|
||||
});
|
||||
|
||||
it('restores the config-backed view when an optimistic channel delete fails', async () => {
|
||||
subscribeHostEventMock.mockImplementation(() => vi.fn());
|
||||
const deleteDeferred = createDeferred<{ success: true }>();
|
||||
hostApiCallMock.mockImplementation(async (path: string) => {
|
||||
if (path === 'channels.accounts') {
|
||||
return {
|
||||
success: true,
|
||||
channels: [{
|
||||
channelType: 'feishu',
|
||||
defaultAccountId: 'default',
|
||||
status: 'connected',
|
||||
accounts: [{
|
||||
accountId: 'default',
|
||||
name: 'Primary Account',
|
||||
configured: true,
|
||||
status: 'connected',
|
||||
isDefault: true,
|
||||
}],
|
||||
}],
|
||||
};
|
||||
}
|
||||
if (path === 'agents.list') return { success: true, agents: [] };
|
||||
if (path === 'channels.deleteConfig') return deleteDeferred.promise;
|
||||
throw new Error(`Unexpected host API path: ${path}`);
|
||||
});
|
||||
|
||||
render(<Channels />);
|
||||
await screen.findByTitle('account.deleteChannel');
|
||||
fireEvent.click(screen.getByTitle('account.deleteChannel'));
|
||||
fireEvent.click(await screen.findByTestId('confirm-dialog-confirm-button'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTitle('account.deleteChannel')).not.toBeInTheDocument();
|
||||
});
|
||||
await act(async () => {
|
||||
deleteDeferred.reject(new Error('delete failed'));
|
||||
try {
|
||||
await deleteDeferred.promise;
|
||||
} catch {
|
||||
// Expected host failure.
|
||||
}
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastErrorMock).toHaveBeenCalledWith('toast.configFailed');
|
||||
expect(hostApiCallMock).toHaveBeenCalledWith(
|
||||
'channels.accounts',
|
||||
expect.objectContaining({ mode: 'config', probe: false }),
|
||||
);
|
||||
expect(screen.getByTitle('account.deleteChannel')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('refetches channel accounts when gateway channel-status events arrive', async () => {
|
||||
let channelStatusHandler: (() => void) | undefined;
|
||||
subscribeHostEventMock.mockImplementation((eventName: string, handler: () => void) => {
|
||||
|
||||
@@ -820,7 +820,7 @@ describe('host services', () => {
|
||||
expect(migrateLegacyChannelWideBindingMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('installs plugin, saves config, and ensures scoped binding without scheduling lifecycle work', async () => {
|
||||
it('commits a plugin channel save and schedules activation without awaiting Gateway readiness', async () => {
|
||||
listAgentsSnapshotMock.mockResolvedValue({
|
||||
agents: [{ id: 'main', name: 'Main' }],
|
||||
defaultAgentId: 'main',
|
||||
@@ -834,6 +834,7 @@ describe('host services', () => {
|
||||
getStatus: vi.fn(() => ({ state: 'running', port: 18789 })),
|
||||
debouncedRestart: vi.fn(),
|
||||
debouncedReload: vi.fn(),
|
||||
restart: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const { createChannelsApi } = await import('@electron/services/channels-api');
|
||||
|
||||
@@ -841,7 +842,7 @@ describe('host services', () => {
|
||||
channelType: 'feishu',
|
||||
accountId: 'default',
|
||||
config: { appId: 'cli_new', appSecret: 'new-secret' },
|
||||
})).resolves.toEqual({ success: true });
|
||||
})).resolves.toEqual({ success: true, activationPending: true });
|
||||
|
||||
expect(ensureFeishuPluginInstalledMock).toHaveBeenCalledTimes(1);
|
||||
expect(saveChannelConfigMock).toHaveBeenCalledWith(
|
||||
@@ -850,8 +851,33 @@ describe('host services', () => {
|
||||
'default',
|
||||
);
|
||||
expect(ensureScopedChannelBindingMock).toHaveBeenCalledWith('feishu', 'default');
|
||||
expect(gatewayManager.debouncedRestart).not.toHaveBeenCalled();
|
||||
expect(gatewayManager.debouncedRestart).toHaveBeenCalledWith(0);
|
||||
expect(gatewayManager.debouncedReload).not.toHaveBeenCalled();
|
||||
expect(gatewayManager.restart).not.toHaveBeenCalled();
|
||||
expect(ensureScopedChannelBindingMock.mock.invocationCallOrder[0])
|
||||
.toBeLessThan(gatewayManager.debouncedRestart.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it('keeps bundled Telegram on the native config reload path', async () => {
|
||||
getChannelFormValuesMock.mockResolvedValue({ botToken: 'old-token', allowedUsers: '1' });
|
||||
const gatewayManager = {
|
||||
getStatus: vi.fn(() => ({ state: 'running', port: 18789 })),
|
||||
restart: vi.fn(),
|
||||
};
|
||||
const { createChannelsApi } = await import('@electron/services/channels-api');
|
||||
|
||||
await expect(createChannelsApi({ gatewayManager: gatewayManager as never }).saveConfig({
|
||||
channelType: 'telegram',
|
||||
accountId: 'default',
|
||||
config: { botToken: 'new-token', allowedUsers: '1' },
|
||||
})).resolves.toEqual({ success: true });
|
||||
|
||||
expect(saveChannelConfigMock).toHaveBeenCalledWith(
|
||||
'telegram',
|
||||
{ botToken: 'new-token', allowedUsers: '1' },
|
||||
'default',
|
||||
);
|
||||
expect(gatewayManager.restart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes agents by awaiting config commit then removing workspace without restarting', async () => {
|
||||
@@ -1004,7 +1030,7 @@ describe('host services', () => {
|
||||
expect(gatewayManager.restart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles channel default, binding delete, enable, delete, login, and no-change without lifecycle work', async () => {
|
||||
it('handles channel actions and restarts a running Gateway for a no-change plugin save', async () => {
|
||||
getChannelFormValuesMock.mockResolvedValue({ appId: 'same', appSecret: 'same-secret' });
|
||||
listAgentsSnapshotMock.mockResolvedValue({
|
||||
agents: [{ id: 'main', name: 'Main' }],
|
||||
@@ -1033,7 +1059,7 @@ describe('host services', () => {
|
||||
channelType: 'feishu',
|
||||
accountId: 'default',
|
||||
config: { appId: 'same', appSecret: 'same-secret' },
|
||||
})).resolves.toEqual({ success: true, noChange: true });
|
||||
})).resolves.toEqual({ success: true, noChange: true, activationPending: true });
|
||||
|
||||
expect(setChannelDefaultAccountMock).toHaveBeenCalledWith('feishu', 'default');
|
||||
expect(clearChannelBindingMock).toHaveBeenCalledWith('feishu', 'default');
|
||||
@@ -1041,7 +1067,7 @@ describe('host services', () => {
|
||||
expect(deleteChannelAccountConfigMock).toHaveBeenCalledWith('feishu', 'default');
|
||||
expect(deleteChannelConfigMock).toHaveBeenCalledWith('feishu');
|
||||
expect(gatewayManager.debouncedReload).not.toHaveBeenCalled();
|
||||
expect(gatewayManager.debouncedRestart).not.toHaveBeenCalled();
|
||||
expect(gatewayManager.debouncedRestart).toHaveBeenCalledWith(0);
|
||||
expect(gatewayManager.restart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1052,7 +1078,7 @@ describe('host services', () => {
|
||||
expect(source).not.toContain('debouncedRestart(8000)');
|
||||
});
|
||||
|
||||
it('persists successful WeChat login without scheduling lifecycle work', async () => {
|
||||
it('persists successful WeChat login and restarts a running Gateway', async () => {
|
||||
startWeChatLoginSessionMock.mockResolvedValue({
|
||||
qrcodeUrl: 'https://example.com/qr',
|
||||
sessionKey: 'session-1',
|
||||
@@ -1085,9 +1111,9 @@ describe('host services', () => {
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(saveChannelConfigMock).toHaveBeenCalledWith('wechat', { enabled: true }, 'wx-account');
|
||||
expect(gatewayManager.debouncedRestart).toHaveBeenCalledWith(0);
|
||||
});
|
||||
expect(gatewayManager.debouncedReload).not.toHaveBeenCalled();
|
||||
expect(gatewayManager.debouncedRestart).not.toHaveBeenCalled();
|
||||
expect(gatewayManager.restart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -579,6 +579,124 @@ describe('sanitizeOpenClawConfig', () => {
|
||||
expect(telegram.botToken).toBe('telegram-token');
|
||||
});
|
||||
|
||||
it('migrates legacy plugin-only channel accounts before stripping credential mirrors', async () => {
|
||||
await writeOpenClawJson({
|
||||
plugins: {
|
||||
enabled: true,
|
||||
allow: ['discord', 'whatsapp', 'qqbot'],
|
||||
entries: {
|
||||
discord: {
|
||||
enabled: true,
|
||||
defaultAccount: 'discord-agent',
|
||||
accounts: {
|
||||
'discord-agent': { enabled: true, token: 'discord-token' },
|
||||
},
|
||||
},
|
||||
whatsapp: {
|
||||
enabled: true,
|
||||
defaultAccount: 'whatsapp-agent',
|
||||
accounts: {
|
||||
'whatsapp-agent': { enabled: true, phoneNumber: '+15555550123' },
|
||||
},
|
||||
},
|
||||
qqbot: {
|
||||
enabled: true,
|
||||
defaultAccount: 'qq-agent',
|
||||
accounts: {
|
||||
'qq-agent': { enabled: true, appId: 'qq-app', clientSecret: 'qq-secret' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { sanitizeOpenClawConfig } = await import('@electron/utils/openclaw-auth');
|
||||
await sanitizeOpenClawConfig();
|
||||
|
||||
const result = await readOpenClawJson();
|
||||
const channels = result.channels as Record<string, Record<string, unknown>>;
|
||||
expect(channels.discord.defaultAccount).toBe('discord-agent');
|
||||
expect(channels.discord.accounts).toEqual({
|
||||
'discord-agent': { enabled: true, token: 'discord-token' },
|
||||
});
|
||||
expect(channels.discord.token).toBe('discord-token');
|
||||
expect(channels.whatsapp.accounts).toEqual({
|
||||
'whatsapp-agent': { enabled: true, phoneNumber: '+15555550123' },
|
||||
});
|
||||
expect(channels.qqbot.accounts).toEqual({
|
||||
'qq-agent': { enabled: true, appId: 'qq-app', clientSecret: 'qq-secret' },
|
||||
});
|
||||
expect(channels.qqbot.appId).toBe('qq-app');
|
||||
expect(channels.qqbot.clientSecret).toBe('qq-secret');
|
||||
|
||||
const plugins = result.plugins as Record<string, unknown>;
|
||||
const entries = plugins.entries as Record<string, Record<string, unknown>>;
|
||||
expect(entries.discord).toEqual({ enabled: true });
|
||||
expect(entries.whatsapp).toEqual({ enabled: true });
|
||||
expect(entries.qqbot).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('normalizes QQBot as an external plugin without credential mirrors', async () => {
|
||||
await writeOpenClawJson({
|
||||
channels: {
|
||||
qqbot: {
|
||||
enabled: true,
|
||||
appId: 'qq-app',
|
||||
clientSecret: 'qq-secret',
|
||||
accounts: {
|
||||
default: { appId: 'qq-app', clientSecret: 'qq-secret', enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
enabled: true,
|
||||
allow: ['openclaw-qqbot'],
|
||||
entries: {
|
||||
'openclaw-qqbot': { enabled: true },
|
||||
qqbot: {
|
||||
enabled: true,
|
||||
defaultAccount: 'default',
|
||||
accounts: {
|
||||
default: { appId: 'qq-app', clientSecret: 'qq-secret', enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { sanitizeOpenClawConfig } = await import('@electron/utils/openclaw-auth');
|
||||
await sanitizeOpenClawConfig();
|
||||
|
||||
const result = await readOpenClawJson();
|
||||
const plugins = result.plugins as Record<string, unknown>;
|
||||
const entries = plugins.entries as Record<string, Record<string, unknown>>;
|
||||
expect(plugins.allow).toEqual(['qqbot']);
|
||||
expect(entries.qqbot).toEqual({ enabled: true });
|
||||
expect(entries['openclaw-qqbot']).toBeUndefined();
|
||||
expect((result.channels as Record<string, unknown>).qqbot).toBeDefined();
|
||||
});
|
||||
|
||||
it('recovers external plugin registrations for legacy channel-only configs', async () => {
|
||||
await writeOpenClawJson({
|
||||
channels: {
|
||||
discord: { enabled: true, token: 'discord-token' },
|
||||
whatsapp: { enabled: true },
|
||||
qqbot: { enabled: true, appId: 'qq-app', clientSecret: 'qq-secret' },
|
||||
},
|
||||
});
|
||||
|
||||
const { sanitizeOpenClawConfig } = await import('@electron/utils/openclaw-auth');
|
||||
await sanitizeOpenClawConfig();
|
||||
|
||||
const result = await readOpenClawJson();
|
||||
const plugins = result.plugins as Record<string, unknown>;
|
||||
const entries = plugins.entries as Record<string, Record<string, unknown>>;
|
||||
expect(plugins.allow).toEqual(expect.arrayContaining(['discord', 'whatsapp', 'qqbot']));
|
||||
expect(entries.discord).toEqual({ enabled: true });
|
||||
expect(entries.whatsapp).toEqual({ enabled: true });
|
||||
expect(entries.qqbot).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('normalizes legacy feishu plugin state to a single external plugin and removes built-in feishu', async () => {
|
||||
await writeOpenClawJson({
|
||||
channels: {
|
||||
|
||||
Reference in New Issue
Block a user