mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 00:48:10 +00:00
Upgrade openclaw to 5.12 (#1023)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 : '');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Generated
+1112
-915
File diff suppressed because it is too large
Load Diff
@@ -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' },
|
||||
];
|
||||
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user