diff --git a/CHANGELOG.md b/CHANGELOG.md index c636be601df..d88f6457c18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Plugin SDK/Discord: restore a deprecated `openclaw/plugin-sdk/discord` compatibility facade and the legacy compat group-policy warning export for the published `@openclaw/discord@2026.3.13` package, covering its config, account, directory, status, and thread-binding imports while keeping new plugins on generic SDK subpaths. Fixes #73685; supersedes #73703. Thanks @rderickson9 and @SymbolStar. - Channels/Discord: suppress duplicate gateway monitors when multiple enabled accounts resolve to the same bot token, preferring config tokens over default env fallback and reporting skipped duplicates as disabled. Supersedes #73608. Thanks @kagura-agent. - Control UI/Talk: decode Google Live binary WebSocket JSON frames and stop queued browser audio on interruption or shutdown, so browser Talk leaves `Connecting Talk...` and barge-in no longer plays stale audio. Fixes #73601 and #73460; supersedes #73466. Thanks @Spolen23 and @WadydX. - Channels/Discord: ignore stale route-shaped conversation bindings after a Discord channel is reconfigured to another agent, while preserving explicit focus and subagent bindings. Fixes #73626. Thanks @ramitrkar-hash. diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 3b4fe3678a4..32aa2a41b67 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -7da4f3439785b85e93740640efce5ca2e6eb0024139a6585641fb995f9b3830c plugin-sdk-api-baseline.json -91d40edb771792303d91a5d188ec1e6d77647c46866733ad65bf16d6a57348ba plugin-sdk-api-baseline.jsonl +46476e7b4fee105ca27aed9c769c507f70f02b8ce8586c135feb18e751db0de1 plugin-sdk-api-baseline.json +4bc1c0dc66d910c80694fa1a6b7ba3ab488bf737b3566e53b8a5857c16d2e0b1 plugin-sdk-api-baseline.jsonl diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index cd30a71c1fe..06ebd814aee 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -545,10 +545,12 @@ surface. The full list of 200+ entrypoints lives in `scripts/lib/plugin-sdk-entrypoints.json`. Reserved bundled-plugin helper seams have been retired from the public SDK -export map. Owner-specific helpers live inside the owning plugin package; shared -host behavior should move through generic SDK contracts such as -`plugin-sdk/gateway-runtime`, `plugin-sdk/security-runtime`, and -`plugin-sdk/plugin-config-runtime`. +export map except for explicitly documented compatibility facades such as the +deprecated `plugin-sdk/discord` shim retained for the published +`@openclaw/discord@2026.3.13` package. Owner-specific helpers live inside the +owning plugin package; shared host behavior should move through generic SDK +contracts such as `plugin-sdk/gateway-runtime`, `plugin-sdk/security-runtime`, +and `plugin-sdk/plugin-config-runtime`. Use the narrowest import that matches the job. If you cannot find an export, check the source at `src/plugin-sdk/` or ask maintainers which generic contract diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md index 385da3176dc..b2c1c26e6ff 100644 --- a/docs/plugins/sdk-overview.md +++ b/docs/plugins/sdk-overview.md @@ -50,6 +50,10 @@ A small set of bundled-plugin helper seams still appear in the generated export map when they have tracked owner usage. They exist for bundled-plugin maintenance only and are not recommended import paths for new third-party plugins. + +`openclaw/plugin-sdk/discord` is also kept as a deprecated compatibility facade +for the published `@openclaw/discord@2026.3.13` package. Do not copy that import +path into new plugins; use the generic channel SDK subpaths instead. ## Subpath reference diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index 5911edaf6c2..3bdb3ee2274 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -84,6 +84,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview) | `plugin-sdk/allowlist-config-edit` | Allowlist config edit/read helpers | | `plugin-sdk/group-access` | Shared group-access decision helpers | | `plugin-sdk/direct-dm` | Shared direct-DM auth/guard helpers | + | `plugin-sdk/discord` | Deprecated Discord compatibility facade for published `@openclaw/discord@2026.3.13`; new plugins should use generic channel SDK subpaths | | `plugin-sdk/interactive-runtime` | Semantic message presentation, delivery, and legacy interactive reply helpers. See [Message Presentation](/plugins/message-presentation) | | `plugin-sdk/channel-inbound` | Compatibility barrel for inbound debounce, mention matching, mention-policy helpers, and envelope helpers | | `plugin-sdk/channel-inbound-debounce` | Narrow inbound debounce helpers | diff --git a/package.json b/package.json index 0d311a23fc5..517d6de29d5 100644 --- a/package.json +++ b/package.json @@ -664,6 +664,10 @@ "types": "./dist/plugin-sdk/direct-dm-guard-policy.d.ts", "default": "./dist/plugin-sdk/direct-dm-guard-policy.js" }, + "./plugin-sdk/discord": { + "types": "./dist/plugin-sdk/discord.d.ts", + "default": "./dist/plugin-sdk/discord.js" + }, "./plugin-sdk/device-bootstrap": { "types": "./dist/plugin-sdk/device-bootstrap.d.ts", "default": "./dist/plugin-sdk/device-bootstrap.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index bdce1188efd..485d442c4ef 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -149,6 +149,7 @@ "direct-dm", "direct-dm-access", "direct-dm-guard-policy", + "discord", "device-bootstrap", "diagnostic-runtime", "error-runtime", diff --git a/src/plugin-sdk/channel-policy.ts b/src/plugin-sdk/channel-policy.ts index a98f70cbf2e..b056249ad76 100644 --- a/src/plugin-sdk/channel-policy.ts +++ b/src/plugin-sdk/channel-policy.ts @@ -25,6 +25,7 @@ export { buildOpenGroupPolicyWarning, collectAllowlistProviderGroupPolicyWarnings, collectAllowlistProviderRestrictSendersWarnings, + collectOpenGroupPolicyConfiguredRouteWarnings, collectOpenGroupPolicyRestrictSendersWarnings, collectOpenGroupPolicyRouteAllowlistWarnings, collectOpenProviderGroupPolicyWarnings, diff --git a/src/plugin-sdk/compat.ts b/src/plugin-sdk/compat.ts index 95354b78f56..84e0c04a550 100644 --- a/src/plugin-sdk/compat.ts +++ b/src/plugin-sdk/compat.ts @@ -61,6 +61,7 @@ export { export { formatAllowFromLowercase, formatNormalizedAllowFromEntries } from "./allow-from.js"; export * from "./channel-config-schema.js"; export * from "./channel-policy.js"; +export { collectOpenGroupPolicyConfiguredRouteWarnings } from "./channel-policy.js"; export * from "./reply-history.js"; export * from "./directory-runtime.js"; export { mapAllowlistResolutionInputs } from "./allow-from.js"; diff --git a/src/plugin-sdk/discord.test.ts b/src/plugin-sdk/discord.test.ts new file mode 100644 index 00000000000..85e9f482ea2 --- /dev/null +++ b/src/plugin-sdk/discord.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const runtimeConfig = { channels: { discord: { token: "token" } } }; + const apiModule = { + collectDiscordStatusIssues: vi.fn(() => []), + discordOnboardingAdapter: { kind: "legacy-onboarding" }, + inspectDiscordAccount: vi.fn(() => ({ accountId: "default" })), + listDiscordAccountIds: vi.fn(() => ["default"]), + listDiscordDirectoryGroupsFromConfig: vi.fn(() => []), + listDiscordDirectoryPeersFromConfig: vi.fn(() => []), + looksLikeDiscordTargetId: vi.fn(() => true), + normalizeDiscordMessagingTarget: vi.fn(() => "channel:123"), + normalizeDiscordOutboundTarget: vi.fn(() => ({ ok: true, to: "channel:123" })), + resolveDefaultDiscordAccountId: vi.fn(() => "default"), + resolveDiscordAccount: vi.fn(() => ({ + accountId: "default", + config: {}, + enabled: true, + token: "token", + tokenSource: "config", + })), + resolveDiscordGroupRequireMention: vi.fn(() => true), + resolveDiscordGroupToolPolicy: vi.fn(() => undefined), + }; + const runtimeModule = { + autoBindSpawnedDiscordSubagent: vi.fn(async (params) => ({ + accountId: params.accountId ?? "default", + channelId: "123", + targetKind: "subagent", + targetSessionKey: params.childSessionKey, + threadId: "456", + cfg: params.cfg, + })), + collectDiscordAuditChannelIds: vi.fn(() => ({ channelIds: [], unresolvedChannels: [] })), + listThreadBindingsBySessionKey: vi.fn(() => []), + unbindThreadBindingsBySessionKey: vi.fn(() => []), + }; + + return { + apiModule, + runtimeModule, + runtimeConfig, + loadBundledPluginPublicSurfaceModuleSync: vi.fn((params: { artifactBasename: string }) => { + if (params.artifactBasename === "runtime-api.js") { + return runtimeModule; + } + return apiModule; + }), + }; +}); + +vi.mock("./facade-loader.js", () => ({ + createLazyFacadeObjectValue: (load: () => object) => + new Proxy( + {}, + { + get(_target, property) { + return Reflect.get(load(), property); + }, + }, + ), + loadBundledPluginPublicSurfaceModuleSync: mocks.loadBundledPluginPublicSurfaceModuleSync, +})); + +vi.mock("./runtime-config-snapshot.js", () => ({ + getRuntimeConfig: () => mocks.runtimeConfig, + getRuntimeConfigSnapshot: () => mocks.runtimeConfig, +})); + +describe("discord plugin-sdk compatibility facade", () => { + it("exports the @openclaw/discord 2026.3.13 import surface", async () => { + const discordSdk = await import("./discord.js"); + + for (const exportName of [ + "DEFAULT_ACCOUNT_ID", + "DiscordConfigSchema", + "PAIRING_APPROVED_MESSAGE", + "applyAccountNameToChannelSection", + "autoBindSpawnedDiscordSubagent", + "buildChannelConfigSchema", + "buildComputedAccountStatusSnapshot", + "buildTokenChannelStatusSummary", + "collectDiscordAuditChannelIds", + "collectDiscordStatusIssues", + "discordOnboardingAdapter", + "emptyPluginConfigSchema", + "getChatChannelMeta", + "inspectDiscordAccount", + "listDiscordAccountIds", + "listDiscordDirectoryGroupsFromConfig", + "listDiscordDirectoryPeersFromConfig", + "listThreadBindingsBySessionKey", + "looksLikeDiscordTargetId", + "migrateBaseNameToDefaultAccount", + "normalizeAccountId", + "normalizeDiscordMessagingTarget", + "normalizeDiscordOutboundTarget", + "projectCredentialSnapshotFields", + "resolveConfiguredFromCredentialStatuses", + "resolveDefaultDiscordAccountId", + "resolveDiscordAccount", + "resolveDiscordGroupRequireMention", + "resolveDiscordGroupToolPolicy", + "unbindThreadBindingsBySessionKey", + ]) { + expect(discordSdk).toHaveProperty(exportName); + } + }); + + it("keeps legacy Discord subagent auto-bind calls working without cfg", async () => { + const { autoBindSpawnedDiscordSubagent } = await import("./discord.js"); + + const binding = await autoBindSpawnedDiscordSubagent({ + agentId: "agent", + channel: "discord", + childSessionKey: "child", + }); + + expect(mocks.runtimeModule.autoBindSpawnedDiscordSubagent).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "agent", + cfg: mocks.runtimeConfig, + childSessionKey: "child", + }), + ); + expect(binding).toEqual( + expect.objectContaining({ + cfg: mocks.runtimeConfig, + targetKind: "subagent", + targetSessionKey: "child", + }), + ); + }); +}); diff --git a/src/plugin-sdk/discord.ts b/src/plugin-sdk/discord.ts new file mode 100644 index 00000000000..ffd3527962b --- /dev/null +++ b/src/plugin-sdk/discord.ts @@ -0,0 +1,248 @@ +import type { + ChannelAccountSnapshot, + ChannelGroupContext, + ChannelMessageActionAdapter, + ChannelStatusIssue, +} from "./channel-contract.js"; +import type { ChannelPlugin } from "./channel-core.js"; +import type { OpenClawConfig } from "./config-types.js"; +import { + createLazyFacadeObjectValue, + loadBundledPluginPublicSurfaceModuleSync, +} from "./facade-loader.js"; +import { getRuntimeConfig, getRuntimeConfigSnapshot } from "./runtime-config-snapshot.js"; + +export type { ChannelMessageActionAdapter, ChannelMessageActionName } from "./channel-contract.js"; +export type { ChannelPlugin } from "./channel-core.js"; +export type { OpenClawConfig } from "./config-types.js"; +export type { OpenClawPluginApi, PluginRuntime } from "./channel-plugin-common.js"; + +export { + DEFAULT_ACCOUNT_ID, + applyAccountNameToChannelSection, + buildChannelConfigSchema, + emptyPluginConfigSchema, + getChatChannelMeta, + migrateBaseNameToDefaultAccount, + normalizeAccountId, + PAIRING_APPROVED_MESSAGE, +} from "./channel-plugin-common.js"; +export { + buildComputedAccountStatusSnapshot, + buildTokenChannelStatusSummary, + projectCredentialSnapshotFields, + resolveConfiguredFromCredentialStatuses, +} from "./channel-status.js"; +export { DiscordConfigSchema } from "./bundled-channel-config-schema.js"; + +export type DiscordAccountConfig = NonNullable["discord"]>; + +export type ResolvedDiscordAccount = { + accountId: string; + enabled: boolean; + name?: string; + token: string; + tokenSource: "env" | "config" | "none"; + config: DiscordAccountConfig; +}; + +export type DiscordOutboundTargetResolution = + | { ok: true; to: string } + | { ok: false; error: Error }; + +export type ThreadBindingTargetKind = "subagent" | "acp"; + +export type ThreadBindingRecord = { + accountId: string; + threadId: string; + channelId?: string; + targetKind: ThreadBindingTargetKind; + targetSessionKey: string; + [key: string]: unknown; +}; + +type DirectoryConfigParams = { + cfg: OpenClawConfig; + accountId?: string | null; +}; + +type DiscordApiFacadeModule = { + collectDiscordStatusIssues: (accounts: ChannelAccountSnapshot[]) => ChannelStatusIssue[]; + discordOnboardingAdapter?: NonNullable["setup"]>; + inspectDiscordAccount: (params: { cfg: OpenClawConfig; accountId?: string | null }) => unknown; + listDiscordAccountIds: (cfg: OpenClawConfig) => string[]; + listDiscordDirectoryGroupsFromConfig: ( + params: DirectoryConfigParams, + ) => unknown[] | Promise; + listDiscordDirectoryPeersFromConfig: ( + params: DirectoryConfigParams, + ) => unknown[] | Promise; + looksLikeDiscordTargetId: (raw: string) => boolean; + normalizeDiscordMessagingTarget: (raw: string) => string | undefined; + normalizeDiscordOutboundTarget: (to?: string) => DiscordOutboundTargetResolution; + resolveDefaultDiscordAccountId: (cfg: OpenClawConfig) => string; + resolveDiscordAccount: (params: { + cfg: OpenClawConfig; + accountId?: string | null; + }) => ResolvedDiscordAccount; + resolveDiscordGroupRequireMention: (params: ChannelGroupContext) => boolean | undefined; + resolveDiscordGroupToolPolicy: (params: ChannelGroupContext) => unknown; +}; + +type DiscordRuntimeFacadeModule = { + autoBindSpawnedDiscordSubagent: (params: { + cfg: OpenClawConfig; + accountId?: string; + channel?: string; + to?: string; + threadId?: string | number; + childSessionKey: string; + agentId: string; + label?: string; + boundBy?: string; + }) => Promise; + collectDiscordAuditChannelIds: (params: { + cfg: OpenClawConfig; + accountId?: string | null; + }) => unknown; + listThreadBindingsBySessionKey: (params: { + targetSessionKey: string; + accountId?: string; + targetKind?: ThreadBindingTargetKind; + }) => ThreadBindingRecord[]; + unbindThreadBindingsBySessionKey: (params: { + targetSessionKey: string; + accountId?: string; + targetKind?: ThreadBindingTargetKind; + reason?: string; + sendFarewell?: boolean; + farewellText?: string; + }) => ThreadBindingRecord[]; +}; + +function loadDiscordApiFacadeModule(): DiscordApiFacadeModule { + return loadBundledPluginPublicSurfaceModuleSync({ + dirName: "discord", + artifactBasename: "api.js", + }); +} + +function loadDiscordRuntimeFacadeModule(): DiscordRuntimeFacadeModule { + return loadBundledPluginPublicSurfaceModuleSync({ + dirName: "discord", + artifactBasename: "runtime-api.js", + }); +} + +function resolveCompatRuntimeConfig(params: { cfg?: OpenClawConfig }): OpenClawConfig { + return params.cfg ?? getRuntimeConfigSnapshot() ?? getRuntimeConfig(); +} + +export const discordOnboardingAdapter = createLazyFacadeObjectValue( + () => loadDiscordApiFacadeModule().discordOnboardingAdapter ?? {}, +); + +export function collectDiscordStatusIssues( + accounts: ChannelAccountSnapshot[], +): ChannelStatusIssue[] { + return loadDiscordApiFacadeModule().collectDiscordStatusIssues(accounts); +} + +export function inspectDiscordAccount(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): unknown { + return loadDiscordApiFacadeModule().inspectDiscordAccount(params); +} + +export function listDiscordAccountIds(cfg: OpenClawConfig): string[] { + return loadDiscordApiFacadeModule().listDiscordAccountIds(cfg); +} + +export function listDiscordDirectoryGroupsFromConfig( + params: DirectoryConfigParams, +): unknown[] | Promise { + return loadDiscordApiFacadeModule().listDiscordDirectoryGroupsFromConfig(params); +} + +export function listDiscordDirectoryPeersFromConfig( + params: DirectoryConfigParams, +): unknown[] | Promise { + return loadDiscordApiFacadeModule().listDiscordDirectoryPeersFromConfig(params); +} + +export function looksLikeDiscordTargetId(raw: string): boolean { + return loadDiscordApiFacadeModule().looksLikeDiscordTargetId(raw); +} + +export function normalizeDiscordMessagingTarget(raw: string): string | undefined { + return loadDiscordApiFacadeModule().normalizeDiscordMessagingTarget(raw); +} + +export function normalizeDiscordOutboundTarget(to?: string): DiscordOutboundTargetResolution { + return loadDiscordApiFacadeModule().normalizeDiscordOutboundTarget(to); +} + +export function resolveDefaultDiscordAccountId(cfg: OpenClawConfig): string { + return loadDiscordApiFacadeModule().resolveDefaultDiscordAccountId(cfg); +} + +export function resolveDiscordAccount(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): ResolvedDiscordAccount { + return loadDiscordApiFacadeModule().resolveDiscordAccount(params); +} + +export function resolveDiscordGroupRequireMention( + params: ChannelGroupContext, +): boolean | undefined { + return loadDiscordApiFacadeModule().resolveDiscordGroupRequireMention(params); +} + +export function resolveDiscordGroupToolPolicy(params: ChannelGroupContext): unknown { + return loadDiscordApiFacadeModule().resolveDiscordGroupToolPolicy(params); +} + +export function collectDiscordAuditChannelIds(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): unknown { + return loadDiscordRuntimeFacadeModule().collectDiscordAuditChannelIds(params); +} + +export async function autoBindSpawnedDiscordSubagent(params: { + cfg?: OpenClawConfig; + accountId?: string; + channel?: string; + to?: string; + threadId?: string | number; + childSessionKey: string; + agentId: string; + label?: string; + boundBy?: string; +}): Promise { + return await loadDiscordRuntimeFacadeModule().autoBindSpawnedDiscordSubagent({ + ...params, + cfg: resolveCompatRuntimeConfig(params), + }); +} + +export function listThreadBindingsBySessionKey(params: { + targetSessionKey: string; + accountId?: string; + targetKind?: ThreadBindingTargetKind; +}): ThreadBindingRecord[] { + return loadDiscordRuntimeFacadeModule().listThreadBindingsBySessionKey(params); +} + +export function unbindThreadBindingsBySessionKey(params: { + targetSessionKey: string; + accountId?: string; + targetKind?: ThreadBindingTargetKind; + reason?: string; + sendFarewell?: boolean; + farewellText?: string; +}): ThreadBindingRecord[] { + return loadDiscordRuntimeFacadeModule().unbindThreadBindingsBySessionKey(params); +} diff --git a/src/plugin-sdk/entrypoints.ts b/src/plugin-sdk/entrypoints.ts index af53ffdc691..9bf55f446ed 100644 --- a/src/plugin-sdk/entrypoints.ts +++ b/src/plugin-sdk/entrypoints.ts @@ -11,6 +11,7 @@ export const reservedBundledPluginSdkEntrypoints = [] as const; // Supported SDK facades backed by bundled plugins. These are intentionally public // until they move to generic, plugin-neutral contracts. export const supportedBundledFacadeSdkEntrypoints = [ + "discord", "lmstudio", "lmstudio-runtime", "memory-core-engine-runtime", diff --git a/src/plugins/contracts/plugin-sdk-subpaths.test.ts b/src/plugins/contracts/plugin-sdk-subpaths.test.ts index d818d69fec1..416410a6ba1 100644 --- a/src/plugins/contracts/plugin-sdk-subpaths.test.ts +++ b/src/plugins/contracts/plugin-sdk-subpaths.test.ts @@ -462,7 +462,7 @@ describe("plugin-sdk subpath exports", () => { }); it("keeps removed bundled-channel aliases out of the public sdk list", () => { - const removedChannelAliases = new Set(["discord", "signal", "slack", "telegram", "whatsapp"]); + const removedChannelAliases = new Set(["signal", "slack", "telegram", "whatsapp"]); const banned = pluginSdkSubpaths.filter((subpath) => removedChannelAliases.has(subpath)); expect(banned).toEqual([]); }); @@ -640,6 +640,7 @@ describe("plugin-sdk subpath exports", () => { expectSourceMentions("compat", [ "createPluginRuntimeStore", "createScopedChannelConfigAdapter", + "collectOpenGroupPolicyConfiguredRouteWarnings", "resolveControlCommandGate", "delegateCompactionToRuntime", ]);