diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c4179a1b5c..d036051bd82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,9 @@ Docs: https://docs.openclaw.ai - Feishu: keep synthetic card-action and bot-menu ids out of platform reply targets, using the real card callback message id when Feishu provides one and plain-sending otherwise. Fixes #71673. Thanks @eddy1068. +- Plugins/QQ Bot: prefer an installed QQ Bot plugin that declares it replaces + the bundled `qqbot` channel, preventing duplicate `qqbot_channel_api` and + `qqbot_remind` tool registration noise. Fixes #63102. - QQ Bot: make `qqbot_remind` schedule, list, and remove Gateway cron jobs directly for owner-authorized senders instead of returning `cronParams` and relying on a follow-up generic `cron` tool call. Fixes #70865. (#70937) diff --git a/docs/plugins/community.md b/docs/plugins/community.md index 76739fe2da9..7377194f546 100644 --- a/docs/plugins/community.md +++ b/docs/plugins/community.md @@ -105,6 +105,10 @@ Connect OpenClaw to QQ via the QQ Bot API. Supports private chats, group mentions, channel messages, and rich media including voice, images, videos, and files. +Current OpenClaw releases bundle QQ Bot. Use the bundled setup in +[QQ Bot](/channels/qqbot) for normal installs; install this external plugin only +when you intentionally want the Tencent-maintained standalone package. + - **npm:** `@tencent-connect/openclaw-qqbot` - **repo:** [github.com/tencent-connect/openclaw-qqbot](https://github.com/tencent-connect/openclaw-qqbot) diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index b0d95de847d..b61f15d9593 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -508,6 +508,11 @@ runtime loads. Read-only channel setup/status discovery can use this metadata directly for configured external channels when no setup entry is available, or when `setup.requiresRuntime: false` declares setup runtime unnecessary. +`channelConfigs` is plugin manifest metadata, not a new top-level user config +section. Users still configure channel instances under `channels.`. +OpenClaw reads manifest metadata to decide which plugin owns that configured +channel before plugin runtime code executes. + For a channel plugin, `configSchema` and `channelConfigs` describe different paths: @@ -554,6 +559,43 @@ Each channel entry can include: | `description` | `string` | Short channel description for inspect and catalog surfaces. | | `preferOver` | `string[]` | Legacy or lower-priority plugin ids this channel should outrank in selection surfaces. | +### Replacing another channel plugin + +Use `preferOver` when your plugin is the preferred owner for a channel id that +another plugin can also provide. Common cases are a renamed plugin id, a +standalone plugin that supersedes a bundled plugin, or a maintained fork that +keeps the same channel id for config compatibility. + +```json +{ + "id": "acme-chat", + "channels": ["chat"], + "channelConfigs": { + "chat": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "webhookUrl": { "type": "string" } + } + }, + "preferOver": ["chat"] + } + } +} +``` + +When `channels.chat` is configured, OpenClaw considers both the channel id and +the preferred plugin id. If the lower-priority plugin was only selected because +it is bundled or enabled by default, OpenClaw disables it in the effective +runtime config so one plugin owns the channel and its tools. Explicit user +selection still wins: if the user explicitly enables both plugins, OpenClaw +preserves that choice and reports duplicate channel/tool diagnostics instead of +silently changing the requested plugin set. + +Keep `preferOver` scoped to plugin ids that can really provide the same channel. +It is not a general priority field and it does not rename user config keys. + ## modelSupport reference Use `modelSupport` when OpenClaw should infer your provider plugin from diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index fb36bef7578..a4eda7a8877 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -232,6 +232,40 @@ do not run in live chat traffic, check these first: Gateway session/status surfaces and, when debugging provider payloads, start the Gateway with `--raw-stream --raw-stream-path `. +### Duplicate channel or tool ownership + +Symptoms: + +- `channel already registered: ()` +- `channel setup already registered: ()` +- `plugin tool name conflict (): ` + +These mean more than one enabled plugin is trying to own the same channel, +setup flow, or tool name. The most common cause is an external channel plugin +installed beside a bundled plugin that now provides the same channel id. + +Debug steps: + +- Run `openclaw plugins list --enabled --verbose` to see every enabled plugin + and origin. +- Run `openclaw plugins inspect --json` for each suspected plugin and + compare `channels`, `channelConfigs`, `tools`, and diagnostics. +- Run `openclaw plugins registry --refresh` after installing or removing + plugin packages so persisted metadata reflects the current install. +- Restart the Gateway after install, registry, or config changes. + +Fix options: + +- If one plugin intentionally replaces another for the same channel id, the + preferred plugin should declare `channelConfigs..preferOver` with + the lower-priority plugin id. See [/plugins/manifest#replacing-another-channel-plugin](/plugins/manifest#replacing-another-channel-plugin). +- If the duplicate is accidental, disable one side with + `plugins.entries..enabled: false` or remove the stale plugin + install. +- If you explicitly enabled both plugins, OpenClaw keeps that request and + reports the conflict. Pick one owner for the channel or rename plugin-owned + tools so the runtime surface is unambiguous. + ## Plugin slots (exclusive categories) Some categories are exclusive (only one active at a time): diff --git a/src/config/plugin-auto-enable.channels.test.ts b/src/config/plugin-auto-enable.channels.test.ts index 750b776a9e4..b75dc1dcaee 100644 --- a/src/config/plugin-auto-enable.channels.test.ts +++ b/src/config/plugin-auto-enable.channels.test.ts @@ -207,6 +207,90 @@ describe("applyPluginAutoEnable channels", () => { expect(result.changes).toEqual([]); }); + it("prefers an external plugin that declares preferOver for a bundled channel", () => { + const result = applyPluginAutoEnable({ + config: { + channels: { qqbot: { appId: "app", clientSecret: "secret" } }, + }, + env: makeIsolatedEnv(), + manifestRegistry: makeRegistry([ + { id: "qqbot", channels: ["qqbot"] }, + { + id: "openclaw-qqbot", + channels: ["qqbot"], + channelConfigs: { + qqbot: { + schema: { type: "object" }, + preferOver: ["qqbot"], + }, + }, + }, + ]), + }); + + expect(result.config.plugins?.entries?.["openclaw-qqbot"]?.enabled).toBe(true); + expect(result.config.plugins?.entries?.qqbot?.enabled).toBe(false); + expect(result.changes.join("\n")).toContain("QQ Bot configured, enabled automatically."); + }); + + it("falls back to the bundled channel when the preferred external plugin is disabled", () => { + const result = applyPluginAutoEnable({ + config: { + channels: { qqbot: { appId: "app", clientSecret: "secret" } }, + plugins: { entries: { "openclaw-qqbot": { enabled: false } } }, + }, + env: makeIsolatedEnv(), + manifestRegistry: makeRegistry([ + { id: "qqbot", channels: ["qqbot"] }, + { + id: "openclaw-qqbot", + channels: ["qqbot"], + channelConfigs: { + qqbot: { + schema: { type: "object" }, + preferOver: ["qqbot"], + }, + }, + }, + ]), + }); + + expect(result.config.plugins?.entries?.["openclaw-qqbot"]?.enabled).toBe(false); + expect(result.config.plugins?.entries?.qqbot).toBeUndefined(); + expect(result.config.channels?.qqbot?.enabled).toBe(true); + expect(result.changes.join("\n")).toContain("QQ Bot configured, enabled automatically."); + }); + + it("does not auto-disable a lower-priority channel plugin that was explicitly selected", () => { + const result = applyPluginAutoEnable({ + config: { + channels: { qqbot: { appId: "app", clientSecret: "secret" } }, + plugins: { + entries: { + qqbot: { enabled: true }, + }, + }, + }, + env: makeIsolatedEnv(), + manifestRegistry: makeRegistry([ + { id: "qqbot", channels: ["qqbot"] }, + { + id: "openclaw-qqbot", + channels: ["qqbot"], + channelConfigs: { + qqbot: { + schema: { type: "object" }, + preferOver: ["qqbot"], + }, + }, + }, + ]), + }); + + expect(result.config.plugins?.entries?.["openclaw-qqbot"]?.enabled).toBe(true); + expect(result.config.plugins?.entries?.qqbot?.enabled).toBe(true); + }); + it("falls back to channel key as plugin id when no installed manifest declares the channel", () => { const result = applyPluginAutoEnable({ config: { @@ -246,7 +330,7 @@ describe("applyPluginAutoEnable channels", () => { }); expect(result.config.plugins?.entries?.primary?.enabled).toBe(true); - expect(result.config.plugins?.entries?.secondary?.enabled).toBeUndefined(); + expect(result.config.plugins?.entries?.secondary?.enabled).toBe(false); expect(result.changes.join("\n")).toContain("primary configured, enabled automatically."); expect(result.changes.join("\n")).not.toContain( "secondary configured, enabled automatically.", @@ -257,7 +341,7 @@ describe("applyPluginAutoEnable channels", () => { const result = applyWithBluebubblesImessageConfig(); expect(result.config.channels?.bluebubbles?.enabled).toBe(true); - expect(result.config.plugins?.entries?.imessage?.enabled).toBeUndefined(); + expect(result.config.plugins?.entries?.imessage?.enabled).toBe(false); expect(result.changes.join("\n")).toContain("BlueBubbles configured, enabled automatically."); expect(result.changes.join("\n")).not.toContain( "iMessage configured, enabled automatically.", diff --git a/src/config/plugin-auto-enable.shared.ts b/src/config/plugin-auto-enable.shared.ts index eda5b854b94..c24d593bfb9 100644 --- a/src/config/plugin-auto-enable.shared.ts +++ b/src/config/plugin-auto-enable.shared.ts @@ -219,27 +219,60 @@ function resolvePluginIdForConfiguredWebFetchProvider( }); } -function buildChannelToPluginIdMap(registry: PluginManifestRegistry): Map { - const map = new Map(); +function normalizeManifestChannelId(channelId: string): string { + return normalizeChatChannelId(channelId) ?? channelId; +} + +function getManifestChannelPreferOver( + plugin: PluginManifestRecord, + channelId: string, +): readonly string[] { + return plugin.channelConfigs?.[channelId]?.preferOver ?? []; +} + +function collectPluginIdsForConfiguredChannel( + channelId: string, + registry: PluginManifestRegistry, +): string[] { + const normalizedChannelId = normalizeManifestChannelId(channelId); + const builtInId = normalizeChatChannelId(normalizedChannelId); + const claims: Array<{ plugin: PluginManifestRecord; preferOver: readonly string[] }> = []; for (const record of registry.plugins) { - for (const channelId of record.channels ?? []) { - if (channelId && !map.has(channelId)) { - map.set(channelId, record.id); + if ( + (record.channels ?? []).some((id) => normalizeManifestChannelId(id) === normalizedChannelId) + ) { + claims.push({ + plugin: record, + preferOver: getManifestChannelPreferOver(record, normalizedChannelId), + }); + } + } + + if (claims.length === 0) { + return [builtInId ?? normalizedChannelId]; + } + + const claimIds = new Set(claims.map((claim) => claim.plugin.id)); + if (builtInId) { + claimIds.add(builtInId); + } + const preferredIds = new Set(); + for (const claim of claims) { + for (const preferredOverId of claim.preferOver) { + if (claimIds.has(preferredOverId)) { + // Keep both sides as candidates. The preferOver filter later disables + // the lower-priority plugin unless the preferred plugin is explicitly + // disabled/denied, preserving fallback to bundled channel support. + preferredIds.add(claim.plugin.id); + preferredIds.add(preferredOverId); } } } - return map; -} -function resolvePluginIdForChannel( - channelId: string, - channelToPluginId: ReadonlyMap, -): string { - const builtInId = normalizeChatChannelId(channelId); - if (builtInId) { - return builtInId; + if (preferredIds.size > 0) { + return [...preferredIds].toSorted((left, right) => left.localeCompare(right)); } - return channelToPluginId.get(channelId) ?? channelId; + return [builtInId ?? claims[0]?.plugin.id ?? normalizedChannelId]; } function collectCandidateChannelIds(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): string[] { @@ -389,9 +422,7 @@ function configMayNeedPluginManifestRegistry(cfg: OpenClawConfig, env: NodeJS.Pr if (key === "defaults" || key === "modelByChannel") { continue; } - if (!normalizeChatChannelId(key)) { - return true; - } + return true; } return false; } @@ -459,11 +490,11 @@ export function resolveConfiguredPluginAutoEnableCandidates(params: { registry: PluginManifestRegistry; }): PluginAutoEnableCandidate[] { const changes: PluginAutoEnableCandidate[] = []; - const channelToPluginId = buildChannelToPluginIdMap(params.registry); for (const channelId of collectCandidateChannelIds(params.config, params.env)) { - const pluginId = resolvePluginIdForChannel(channelId, channelToPluginId); if (isChannelConfigured(params.config, channelId, params.env)) { - changes.push({ pluginId, kind: "channel-configured", channelId }); + for (const pluginId of collectPluginIdsForConfiguredChannel(channelId, params.registry)) { + changes.push({ pluginId, kind: "channel-configured", channelId }); + } } } @@ -582,6 +613,45 @@ function isPluginDenied(cfg: OpenClawConfig, pluginId: string): boolean { return Array.isArray(deny) && deny.includes(pluginId); } +function isPluginExplicitlySelected(cfg: OpenClawConfig, pluginId: string): boolean { + const allow = cfg.plugins?.allow; + if (Array.isArray(allow) && allow.includes(pluginId)) { + return true; + } + return hasMaterialPluginEntryConfig(cfg.plugins?.entries?.[pluginId]); +} + +function disableImplicitPreferredOverPlugin(params: { + config: OpenClawConfig; + originalConfig: OpenClawConfig; + pluginId: string; + manifestRegistry: PluginManifestRegistry; +}): OpenClawConfig { + if (isPluginExplicitlySelected(params.originalConfig, params.pluginId)) { + return params.config; + } + if ( + !normalizeChatChannelId(params.pluginId) && + !isKnownPluginId(params.pluginId, params.manifestRegistry) + ) { + return params.config; + } + const existingEntry = params.config.plugins?.entries?.[params.pluginId]; + return { + ...params.config, + plugins: { + ...params.config.plugins, + entries: { + ...params.config.plugins?.entries, + [params.pluginId]: { + ...(existingEntry && typeof existingEntry === "object" ? existingEntry : {}), + enabled: false, + }, + }, + }, + }; +} + function isBuiltInChannelAlreadyEnabled(cfg: OpenClawConfig, channelId: string): boolean { const channels = cfg.channels as Record | undefined; const channelConfig = channels?.[channelId]; @@ -753,6 +823,12 @@ export function materializePluginAutoEnableCandidatesInternal(params: { preferOverCache, }) ) { + next = disableImplicitPreferredOverPlugin({ + config: next, + originalConfig: params.config ?? {}, + pluginId: entry.pluginId, + manifestRegistry: params.manifestRegistry, + }); continue; } diff --git a/src/plugins/loader.prefer-over.test.ts b/src/plugins/loader.prefer-over.test.ts new file mode 100644 index 00000000000..530e382842a --- /dev/null +++ b/src/plugins/loader.prefer-over.test.ts @@ -0,0 +1,185 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; +import { clearPluginDiscoveryCache } from "./discovery.js"; +import { clearPluginLoaderCache, loadOpenClawPlugins } from "./loader.js"; +import { clearPluginManifestRegistryCache } from "./manifest-registry.js"; +import { resetPluginRuntimeStateForTest } from "./runtime.js"; + +const tempDirs: string[] = []; + +function makeTempDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-prefer-over-")); + if (process.platform !== "win32") { + fs.chmodSync(dir, 0o755); + } + tempDirs.push(dir); + return dir; +} + +function writeChannelToolPlugin(params: { + rootDir: string; + id: string; + channelId: string; + enabledByDefault?: boolean; + preferOver?: string[]; +}): string { + const pluginDir = path.join(params.rootDir, params.id); + fs.mkdirSync(pluginDir, { recursive: true }); + if (process.platform !== "win32") { + fs.chmodSync(pluginDir, 0o755); + } + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: params.id, + channels: [params.channelId], + ...(params.enabledByDefault ? { enabledByDefault: true } : {}), + channelConfigs: { + [params.channelId]: { + schema: { type: "object" }, + ...(params.preferOver ? { preferOver: params.preferOver } : {}), + }, + }, + configSchema: { type: "object", additionalProperties: false, properties: {} }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "index.cjs"), + `module.exports = { + id: ${JSON.stringify(params.id)}, + register(api) { + api.registerChannel({ + plugin: { + id: ${JSON.stringify(params.channelId)}, + meta: { + id: ${JSON.stringify(params.channelId)}, + label: ${JSON.stringify(params.channelId)}, + selectionLabel: ${JSON.stringify(params.channelId)}, + docsPath: ${JSON.stringify(`/channels/${params.channelId}`)}, + blurb: "fixture channel", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({ accountId: "default" }), + }, + outbound: { deliveryMode: "direct" }, + }, + }); + api.registerTool({ + name: "qqbot_remind", + description: "fixture", + parameters: { type: "object", properties: {} }, + execute() { return { content: [{ type: "text", text: "ok" }] }; }, + }, { name: "qqbot_remind" }); + }, + };`, + "utf-8", + ); + return pluginDir; +} + +afterEach(() => { + clearPluginLoaderCache(); + clearPluginDiscoveryCache(); + clearPluginManifestRegistryCache(); + resetPluginRuntimeStateForTest(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("plugin loader preferOver activation", () => { + it("loads the preferred external channel plugin without the replaced bundled plugin tools", () => { + const bundledRoot = makeTempDir(); + writeChannelToolPlugin({ + rootDir: bundledRoot, + id: "qqbot", + channelId: "qqbot", + enabledByDefault: true, + }); + const externalRoot = makeTempDir(); + const externalPluginDir = writeChannelToolPlugin({ + rootDir: externalRoot, + id: "openclaw-qqbot", + channelId: "qqbot", + preferOver: ["qqbot"], + }); + const env = { + OPENCLAW_STATE_DIR: makeTempDir(), + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot, + }; + const rawConfig = { + channels: { qqbot: { appId: "app", clientSecret: "secret" } }, + plugins: { load: { paths: [externalPluginDir] } }, + }; + const autoEnabled = applyPluginAutoEnable({ config: rawConfig, env }); + + const registry = loadOpenClawPlugins({ + cache: false, + config: autoEnabled.config, + activationSourceConfig: rawConfig, + autoEnabledReasons: autoEnabled.autoEnabledReasons, + env, + }); + + expect(autoEnabled.config.plugins?.entries?.["openclaw-qqbot"]?.enabled).toBe(true); + expect(autoEnabled.config.plugins?.entries?.qqbot?.enabled).toBe(false); + expect(registry.plugins.find((plugin) => plugin.id === "openclaw-qqbot")?.status).toBe( + "loaded", + ); + expect(registry.plugins.find((plugin) => plugin.id === "qqbot")?.status).toBe("disabled"); + expect(registry.tools.map((tool) => tool.pluginId)).toEqual(["openclaw-qqbot"]); + expect(registry.diagnostics.map((diag) => diag.message).join("\n")).not.toContain( + "plugin tool name conflict", + ); + }); + + it("blocks tools from a plugin that loses a duplicate channel registration", () => { + const bundledRoot = makeTempDir(); + writeChannelToolPlugin({ + rootDir: bundledRoot, + id: "qqbot", + channelId: "qqbot", + enabledByDefault: true, + }); + const externalRoot = makeTempDir(); + const externalPluginDir = writeChannelToolPlugin({ + rootDir: externalRoot, + id: "openclaw-qqbot", + channelId: "qqbot", + }); + const env = { + OPENCLAW_STATE_DIR: makeTempDir(), + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot, + }; + + const registry = loadOpenClawPlugins({ + cache: false, + config: { + channels: { qqbot: { appId: "app", clientSecret: "secret" } }, + plugins: { + load: { paths: [externalPluginDir] }, + entries: { + qqbot: { enabled: true }, + "openclaw-qqbot": { enabled: true }, + }, + }, + }, + env, + }); + + const diagnostics = registry.diagnostics.map((diag) => diag.message).join("\n"); + expect(diagnostics).toContain("channel already registered: qqbot"); + expect(diagnostics).not.toContain("plugin tool name conflict"); + expect(registry.tools.map((tool) => tool.pluginId)).toHaveLength(1); + }); +}); diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 0b940f21341..901eacd6a18 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -226,6 +226,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { const registry = createEmptyPluginRegistry(); const coreGatewayMethods = new Set(Object.keys(registryParams.coreGatewayHandlers ?? {})); const pluginHookRollback = new Map(); + const pluginsWithChannelRegistrationConflict = new Set(); const pushDiagnostic = (diag: PluginDiagnostic) => { registry.diagnostics.push(diag); @@ -373,6 +374,9 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { tool: AnyAgentTool | OpenClawPluginToolFactory, opts?: { name?: string; names?: string[]; optional?: boolean }, ) => { + if (pluginsWithChannelRegistrationConflict.has(record.id)) { + return; + } const names = opts?.names ?? (opts?.name ? [opts.name] : []); const optional = opts?.optional === true; const factory: OpenClawPluginToolFactory = @@ -674,6 +678,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { source: record.source, message: `channel already registered: ${id} (${existingRuntime.pluginId})`, }); + pluginsWithChannelRegistrationConflict.add(record.id); return; } const existingSetup = registry.channelSetups.find((entry) => entry.plugin.id === id); @@ -692,6 +697,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { source: record.source, message: `channel setup already registered: ${id} (${existingSetup.pluginId})`, }); + pluginsWithChannelRegistrationConflict.add(record.id); return; } record.channelIds.push(id);