Upgrade openclaw to 5.12 (#1023)

This commit is contained in:
paisley
2026-05-15 17:13:43 +08:00
committed by GitHub
parent 360b6649ba
commit 34bfae2851
21 changed files with 1522 additions and 993 deletions
+10
View File
@@ -25,6 +25,11 @@ jobs:
node-version: '24'
cache: 'pnpm'
- name: Prefer HTTPS for public GitHub git dependencies
run: |
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -69,6 +74,11 @@ jobs:
node-version: '24'
cache: 'pnpm'
- name: Prefer HTTPS for public GitHub git dependencies
run: |
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
- name: Install dependencies
run: pnpm install --frozen-lockfile
+5
View File
@@ -35,6 +35,11 @@ jobs:
node-version: '24'
cache: 'pnpm'
- name: Prefer HTTPS for public GitHub git dependencies
run: |
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
- name: Install dependencies
run: pnpm install --frozen-lockfile
+5
View File
@@ -37,6 +37,11 @@ jobs:
node-version: '24'
cache: 'pnpm'
- name: Prefer HTTPS for public GitHub git dependencies
run: |
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
- name: Install dependencies
run: pnpm install --frozen-lockfile
+5
View File
@@ -33,6 +33,11 @@ jobs:
node-version: '24'
cache: 'pnpm'
- name: Prefer HTTPS for public GitHub git dependencies
run: |
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
- name: Install dependencies
run: pnpm install --frozen-lockfile
+5
View File
@@ -43,6 +43,11 @@ jobs:
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Prefer HTTPS for public GitHub git dependencies
run: |
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
- name: Install dependencies
run: pnpm install
+5
View File
@@ -57,6 +57,11 @@ jobs:
node-version: '24'
cache: 'pnpm'
- name: Prefer HTTPS for public GitHub git dependencies
run: |
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
- name: Install dependencies
run: pnpm install
+5
View File
@@ -31,6 +31,11 @@ jobs:
node-version: "24"
cache: "pnpm"
- name: Prefer HTTPS for public GitHub git dependencies
run: |
git config --global "url.https://github.com/.insteadOf" "git@github.com:"
git config --global --add "url.https://github.com/.insteadOf" "ssh://git@github.com/"
- name: Install dependencies
run: pnpm install
+26 -1
View File
@@ -25,10 +25,13 @@ import {
listAgentsSnapshotFromConfig,
} from '../../utils/agent-config';
import {
ensureDiscordPluginInstalled,
ensureDingTalkPluginInstalled,
ensureFeishuPluginInstalled,
ensureQQBotPluginInstalled,
ensureWeChatPluginInstalled,
ensureWeComPluginInstalled,
ensureWhatsAppPluginInstalled,
} from '../../utils/plugin-install';
import {
computeChannelRuntimeStatus,
@@ -1471,7 +1474,28 @@ export async function handleChannelRoutes(
return true;
}
}
// QQBot is a built-in channel since OpenClaw 3.31 — no plugin install needed
if (storedChannelType === 'discord') {
const installResult = await ensureDiscordPluginInstalled();
if (!installResult.installed) {
sendJson(res, 500, { success: false, error: installResult.warning || 'Discord plugin install failed' });
return true;
}
}
if (storedChannelType === 'qqbot') {
const installResult = await ensureQQBotPluginInstalled();
if (!installResult.installed) {
sendJson(res, 500, { success: false, error: installResult.warning || 'QQBot plugin install failed' });
return true;
}
}
if (storedChannelType === 'whatsapp') {
const installResult = await ensureWhatsAppPluginInstalled();
if (!installResult.installed) {
sendJson(res, 500, { success: false, error: installResult.warning || 'WhatsApp plugin install failed' });
return true;
}
}
// QQBot is installed as an official external channel plugin for this OpenClaw version.
if (storedChannelType === 'feishu') {
const installResult = await ensureFeishuPluginInstalled();
if (!installResult.installed) {
@@ -1489,6 +1513,7 @@ export async function handleChannelRoutes(
const existingValues = await getChannelFormValues(body.channelType, body.accountId);
if (isSameConfigValues(existingValues, body.config)) {
await ensureScopedChannelBinding(body.channelType, body.accountId);
scheduleGatewayChannelSaveRefresh(ctx, storedChannelType, `channel:saveConfigNoChange:${storedChannelType}`);
sendJson(res, 200, { success: true, noChange: true });
return true;
}
+6 -16
View File
@@ -33,7 +33,7 @@ import { buildProxyEnv, resolveProxySettings } from '../utils/proxy';
import { syncProxyConfigToOpenClaw } from '../utils/openclaw-proxy';
import { logger } from '../utils/logger';
import { prependPathEntry } from '../utils/env-path';
import { copyPluginFromNodeModules, fixupPluginManifest, cpSyncSafe } from '../utils/plugin-install';
import { copyPluginFromNodeModules, fixupPluginManifest, cpSyncSafe, buildCandidateSources } from '../utils/plugin-install';
import { stripSystemdSupervisorEnv } from './config-sync-env';
import { cleanupAgentsSymlinkedSkills, cleanupStalePluginRuntimeDeps } from './skills-symlink-cleanup';
import {
@@ -71,6 +71,9 @@ const CHANNEL_PLUGIN_MAP: Record<string, { dirName: string; npmName: string }> =
dingtalk: { dirName: 'dingtalk', npmName: '@soimy/dingtalk' },
wecom: { dirName: 'wecom', npmName: '@wecom/wecom-openclaw-plugin' },
feishu: { dirName: 'feishu-openclaw-plugin', npmName: '@larksuite/openclaw-lark' },
discord: { dirName: 'discord', npmName: '@openclaw/discord' },
qqbot: { dirName: 'qqbot', npmName: '@openclaw/qqbot' },
whatsapp: { dirName: 'whatsapp', npmName: '@openclaw/whatsapp' },
'openclaw-weixin': { dirName: 'openclaw-weixin', npmName: '@tencent-weixin/openclaw-weixin' },
};
@@ -107,19 +110,6 @@ function readPluginVersion(pkgJsonPath: string): string | null {
}
}
function buildBundledPluginSources(pluginDirName: string): string[] {
return app.isPackaged
? [
join(process.resourcesPath, 'openclaw-plugins', pluginDirName),
join(process.resourcesPath, 'app.asar.unpacked', 'build', 'openclaw-plugins', pluginDirName),
join(process.resourcesPath, 'app.asar.unpacked', 'openclaw-plugins', pluginDirName),
]
: [
join(app.getAppPath(), 'build', 'openclaw-plugins', pluginDirName),
join(process.cwd(), 'build', 'openclaw-plugins', pluginDirName),
];
}
function measureSync<T>(timings: Record<string, number>, key: string, fn: () => T): T {
const startedAt = Date.now();
try {
@@ -164,7 +154,7 @@ function ensureConfiguredPluginsUpgraded(configuredChannels: string[]): boolean
const installedVersion = isInstalled ? readPluginVersion(join(targetDir, 'package.json')) : null;
// Try bundled sources first (packaged mode or if bundle-plugins was run)
const bundledSources = buildBundledPluginSources(dirName);
const bundledSources = buildCandidateSources(dirName);
const bundledDir = bundledSources.find((dir) => existsSync(fsPath(join(dir, 'openclaw.plugin.json'))));
if (bundledDir) {
@@ -249,7 +239,7 @@ function buildPluginSourceSignatures(configuredChannels: string[]): Record<strin
for (const channelType of [...configuredChannels].sort()) {
const pluginInfo = CHANNEL_PLUGIN_MAP[channelType];
if (!pluginInfo) continue;
const bundledSources = buildBundledPluginSources(pluginInfo.dirName);
const bundledSources = buildCandidateSources(pluginInfo.dirName);
const bundledDir = bundledSources.find((dir) => existsSync(fsPath(join(dir, 'openclaw.plugin.json'))));
const devPkgPath = join(process.cwd(), 'node_modules', ...pluginInfo.npmName.split('/'));
const sourceDir = bundledDir || (!app.isPackaged ? devPkgPath : '');
+4 -2
View File
@@ -102,6 +102,8 @@ export async function waitForGatewayReady(options: {
throw new Error(`Gateway failed to start after ${retries} retries (port ${options.port})`);
}
const GATEWAY_PROTOCOL_VERSION = 4;
export function buildGatewayConnectFrame(options: {
challengeNonce: string;
token: string;
@@ -145,8 +147,8 @@ export function buildGatewayConnectFrame(options: {
id: connectId,
method: 'connect',
params: {
minProtocol: 3,
maxProtocol: 3,
minProtocol: GATEWAY_PROTOCOL_VERSION,
maxProtocol: GATEWAY_PROTOCOL_VERSION,
client: {
id: clientId,
displayName: 'ClawX',
+119 -20
View File
@@ -32,6 +32,28 @@ const DEFAULT_ACCOUNT_ID = 'default';
// schema validation errors. ClawX falls back to DEFAULT_ACCOUNT_ID
// when `defaultAccount` is absent.
const CHANNELS_OMIT_DEFAULT_ACCOUNT_KEY = new Set(['dingtalk']);
// Channels whose schema accepts a top-level default account and account map,
// but whose account payload contains nested strict-schema objects that ClawX
// can accidentally make invalid by adding UI convenience fields. Keep this
// sanitization narrowly scoped to known nested maps so local config remains
// OpenClaw-compatible after a save.
const DISCORD_GUILD_CHANNEL_KEYS_TO_KEEP = new Set([
'autoArchiveDuration',
'autoThread',
'autoThreadName',
'enabled',
'ignoreOtherMentions',
'includeThreadStarter',
'requireMention',
'roles',
'skills',
'systemPrompt',
'tools',
'toolsBySender',
'users',
]);
const DISCORD_CHANNEL_ALLOW_FLAG_KEYS = new Set(['allow']);
const CHANNEL_TOP_LEVEL_KEYS_TO_KEEP = new Set(['accounts', 'defaultAccount', 'enabled']);
const WECHAT_STATE_DIR = join(OPENCLAW_DIR, WECHAT_PLUGIN_ID);
const WECHAT_ACCOUNT_INDEX_FILE = join(WECHAT_STATE_DIR, 'accounts.json');
@@ -40,8 +62,8 @@ const LEGACY_WECHAT_CREDENTIALS_DIR = join(OPENCLAW_DIR, 'credentials', WECHAT_P
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)
const PLUGIN_CHANNELS: string[] = [];
const LEGACY_BUILTIN_CHANNEL_PLUGIN_IDS = new Set(['whatsapp']);
const PLUGIN_CHANNELS: string[] = ['discord', 'qqbot', 'whatsapp'];
const LEGACY_BUILTIN_CHANNEL_PLUGIN_IDS = new Set<string>();
const BUILTIN_CHANNEL_IDS = new Set([
'discord',
'telegram',
@@ -78,6 +100,57 @@ const CHANNEL_UNIQUE_CREDENTIAL_KEY: Record<string, string> = {
// ── Helpers ──────────────────────────────────────────────────────
function sanitizeDiscordGuildChannelConfig(channelConfig: unknown): void {
if (!channelConfig || typeof channelConfig !== 'object' || Array.isArray(channelConfig)) {
return;
}
const record = channelConfig as Record<string, unknown>;
// Backward compatibility for the older ClawX-generated shape:
// channels: { "123": { allow: true, requireMention: true } }
// OpenClaw's current DiscordGuildChannelConfig does not include `allow`;
// represent deny/allow using `enabled` instead.
if (record.allow === false && record.enabled === undefined) {
record.enabled = false;
}
for (const key of Object.keys(record)) {
if (DISCORD_CHANNEL_ALLOW_FLAG_KEYS.has(key)) {
delete record[key];
continue;
}
if (!DISCORD_GUILD_CHANNEL_KEYS_TO_KEEP.has(key)) {
delete record[key];
}
}
}
function sanitizeDiscordGuilds(config: unknown): void {
if (!config || typeof config !== 'object' || Array.isArray(config)) {
return;
}
const record = config as Record<string, unknown>;
const guilds = record.guilds;
if (!guilds || typeof guilds !== 'object' || Array.isArray(guilds)) {
return;
}
for (const guildConfig of Object.values(guilds as Record<string, unknown>)) {
if (!guildConfig || typeof guildConfig !== 'object' || Array.isArray(guildConfig)) {
continue;
}
const channels = (guildConfig as Record<string, unknown>).channels;
if (!channels || typeof channels !== 'object' || Array.isArray(channels)) {
continue;
}
for (const channelConfig of Object.values(channels as Record<string, unknown>)) {
sanitizeDiscordGuildChannelConfig(channelConfig);
}
}
}
/**
* Strip `defaultAccount` from channel sections whose plugin schema
* declares additionalProperties:false without listing `defaultAccount`.
@@ -92,6 +165,17 @@ function sanitizeChannelSectionsBeforeWrite(config: OpenClawConfig): void {
delete section.defaultAccount;
}
}
const discordSection = config.channels.discord;
if (discordSection) {
sanitizeDiscordGuilds(discordSection);
const accounts = getChannelAccountsMap(discordSection);
if (accounts) {
for (const accountConfig of Object.values(accounts)) {
sanitizeDiscordGuilds(accountConfig);
}
}
}
}
async function fileExists(p: string): Promise<boolean> {
@@ -421,6 +505,10 @@ 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) {
@@ -578,11 +666,11 @@ function transformChannelConfig(
if (channelId && typeof channelId === 'string' && channelId.trim()) {
guildConfig.channels = {
[channelId.trim()]: { allow: true, requireMention: true }
[channelId.trim()]: { requireMention: true }
};
} else {
guildConfig.channels = {
'*': { allow: true, requireMention: true }
'*': { requireMention: true }
};
}
@@ -623,6 +711,16 @@ function transformChannelConfig(
transformedConfig.allowFrom = allowFrom;
}
if (channelType === 'whatsapp') {
// The WhatsApp plugin stores QR/session state on disk and does not
// require static credentials, but the runtime still needs an enabled
// plugin config entry for the channel account to appear in status.
transformedConfig = {
...transformedConfig,
enabled: transformedConfig.enabled ?? true,
};
}
if (channelType === 'dingtalk') {
// The per-account schema uses additionalProperties:false and does
// NOT include these legacy/obsolete fields. Strip them before
@@ -750,22 +848,8 @@ export async function saveChannelConfig(
await ensurePluginAllowlist(currentConfig, resolvedChannelType);
syncBuiltinChannelsWithPluginAllowlist(currentConfig, [resolvedChannelType]);
// Plugin-based channels (e.g. WhatsApp) go under plugins.entries, not channels
if (PLUGIN_CHANNELS.includes(resolvedChannelType)) {
ensurePluginRegistration(currentConfig, resolvedChannelType);
currentConfig.plugins!.entries![resolvedChannelType] = {
...currentConfig.plugins!.entries![resolvedChannelType],
enabled: config.enabled ?? true,
};
await writeOpenClawConfig(currentConfig);
logger.info('Plugin channel config saved', {
channelType: resolvedChannelType,
configFile: CONFIG_FILE,
path: `plugins.entries.${resolvedChannelType}`,
});
console.log(`Saved plugin channel config for ${resolvedChannelType}`);
return;
}
// Plugin-based channels are mirrored into plugins.entries.<id> below,
// but ClawX still keeps channels.<id> as the local account-list source.
if (!currentConfig.channels) {
currentConfig.channels = {};
@@ -812,6 +896,21 @@ 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],
};
}
// Most OpenClaw channel plugins/built-ins also read the default
// account's credentials from the top level of `channels.<type>`
// (e.g. channels.feishu.appId). Mirror them there so the
+42
View File
@@ -2485,6 +2485,48 @@ export async function sanitizeOpenClawConfig(): Promise<void> {
modified = true;
console.log(`[sanitize] Mirrored ${channelType} default account credentials to top-level channels.${channelType}`);
}
if (channelType === 'discord') {
const sanitizeDiscordGuildChannelConfig = (channelConfig: unknown): boolean => {
if (!channelConfig || typeof channelConfig !== 'object' || Array.isArray(channelConfig)) return false;
const channelRecord = channelConfig as Record<string, unknown>;
let channelModified = false;
if (channelRecord.allow === false && channelRecord.enabled === undefined) {
channelRecord.enabled = false;
channelModified = true;
}
for (const key of ['allow']) {
if (key in channelRecord) {
delete channelRecord[key];
channelModified = true;
}
}
return channelModified;
};
const sanitizeDiscordGuilds = (target: Record<string, unknown>): boolean => {
const guilds = target.guilds;
if (!guilds || typeof guilds !== 'object' || Array.isArray(guilds)) return false;
let guildsModified = false;
for (const guildConfig of Object.values(guilds as Record<string, unknown>)) {
if (!guildConfig || typeof guildConfig !== 'object' || Array.isArray(guildConfig)) continue;
const channels = (guildConfig as Record<string, unknown>).channels;
if (!channels || typeof channels !== 'object' || Array.isArray(channels)) continue;
for (const channelConfig of Object.values(channels as Record<string, unknown>)) {
guildsModified = sanitizeDiscordGuildChannelConfig(channelConfig) || guildsModified;
}
}
return guildsModified;
};
const sanitizedTopLevel = sanitizeDiscordGuilds(section);
const sanitizedAccounts = Object.values(accounts ?? {}).some((accountConfig) => (
accountConfig && typeof accountConfig === 'object' && sanitizeDiscordGuilds(accountConfig)
));
if (sanitizedTopLevel || sanitizedAccounts) {
modified = true;
console.log('[sanitize] Removed incompatible Discord channel allow flags');
}
}
}
}
+19 -4
View File
@@ -2,11 +2,8 @@
* Shared OpenClaw Plugin Install Utilities
*
* Provides version-aware install/upgrade logic for bundled OpenClaw plugins
* (DingTalk, WeCom, Feishu, WeChat). Used both at app startup (to auto-upgrade
* (DingTalk, WeCom, Feishu, WeChat, Discord, QQBot, WhatsApp). Used both at app startup (to auto-upgrade
* stale plugins) and when a user configures a channel.
*
* Note: QQBot was moved to a built-in channel in OpenClaw 3.31 and is no longer
* managed as a plugin.
*/
import { app } from 'electron';
import path from 'node:path';
@@ -234,6 +231,9 @@ const PLUGIN_NPM_NAMES: Record<string, string> = {
dingtalk: '@soimy/dingtalk',
wecom: '@wecom/wecom-openclaw-plugin',
'feishu-openclaw-plugin': '@larksuite/openclaw-lark',
discord: '@openclaw/discord',
qqbot: '@openclaw/qqbot',
whatsapp: '@openclaw/whatsapp',
'openclaw-weixin': '@tencent-weixin/openclaw-weixin',
};
@@ -517,6 +517,18 @@ export function ensureWeChatPluginInstalled(): { installed: boolean; warning?: s
return ensurePluginInstalled('openclaw-weixin', buildCandidateSources('openclaw-weixin'), 'WeChat');
}
export function ensureDiscordPluginInstalled(): { installed: boolean; warning?: string } {
return ensurePluginInstalled('discord', buildCandidateSources('discord'), 'Discord');
}
export function ensureQQBotPluginInstalled(): { installed: boolean; warning?: string } {
return ensurePluginInstalled('qqbot', buildCandidateSources('qqbot'), 'QQBot');
}
export function ensureWhatsAppPluginInstalled(): { installed: boolean; warning?: string } {
return ensurePluginInstalled('whatsapp', buildCandidateSources('whatsapp'), 'WhatsApp');
}
// ── Bulk startup installer ───────────────────────────────────────────────────
/**
@@ -528,6 +540,9 @@ const ALL_BUNDLED_PLUGINS = [
{ fn: ensureFeishuPluginInstalled, label: 'Feishu' },
{ fn: ensureWeChatPluginInstalled, label: 'WeChat' },
{ fn: ensureDiscordPluginInstalled, label: 'Discord' },
{ fn: ensureQQBotPluginInstalled, label: 'QQBot' },
{ fn: ensureWhatsAppPluginInstalled, label: 'WhatsApp' },
] as const;
/**
+8 -5
View File
@@ -106,8 +106,11 @@
"@grammyjs/runner": "^2.0.3",
"@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.7",
"@larksuite/openclaw-lark": "2026.4.8",
"@larksuite/openclaw-lark": "2026.5.12",
"@larksuiteoapi/node-sdk": "^1.61.1",
"@openclaw/discord": "2026.5.12",
"@openclaw/qqbot": "2026.5.12",
"@openclaw/whatsapp": "2026.5.12",
"@playwright/test": "^1.56.1",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -121,9 +124,9 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@soimy/dingtalk": "^3.5.3",
"@soimy/dingtalk": "^3.6.2",
"@tencent-connect/qqbot-connector": "^1.1.0",
"@tencent-weixin/openclaw-weixin": "^2.1.9",
"@tencent-weixin/openclaw-weixin": "^2.4.3",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^25.3.0",
@@ -133,7 +136,7 @@
"@typescript-eslint/eslint-plugin": "^8.56.0",
"@typescript-eslint/parser": "^8.56.0",
"@vitejs/plugin-react": "^5.1.4",
"@wecom/wecom-openclaw-plugin": "^2026.4.23",
"@wecom/wecom-openclaw-plugin": "^2026.5.7",
"@whiskeysockets/baileys": "7.0.0-rc.9",
"acpx": "0.5.3",
"autoprefixer": "^10.4.24",
@@ -153,7 +156,7 @@
"jsdom": "^28.1.0",
"lucide-react": "^0.563.0",
"mpg123-decoder": "^1.0.3",
"openclaw": "2026.4.23",
"openclaw": "2026.5.12",
"opusscript": "^0.1.1",
"playwright-core": "1.59.1",
"png2icons": "^2.0.1",
+1112 -915
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -606,6 +606,9 @@ exports.default = async function afterPack(context) {
{ npmName: '@soimy/dingtalk', pluginId: 'dingtalk' },
{ npmName: '@wecom/wecom-openclaw-plugin', pluginId: 'wecom' },
{ npmName: '@larksuite/openclaw-lark', pluginId: 'feishu-openclaw-plugin' },
{ npmName: '@openclaw/discord', pluginId: 'discord' },
{ npmName: '@openclaw/qqbot', pluginId: 'qqbot' },
{ npmName: '@openclaw/whatsapp', pluginId: 'whatsapp' },
{ npmName: '@tencent-weixin/openclaw-weixin', pluginId: 'openclaw-weixin' },
];
+6
View File
@@ -7,6 +7,9 @@
* Current plugins:
* - @soimy/dingtalk -> build/openclaw-plugins/dingtalk
* - @wecom/wecom-openclaw-plugin -> build/openclaw-plugins/wecom
* - @openclaw/discord -> build/openclaw-plugins/discord
* - @openclaw/qqbot -> build/openclaw-plugins/qqbot
* - @openclaw/whatsapp -> build/openclaw-plugins/whatsapp
* - @tencent-weixin/openclaw-weixin -> build/openclaw-plugins/openclaw-weixin
*
* The output plugin directory contains:
@@ -39,6 +42,9 @@ const PLUGINS = [
{ npmName: '@soimy/dingtalk', pluginId: 'dingtalk' },
{ npmName: '@wecom/wecom-openclaw-plugin', pluginId: 'wecom' },
{ npmName: '@larksuite/openclaw-lark', pluginId: 'feishu-openclaw-plugin' },
{ npmName: '@openclaw/discord', pluginId: 'discord' },
{ npmName: '@openclaw/qqbot', pluginId: 'qqbot' },
{ npmName: '@openclaw/whatsapp', pluginId: 'whatsapp' },
{ npmName: '@tencent-weixin/openclaw-weixin', pluginId: 'openclaw-weixin' },
];
+3 -17
View File
@@ -81,7 +81,7 @@ export function ChannelConfigModal({
onChannelSaved,
}: ChannelConfigModalProps) {
const { t } = useTranslation('channels');
const { channels, addChannel, fetchChannels } = useChannelsStore();
const { fetchChannels } = useChannelsStore();
const [selectedType, setSelectedType] = useState<ChannelType | null>(initialSelectedType);
const [configValues, setConfigValues] = useState<Record<string, string>>({});
const [channelName, setChannelName] = useState('');
@@ -192,23 +192,9 @@ export function ChannelConfigModal({
}, [selectedType, loadingConfig, showChannelName]);
const finishSave = useCallback(async (channelType: ChannelType) => {
const displayName = showChannelName && channelName.trim()
? channelName.trim()
: CHANNEL_NAMES[channelType];
const existingChannel = channels.find((channel) => channel.type === channelType);
if (!existingChannel) {
await addChannel({
type: channelType,
name: displayName,
token: meta?.configFields[0]?.key ? configValues[meta.configFields[0].key] : undefined,
});
} else {
await fetchChannels();
}
await fetchChannels();
await onChannelSaved?.(channelType);
}, [addChannel, channelName, channels, configValues, fetchChannels, meta?.configFields, onChannelSaved, showChannelName]);
}, [fetchChannels, onChannelSaved]);
const finishSaveRef = useRef(finishSave);
const onCloseRef = useRef(onClose);
+84 -12
View File
@@ -188,21 +188,24 @@ describe('WeCom plugin configuration', () => {
expect(plugins.entries['feishu-openclaw-plugin']).toBeUndefined();
});
it('saves whatsapp as a built-in channel instead of a plugin', async () => {
it('saves whatsapp as an external plugin-backed channel', async () => {
const { saveChannelConfig } = await import('@electron/utils/channel-config');
await saveChannelConfig('whatsapp', { enabled: true }, 'default');
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 }> }> };
expect(channels.whatsapp.enabled).toBe(true);
expect(channels.whatsapp.defaultAccount).toBe('default');
expect(channels.whatsapp.accounts?.default?.enabled).toBe(true);
expect(config.plugins).toBeUndefined();
expect(plugins.allow).toContain('whatsapp');
expect(plugins.entries.whatsapp.enabled).toBe(true);
expect(plugins.entries.whatsapp.accounts?.default?.enabled).toBe(true);
});
it('cleans up stale whatsapp plugin registration when saving built-in config', async () => {
it('keeps whatsapp plugin registration when saving plugin-backed config', async () => {
const { saveChannelConfig, writeOpenClawConfig } = await import('@electron/utils/channel-config');
await writeOpenClawConfig({
@@ -218,12 +221,15 @@ describe('WeCom plugin configuration', () => {
await saveChannelConfig('whatsapp', { enabled: true }, 'default');
const config = await readOpenClawJson();
expect(config.plugins).toBeUndefined();
const channels = config.channels as Record<string, { enabled?: boolean }>;
const plugins = config.plugins as { allow?: string[]; entries?: Record<string, { enabled?: boolean }> };
expect(channels.whatsapp.enabled).toBe(true);
expect(plugins.allow).toContain('whatsapp');
expect(plugins.entries?.whatsapp?.enabled).toBe(true);
});
it('saves qqbot as a built-in channel without plugin registration (OpenClaw 3.31+)', async () => {
it('saves qqbot and discord as external plugin-backed channels', async () => {
const { saveChannelConfig } = await import('@electron/utils/channel-config');
await saveChannelConfig('discord', { token: 'discord-token' }, 'default');
@@ -232,16 +238,82 @@ 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> }> };
// QQBot config should be saved under channels.qqbot
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();
});
// QQBot should NOT appear in plugins.entries (built-in channel)
const plugins = config.plugins as { entries?: Record<string, unknown> } | undefined;
if (plugins?.entries) {
expect(plugins.entries['openclaw-qqbot']).toBeUndefined();
expect(plugins.entries['qqbot']).toBeUndefined();
}
it('saves discord guild channel allowlist without schema-invalid allow flags', async () => {
const { saveChannelConfig } = await import('@electron/utils/channel-config');
await saveChannelConfig(
'discord',
{ token: 'discord-token', guildId: '1438451181474287618', channelId: '1438452657525100686' },
'default',
);
const config = await readOpenClawJson();
const channels = config.channels as Record<string, {
guilds?: Record<string, { channels?: Record<string, Record<string, unknown>> }>;
accounts?: Record<string, {
guilds?: Record<string, { channels?: Record<string, Record<string, unknown>> }>;
}>;
}>;
const topLevelChannel = channels.discord.guilds?.['1438451181474287618'].channels?.['1438452657525100686'];
const accountChannel = channels.discord.accounts?.default.guilds?.['1438451181474287618'].channels?.['1438452657525100686'];
expect(topLevelChannel).toEqual({ requireMention: true });
expect(accountChannel).toEqual({ requireMention: true });
});
it('sanitizes legacy discord guild channel allow flags before writing', async () => {
const { saveChannelConfig, writeOpenClawConfig } = await import('@electron/utils/channel-config');
await writeOpenClawConfig({
channels: {
discord: {
enabled: true,
defaultAccount: 'default',
token: 'discord-token',
guilds: {
'1438451181474287618': {
channels: {
'*': { allow: true, requireMention: true },
},
},
},
accounts: {
default: {
token: 'discord-token',
guilds: {
'1438451181474287618': {
channels: {
'*': { allow: true, requireMention: true },
},
},
},
},
},
},
},
});
await saveChannelConfig('discord', { token: 'discord-token', guildId: '1438451181474287618' }, 'default');
const config = await readOpenClawJson();
const channels = config.channels as Record<string, {
guilds?: Record<string, { channels?: Record<string, Record<string, unknown>> }>;
accounts?: Record<string, {
guilds?: Record<string, { channels?: Record<string, Record<string, unknown>> }>;
}>;
}>;
expect(channels.discord.guilds?.['1438451181474287618'].channels?.['*']).not.toHaveProperty('allow');
expect(channels.discord.accounts?.default.guilds?.['1438451181474287618'].channels?.['*']).not.toHaveProperty('allow');
});
});
+46 -1
View File
@@ -11,6 +11,7 @@ const listAgentsSnapshotMock = vi.fn();
const sendJsonMock = vi.fn();
const proxyAwareFetchMock = vi.fn();
const saveChannelConfigMock = vi.fn();
const getChannelFormValuesMock = vi.fn();
const setChannelDefaultAccountMock = vi.fn();
const assignChannelAccountToAgentMock = vi.fn();
const clearChannelBindingMock = vi.fn();
@@ -21,7 +22,7 @@ vi.mock('@electron/utils/channel-config', () => ({
cleanupDanglingWeChatPluginState: vi.fn(),
deleteChannelAccountConfig: vi.fn(),
deleteChannelConfig: vi.fn(),
getChannelFormValues: vi.fn(),
getChannelFormValues: (...args: unknown[]) => getChannelFormValuesMock(...args),
listConfiguredChannelAccounts: (...args: unknown[]) => listConfiguredChannelAccountsMock(...args),
listConfiguredChannelAccountsFromConfig: (...args: unknown[]) => listConfiguredChannelAccountsMock(...args),
listConfiguredChannels: (...args: unknown[]) => listConfiguredChannelsMock(...args),
@@ -43,10 +44,13 @@ vi.mock('@electron/utils/agent-config', () => ({
}));
vi.mock('@electron/utils/plugin-install', () => ({
ensureDiscordPluginInstalled: vi.fn(),
ensureDingTalkPluginInstalled: vi.fn(),
ensureFeishuPluginInstalled: vi.fn(),
ensureQQBotPluginInstalled: vi.fn(),
ensureWeChatPluginInstalled: vi.fn(),
ensureWeComPluginInstalled: vi.fn(),
ensureWhatsAppPluginInstalled: vi.fn(),
}));
vi.mock('@electron/utils/wechat-login', () => ({
@@ -99,6 +103,7 @@ describe('handleChannelRoutes', () => {
rmSync(testOpenClawConfigDir, { recursive: true, force: true });
proxyAwareFetchMock.mockReset();
parseJsonBodyMock.mockResolvedValue({});
getChannelFormValuesMock.mockResolvedValue(undefined);
listConfiguredChannelAccountsMock.mockReturnValue({});
listAgentsSnapshotMock.mockResolvedValue({
agents: [],
@@ -1389,4 +1394,44 @@ describe('handleChannelRoutes', () => {
}),
);
});
it('restarts gateway after a no-change channel config save', async () => {
parseJsonBodyMock.mockResolvedValue({
channelType: 'telegram',
accountId: 'default',
config: { botToken: 'telegram-token', allowedUsers: '123456' },
});
getChannelFormValuesMock.mockResolvedValue({ botToken: 'telegram-token', allowedUsers: '123456' });
listConfiguredChannelAccountsMock.mockReturnValue({
telegram: {
defaultAccountId: 'default',
accountIds: ['default'],
},
});
const debouncedRestart = vi.fn();
const { handleChannelRoutes } = await import('@electron/api/routes/channels');
const handled = await handleChannelRoutes(
{ method: 'POST' } as IncomingMessage,
{} as ServerResponse,
new URL('http://127.0.0.1:13210/api/channels/config'),
{
gatewayManager: {
rpc: vi.fn(),
getStatus: () => ({ state: 'running' }),
debouncedReload: vi.fn(),
debouncedRestart,
},
} as never,
);
expect(handled).toBe(true);
expect(saveChannelConfigMock).not.toHaveBeenCalled();
expect(debouncedRestart).toHaveBeenCalledWith(150);
expect(sendJsonMock).toHaveBeenCalledWith(
expect.anything(),
200,
expect.objectContaining({ success: true, noChange: true }),
);
});
});
+4
View File
@@ -131,6 +131,10 @@ describe('connectGatewaySocket', () => {
expect(socket.sentFrames).toHaveLength(1);
const connectFrame = JSON.parse(socket.sentFrames[0]) as { id: string; method: string };
expect(connectFrame.method).toBe('connect');
expect((connectFrame as { params?: { minProtocol?: number; maxProtocol?: number } }).params).toMatchObject({
minProtocol: 4,
maxProtocol: 4,
});
expect(pendingRequests.size).toBe(1);
await vi.advanceTimersByTimeAsync(GATEWAY_CONNECT_HANDSHAKE_TIMEOUT_MS - 1_000);