diff --git a/.npmrc b/.npmrc index 05620061611..bdf24a6c276 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,4 @@ # pnpm build-script allowlist lives in package.json -> pnpm.onlyBuiltDependencies. +# TS 7 native-preview fails to resolve packages reliably from pnpm's isolated linker. +# Keep the workspace on a hoisted layout so pnpm check/build stay stable. +node-linker=hoisted diff --git a/CHANGELOG.md b/CHANGELOG.md index aa76166bf0d..04aa378d28f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ Docs: https://docs.openclaw.ai - Control UI/appearance: unify theme border radii across Claw, Knot, and Dash, and add a Roundness slider to the Appearance settings so users can adjust corner radius from sharp to fully rounded. Thanks @BunsDev. - Control UI/chat: add an expand-to-canvas button on assistant chat bubbles and in-app session navigation from Sessions and Cron views. Thanks @BunsDev. - Plugins/context engines: expose `delegateCompactionToRuntime(...)` on the public plugin SDK, refactor the legacy engine to use the shared helper, and clarify `ownsCompaction` delegation semantics for non-owning engines. (#49061) Thanks @jalehman. +- Plugins/MiniMax: add MiniMax-M2.7 and MiniMax-M2.7-highspeed models and update the default model from M2.5 to M2.7. (#49691) Thanks @liyuan97. ### Fixes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9e487f254cd..8914ffc1f31 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -83,7 +83,8 @@ Welcome to the lobster tank! 🦞 1. **Bugs & small fixes** → Open a PR! 2. **New features / architecture** → Start a [GitHub Discussion](https://github.com/openclaw/openclaw/discussions) or ask in Discord first -3. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828) +3. **Test/CI-only PRs for known `main` failures** → Don't open a PR, the Maintainer team is already tracking it and such PRs will be closed automatically. If you've spotted a _new_ regression not yet shown in main CI, report it as an issue first. +4. **Questions** → Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828) ## Before You PR @@ -96,6 +97,7 @@ Welcome to the lobster tank! 🦞 - For targeted shared-surface work, use `pnpm test:contracts:channels` or `pnpm test:contracts:plugins` - If you changed broader runtime behavior, still run the relevant wider lanes (`pnpm test:extensions`, `pnpm test:channels`, or `pnpm test`) before asking for review - If you have access to Codex, run `codex review --base origin/main` locally before opening or updating your PR. Treat this as the current highest standard of AI review, even if GitHub Codex review also runs. +- Do not submit test or CI-config fixes for failures already red on `main` CI. If a failure is already visible in the [main branch CI runs](https://github.com/openclaw/openclaw/actions), it's a known issue the Maintainer team is tracking, and a PR that only addresses those failures will be closed automatically. If you spot a _new_ regression not yet shown in main CI, report it as an issue first. - Ensure CI checks pass - Keep PRs focused (one thing per PR; do not mix unrelated concerns) - Describe what & why diff --git a/docs/CNAME b/docs/CNAME deleted file mode 100644 index 715bc9df52a..00000000000 --- a/docs/CNAME +++ /dev/null @@ -1 +0,0 @@ -docs.openclaw.ai diff --git a/docs/help/testing.md b/docs/help/testing.md index 2d7e9664176..6fb91982f1d 100644 --- a/docs/help/testing.md +++ b/docs/help/testing.md @@ -52,6 +52,10 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost): - Runs in CI - No real keys required - Should be fast and stable +- Scheduler note: + - `pnpm test` now keeps a small checked-in behavioral manifest for true pool/isolation overrides and a separate timing snapshot for the slowest unit files. + - Shared unit coverage stays on, but the wrapper peels the heaviest measured files into dedicated lanes instead of relying on a growing hand-maintained exclusion list. + - Refresh the timing snapshot with `pnpm test:perf:update-timings` after major suite shape changes. - Embedded runner note: - When you change message-tool discovery inputs or compaction runtime context, keep both levels of coverage. diff --git a/docs/reference/test.md b/docs/reference/test.md index 378789f6d6e..e337e963e1d 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -12,9 +12,10 @@ title: "Tests" - `pnpm test:force`: Kills any lingering gateway process holding the default control port, then runs the full Vitest suite with an isolated gateway port so server tests don’t collide with a running instance. Use this when a prior gateway run left port 18789 occupied. - `pnpm test:coverage`: Runs the unit suite with V8 coverage (via `vitest.unit.config.ts`). Global thresholds are 70% lines/branches/functions/statements. Coverage excludes integration-heavy entrypoints (CLI wiring, gateway/telegram bridges, webchat static server) to keep the target focused on unit-testable logic. - `pnpm test` on Node 22, 23, and 24 uses Vitest `vmForks` by default for faster startup. Node 25+ falls back to `forks` until re-validated. You can force behavior with `OPENCLAW_TEST_VM_FORKS=0|1`. -- `pnpm test`: runs the fast core unit lane by default for quick local feedback. +- `pnpm test`: runs the full wrapper. It keeps only a small behavioral override manifest in git, then uses a checked-in timing snapshot to peel the heaviest measured unit files into dedicated lanes. - `pnpm test:channels`: runs channel-heavy suites. - `pnpm test:extensions`: runs extension/plugin suites. +- `pnpm test:perf:update-timings`: refreshes the checked-in slow-file timing snapshot used by `scripts/test-parallel.mjs`. - Gateway integration: opt-in via `OPENCLAW_TEST_INCLUDE_GATEWAY=1 pnpm test` or `pnpm test:gateway`. - `pnpm test:e2e`: Runs gateway end-to-end smoke tests (multi-instance WS/HTTP/node pairing). Defaults to `vmForks` + adaptive workers in `vitest.e2e.config.ts`; tune with `OPENCLAW_E2E_WORKERS=` and set `OPENCLAW_E2E_VERBOSE=1` for verbose logs. - `pnpm test:live`: Runs provider live tests (minimax/zai). Requires API keys and `LIVE=1` (or provider-specific `*_LIVE_TEST=1`) to unskip. diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts index 33249fcfa9e..4d4b411a639 100644 --- a/extensions/bluebubbles/src/channel.ts +++ b/extensions/bluebubbles/src/channel.ts @@ -4,7 +4,15 @@ import { createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; -import { collectOpenGroupPolicyRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; +import { + createOpenGroupPolicyRestrictSendersWarningCollector, + projectWarningCollector, +} from "openclaw/plugin-sdk/channel-policy"; +import { + createAttachedChannelResultAdapter, + createPairingPrefixStripper, + createTextPairingAdapter, +} from "openclaw/plugin-sdk/channel-runtime"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { listBlueBubblesAccountIds, @@ -68,6 +76,17 @@ const resolveBlueBubblesDmPolicy = createScopedDmSecurityResolver normalizeBlueBubblesHandle(raw.replace(/^bluebubbles:/i, "")), }); +const collectBlueBubblesSecurityWarnings = + createOpenGroupPolicyRestrictSendersWarningCollector({ + resolveGroupPolicy: (account) => account.config.groupPolicy, + defaultGroupPolicy: "allowlist", + surface: "BlueBubbles groups", + openScope: "any member", + groupPolicyPath: "channels.bluebubbles.groupPolicy", + groupAllowFromPath: "channels.bluebubbles.groupAllowFrom", + mentionGated: false, + }); + const meta = { id: "bluebubbles", label: "BlueBubbles", @@ -123,17 +142,10 @@ export const bluebubblesPlugin: ChannelPlugin = { actions: bluebubblesMessageActions, security: { resolveDmPolicy: resolveBlueBubblesDmPolicy, - collectWarnings: ({ account }) => { - const groupPolicy = account.config.groupPolicy ?? "allowlist"; - return collectOpenGroupPolicyRestrictSendersWarnings({ - groupPolicy, - surface: "BlueBubbles groups", - openScope: "any member", - groupPolicyPath: "channels.bluebubbles.groupPolicy", - groupAllowFromPath: "channels.bluebubbles.groupAllowFrom", - mentionGated: false, - }); - }, + collectWarnings: projectWarningCollector( + ({ account }: { account: ResolvedBlueBubblesAccount }) => account, + collectBlueBubblesSecurityWarnings, + ), }, messaging: { normalizeTarget: normalizeBlueBubblesMessagingTarget, @@ -226,17 +238,18 @@ export const bluebubblesPlugin: ChannelPlugin = { }, }, setup: blueBubblesSetupAdapter, - pairing: { + pairing: createTextPairingAdapter({ idLabel: "bluebubblesSenderId", - normalizeAllowEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")), - notifyApproval: async ({ cfg, id }) => { + message: PAIRING_APPROVED_MESSAGE, + normalizeAllowEntry: createPairingPrefixStripper(/^bluebubbles:/i, normalizeBlueBubblesHandle), + notify: async ({ cfg, id, message }) => { await ( await loadBlueBubblesChannelRuntime() - ).sendMessageBlueBubbles(id, PAIRING_APPROVED_MESSAGE, { + ).sendMessageBlueBubbles(id, message, { cfg: cfg, }); }, - }, + }), outbound: { deliveryMode: "direct", textChunkLimit: 4000, @@ -250,46 +263,44 @@ export const bluebubblesPlugin: ChannelPlugin = { } return { ok: true, to: trimmed }; }, - sendText: async ({ cfg, to, text, accountId, replyToId }) => { - const runtime = await loadBlueBubblesChannelRuntime(); - const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : ""; - // Resolve short ID (e.g., "5") to full UUID - const replyToMessageGuid = rawReplyToId - ? runtime.resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true }) - : ""; - const result = await runtime.sendMessageBlueBubbles(to, text, { - cfg: cfg, - accountId: accountId ?? undefined, - replyToMessageGuid: replyToMessageGuid || undefined, - }); - return { channel: "bluebubbles", ...result }; - }, - sendMedia: async (ctx) => { - const runtime = await loadBlueBubblesChannelRuntime(); - const { cfg, to, text, mediaUrl, accountId, replyToId } = ctx; - const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as { - mediaPath?: string; - mediaBuffer?: Uint8Array; - contentType?: string; - filename?: string; - caption?: string; - }; - const resolvedCaption = caption ?? text; - const result = await runtime.sendBlueBubblesMedia({ - cfg: cfg, - to, - mediaUrl, - mediaPath, - mediaBuffer, - contentType, - filename, - caption: resolvedCaption ?? undefined, - replyToId: replyToId ?? null, - accountId: accountId ?? undefined, - }); - - return { channel: "bluebubbles", ...result }; - }, + ...createAttachedChannelResultAdapter({ + channel: "bluebubbles", + sendText: async ({ cfg, to, text, accountId, replyToId }) => { + const runtime = await loadBlueBubblesChannelRuntime(); + const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : ""; + const replyToMessageGuid = rawReplyToId + ? runtime.resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true }) + : ""; + return await runtime.sendMessageBlueBubbles(to, text, { + cfg: cfg, + accountId: accountId ?? undefined, + replyToMessageGuid: replyToMessageGuid || undefined, + }); + }, + sendMedia: async (ctx) => { + const runtime = await loadBlueBubblesChannelRuntime(); + const { cfg, to, text, mediaUrl, accountId, replyToId } = ctx; + const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as { + mediaPath?: string; + mediaBuffer?: Uint8Array; + contentType?: string; + filename?: string; + caption?: string; + }; + return await runtime.sendBlueBubblesMedia({ + cfg: cfg, + to, + mediaUrl, + mediaPath, + mediaBuffer, + contentType, + filename, + caption: caption ?? text ?? undefined, + replyToId: replyToId ?? null, + accountId: accountId ?? undefined, + }); + }, + }), }, status: { defaultRuntime: { diff --git a/extensions/bluebubbles/src/monitor-processing.ts b/extensions/bluebubbles/src/monitor-processing.ts index 958c629f766..ef01150487b 100644 --- a/extensions/bluebubbles/src/monitor-processing.ts +++ b/extensions/bluebubbles/src/monitor-processing.ts @@ -1,3 +1,8 @@ +import { + resolveOutboundMediaUrls, + resolveTextChunksWithFallback, + sendMediaWithLeadingCaption, +} from "openclaw/plugin-sdk/reply-payload"; import { downloadBlueBubblesAttachment } from "./attachments.js"; import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js"; import { fetchBlueBubblesHistory } from "./history.js"; @@ -1243,11 +1248,7 @@ export async function processMessage( const replyToMessageGuid = rawReplyToId ? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true }) : ""; - const mediaList = payload.mediaUrls?.length - ? payload.mediaUrls - : payload.mediaUrl - ? [payload.mediaUrl] - : []; + const mediaList = resolveOutboundMediaUrls(payload); if (mediaList.length > 0) { const tableMode = core.channel.text.resolveMarkdownTableMode({ cfg: config, @@ -1257,43 +1258,44 @@ export async function processMessage( const text = sanitizeReplyDirectiveText( core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode), ); - let first = true; - for (const mediaUrl of mediaList) { - const caption = first ? text : undefined; - first = false; - const cachedBody = (caption ?? "").trim() || ""; - const pendingId = rememberPendingOutboundMessageId({ - accountId: account.accountId, - sessionKey: route.sessionKey, - outboundTarget, - chatGuid: chatGuidForActions ?? chatGuid, - chatIdentifier, - chatId, - snippet: cachedBody, - }); - let result: Awaited>; - try { - result = await sendBlueBubblesMedia({ - cfg: config, - to: outboundTarget, - mediaUrl, - caption: caption ?? undefined, - replyToId: replyToMessageGuid || null, + await sendMediaWithLeadingCaption({ + mediaUrls: mediaList, + caption: text, + send: async ({ mediaUrl, caption }) => { + const cachedBody = (caption ?? "").trim() || ""; + const pendingId = rememberPendingOutboundMessageId({ accountId: account.accountId, + sessionKey: route.sessionKey, + outboundTarget, + chatGuid: chatGuidForActions ?? chatGuid, + chatIdentifier, + chatId, + snippet: cachedBody, }); - } catch (err) { - forgetPendingOutboundMessageId(pendingId); - throw err; - } - if (maybeEnqueueOutboundMessageId(result.messageId, cachedBody)) { - forgetPendingOutboundMessageId(pendingId); - } - sentMessage = true; - statusSink?.({ lastOutboundAt: Date.now() }); - if (info.kind === "block") { - restartTypingSoon(); - } - } + let result: Awaited>; + try { + result = await sendBlueBubblesMedia({ + cfg: config, + to: outboundTarget, + mediaUrl, + caption: caption ?? undefined, + replyToId: replyToMessageGuid || null, + accountId: account.accountId, + }); + } catch (err) { + forgetPendingOutboundMessageId(pendingId); + throw err; + } + if (maybeEnqueueOutboundMessageId(result.messageId, cachedBody)) { + forgetPendingOutboundMessageId(pendingId); + } + sentMessage = true; + statusSink?.({ lastOutboundAt: Date.now() }); + if (info.kind === "block") { + restartTypingSoon(); + } + }, + }); return; } @@ -1312,11 +1314,14 @@ export async function processMessage( ); const chunks = chunkMode === "newline" - ? core.channel.text.chunkTextWithMode(text, textLimit, chunkMode) - : core.channel.text.chunkMarkdownText(text, textLimit); - if (!chunks.length && text) { - chunks.push(text); - } + ? resolveTextChunksWithFallback( + text, + core.channel.text.chunkTextWithMode(text, textLimit, chunkMode), + ) + : resolveTextChunksWithFallback( + text, + core.channel.text.chunkMarkdownText(text, textLimit), + ); if (!chunks.length) { return; } diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 1224fc7b37a..0ddb5c9e19f 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -1,15 +1,22 @@ import { Separator, TextDisplay } from "@buape/carbon"; import { - buildAccountScopedAllowlistConfigEditor, - resolveLegacyDmAllowlistConfigPaths, + buildLegacyDmAccountAllowlistAdapter, + createAccountScopedAllowlistNameResolver, + createNestedAllowlistOverrideResolver, } from "openclaw/plugin-sdk/allowlist-config-edit"; import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; +import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { - collectOpenGroupPolicyConfiguredRouteWarnings, - collectOpenProviderGroupPolicyWarnings, -} from "openclaw/plugin-sdk/channel-config-helpers"; -import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; -import { normalizeMessageChannel } from "openclaw/plugin-sdk/channel-runtime"; + createAttachedChannelResultAdapter, + createChannelDirectoryAdapter, + createPairingPrefixStripper, + createTopLevelChannelReplyToModeResolver, + createRuntimeDirectoryLiveAdapter, + createTextPairingAdapter, + normalizeMessageChannel, + resolveOutboundSendDep, + resolveTargetsWithOptionalToken, +} from "openclaw/plugin-sdk/channel-runtime"; import { buildOutboundBaseSessionKey, normalizeOutboundThreadId } from "openclaw/plugin-sdk/core"; import { resolveThreadSessionKeys, type RoutePeer } from "openclaw/plugin-sdk/routing"; import { @@ -131,42 +138,40 @@ function hasDiscordExecApprovalDmRoute(cfg: OpenClawConfig): boolean { }); } -function readDiscordAllowlistConfig(account: ResolvedDiscordAccount) { - const groupOverrides: Array<{ label: string; entries: string[] }> = []; - for (const [guildKey, guildCfg] of Object.entries(account.config.guilds ?? {})) { - const entries = (guildCfg?.users ?? []).map(String).filter(Boolean); - if (entries.length > 0) { - groupOverrides.push({ label: `guild ${guildKey}`, entries }); - } - for (const [channelKey, channelCfg] of Object.entries(guildCfg?.channels ?? {})) { - const channelEntries = (channelCfg?.users ?? []).map(String).filter(Boolean); - if (channelEntries.length > 0) { - groupOverrides.push({ - label: `guild ${guildKey} / channel ${channelKey}`, - entries: channelEntries, - }); - } - } - } - return { - dmAllowFrom: (account.config.allowFrom ?? account.config.dm?.allowFrom ?? []).map(String), - groupPolicy: account.config.groupPolicy, - groupOverrides, - }; -} +const resolveDiscordAllowlistGroupOverrides = createNestedAllowlistOverrideResolver({ + resolveRecord: (account: ResolvedDiscordAccount) => account.config.guilds, + outerLabel: (guildKey) => `guild ${guildKey}`, + resolveOuterEntries: (guildCfg) => guildCfg?.users, + resolveChildren: (guildCfg) => guildCfg?.channels, + innerLabel: (guildKey, channelKey) => `guild ${guildKey} / channel ${channelKey}`, + resolveInnerEntries: (channelCfg) => channelCfg?.users, +}); -async function resolveDiscordAllowlistNames(params: { - cfg: Parameters[0]["cfg"]; - accountId?: string | null; - entries: string[]; -}) { - const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId }); - const token = account.token?.trim(); - if (!token) { - return []; - } - return await resolveDiscordUserAllowlist({ token, entries: params.entries }); -} +const resolveDiscordAllowlistNames = createAccountScopedAllowlistNameResolver({ + resolveAccount: ({ cfg, accountId }) => resolveDiscordAccount({ cfg, accountId }), + resolveToken: (account: ResolvedDiscordAccount) => account.token, + resolveNames: ({ token, entries }) => resolveDiscordUserAllowlist({ token, entries }), +}); + +const collectDiscordSecurityWarnings = + createOpenProviderConfiguredRouteWarningCollector({ + providerConfigPresent: (cfg) => cfg.channels?.discord !== undefined, + resolveGroupPolicy: (account) => account.config.groupPolicy, + resolveRouteAllowlistConfigured: (account) => + Object.keys(account.config.guilds ?? {}).length > 0, + configureRouteAllowlist: { + surface: "Discord guilds", + openScope: "any channel not explicitly denied", + groupPolicyPath: "channels.discord.groupPolicy", + routeAllowlistPath: "channels.discord.guilds..channels", + }, + missingRouteAllowlist: { + surface: "Discord guilds", + openBehavior: "with no guild/channel allowlist; any channel can trigger (mention-gated)", + remediation: + 'Set channels.discord.groupPolicy="allowlist" and configure channels.discord.guilds..channels', + }, + }); function normalizeDiscordAcpConversationId(conversationId: string) { const normalized = conversationId.trim(); @@ -288,60 +293,29 @@ export const discordPlugin: ChannelPlugin = { ...createDiscordPluginBase({ setup: discordSetupAdapter, }), - pairing: { + pairing: createTextPairingAdapter({ idLabel: "discordUserId", - normalizeAllowEntry: (entry) => entry.replace(/^(discord|user):/i, ""), - notifyApproval: async ({ id }) => { - await getDiscordRuntime().channel.discord.sendMessageDiscord( - `user:${id}`, - PAIRING_APPROVED_MESSAGE, - ); + message: PAIRING_APPROVED_MESSAGE, + normalizeAllowEntry: createPairingPrefixStripper(/^(discord|user):/i), + notify: async ({ id, message }) => { + await getDiscordRuntime().channel.discord.sendMessageDiscord(`user:${id}`, message); }, - }, + }), allowlist: { - supportsScope: ({ scope }) => scope === "dm", - readConfig: ({ cfg, accountId }) => - readDiscordAllowlistConfig(resolveDiscordAccount({ cfg, accountId })), - resolveNames: async ({ cfg, accountId, entries }) => - await resolveDiscordAllowlistNames({ cfg, accountId, entries }), - applyConfigEdit: buildAccountScopedAllowlistConfigEditor({ + ...buildLegacyDmAccountAllowlistAdapter({ channelId: "discord", + resolveAccount: ({ cfg, accountId }) => resolveDiscordAccount({ cfg, accountId }), normalize: ({ cfg, accountId, values }) => discordConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }), - resolvePaths: resolveLegacyDmAllowlistConfigPaths, + resolveDmAllowFrom: (account) => account.config.allowFrom ?? account.config.dm?.allowFrom, + resolveGroupPolicy: (account) => account.config.groupPolicy, + resolveGroupOverrides: resolveDiscordAllowlistGroupOverrides, }), + resolveNames: resolveDiscordAllowlistNames, }, security: { resolveDmPolicy: resolveDiscordDmPolicy, - collectWarnings: ({ account, cfg }) => { - const guildEntries = account.config.guilds ?? {}; - const guildsConfigured = Object.keys(guildEntries).length > 0; - const channelAllowlistConfigured = guildsConfigured; - - return collectOpenProviderGroupPolicyWarnings({ - cfg, - providerConfigPresent: cfg.channels?.discord !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - collect: (groupPolicy) => - collectOpenGroupPolicyConfiguredRouteWarnings({ - groupPolicy, - routeAllowlistConfigured: channelAllowlistConfigured, - configureRouteAllowlist: { - surface: "Discord guilds", - openScope: "any channel not explicitly denied", - groupPolicyPath: "channels.discord.groupPolicy", - routeAllowlistPath: "channels.discord.guilds..channels", - }, - missingRouteAllowlist: { - surface: "Discord guilds", - openBehavior: - "with no guild/channel allowlist; any channel can trigger (mention-gated)", - remediation: - 'Set channels.discord.groupPolicy="allowlist" and configure channels.discord.guilds..channels', - }, - }), - }); - }, + collectWarnings: collectDiscordSecurityWarnings, }, groups: { resolveRequireMention: resolveDiscordGroupRequireMention, @@ -351,7 +325,7 @@ export const discordPlugin: ChannelPlugin = { stripPatterns: () => ["<@!?\\d+>"], }, threading: { - resolveReplyToMode: ({ cfg }) => cfg.channels?.discord?.replyToMode ?? "off", + resolveReplyToMode: createTopLevelChannelReplyToModeResolver("discord"), }, agentPrompt: { messageToolHints: () => [ @@ -387,53 +361,57 @@ export const discordPlugin: ChannelPlugin = { (normalizeMessageChannel(target.channel) ?? target.channel) === "discord" && isDiscordExecApprovalClientEnabled({ cfg, accountId: target.accountId }), }, - directory: { - self: async () => null, + directory: createChannelDirectoryAdapter({ listPeers: async (params) => listDiscordDirectoryPeersFromConfig(params), listGroups: async (params) => listDiscordDirectoryGroupsFromConfig(params), - listPeersLive: async (params) => - getDiscordRuntime().channel.discord.listDirectoryPeersLive(params), - listGroupsLive: async (params) => - getDiscordRuntime().channel.discord.listDirectoryGroupsLive(params), - }, + ...createRuntimeDirectoryLiveAdapter({ + getRuntime: () => getDiscordRuntime().channel.discord, + listPeersLive: (runtime) => runtime.listDirectoryPeersLive, + listGroupsLive: (runtime) => runtime.listDirectoryGroupsLive, + }), + }), resolver: { resolveTargets: async ({ cfg, accountId, inputs, kind }) => { const account = resolveDiscordAccount({ cfg, accountId }); - const token = account.token?.trim(); - if (!token) { - return inputs.map((input) => ({ - input, - resolved: false, - note: "missing Discord token", - })); - } if (kind === "group") { - const resolved = await getDiscordRuntime().channel.discord.resolveChannelAllowlist({ - token, - entries: inputs, + return resolveTargetsWithOptionalToken({ + token: account.token, + inputs, + missingTokenNote: "missing Discord token", + resolveWithToken: ({ token, inputs }) => + getDiscordRuntime().channel.discord.resolveChannelAllowlist({ + token, + entries: inputs, + }), + mapResolved: (entry) => ({ + input: entry.input, + resolved: entry.resolved, + id: entry.channelId ?? entry.guildId, + name: + entry.channelName ?? + entry.guildName ?? + (entry.guildId && !entry.channelId ? entry.guildId : undefined), + note: entry.note, + }), }); - return resolved.map((entry) => ({ + } + return resolveTargetsWithOptionalToken({ + token: account.token, + inputs, + missingTokenNote: "missing Discord token", + resolveWithToken: ({ token, inputs }) => + getDiscordRuntime().channel.discord.resolveUserAllowlist({ + token, + entries: inputs, + }), + mapResolved: (entry) => ({ input: entry.input, resolved: entry.resolved, - id: entry.channelId ?? entry.guildId, - name: - entry.channelName ?? - entry.guildName ?? - (entry.guildId && !entry.channelId ? entry.guildId : undefined), + id: entry.id, + name: entry.name, note: entry.note, - })); - } - const resolved = await getDiscordRuntime().channel.discord.resolveUserAllowlist({ - token, - entries: inputs, + }), }); - return resolved.map((entry) => ({ - input: entry.input, - resolved: entry.resolved, - id: entry.id, - name: entry.name, - note: entry.note, - })); }, }, actions: discordMessageActions, @@ -444,50 +422,51 @@ export const discordPlugin: ChannelPlugin = { textChunkLimit: 2000, pollMaxOptions: 10, resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to), - sendText: async ({ cfg, to, text, accountId, deps, replyToId, silent }) => { - const send = - resolveOutboundSendDep(deps, "discord") ?? - getDiscordRuntime().channel.discord.sendMessageDiscord; - const result = await send(to, text, { - verbose: false, - cfg, - replyTo: replyToId ?? undefined, - accountId: accountId ?? undefined, - silent: silent ?? undefined, - }); - return { channel: "discord", ...result }; - }, - sendMedia: async ({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId, - deps, - replyToId, - silent, - }) => { - const send = - resolveOutboundSendDep(deps, "discord") ?? - getDiscordRuntime().channel.discord.sendMessageDiscord; - const result = await send(to, text, { - verbose: false, + ...createAttachedChannelResultAdapter({ + channel: "discord", + sendText: async ({ cfg, to, text, accountId, deps, replyToId, silent }) => { + const send = + resolveOutboundSendDep(deps, "discord") ?? + getDiscordRuntime().channel.discord.sendMessageDiscord; + return await send(to, text, { + verbose: false, + cfg, + replyTo: replyToId ?? undefined, + accountId: accountId ?? undefined, + silent: silent ?? undefined, + }); + }, + sendMedia: async ({ cfg, + to, + text, mediaUrl, mediaLocalRoots, - replyTo: replyToId ?? undefined, - accountId: accountId ?? undefined, - silent: silent ?? undefined, - }); - return { channel: "discord", ...result }; - }, - sendPoll: async ({ cfg, to, poll, accountId, silent }) => - await getDiscordRuntime().channel.discord.sendPollDiscord(to, poll, { - cfg, - accountId: accountId ?? undefined, - silent: silent ?? undefined, - }), + accountId, + deps, + replyToId, + silent, + }) => { + const send = + resolveOutboundSendDep(deps, "discord") ?? + getDiscordRuntime().channel.discord.sendMessageDiscord; + return await send(to, text, { + verbose: false, + cfg, + mediaUrl, + mediaLocalRoots, + replyTo: replyToId ?? undefined, + accountId: accountId ?? undefined, + silent: silent ?? undefined, + }); + }, + sendPoll: async ({ cfg, to, poll, accountId, silent }) => + await getDiscordRuntime().channel.discord.sendPollDiscord(to, poll, { + cfg, + accountId: accountId ?? undefined, + silent: silent ?? undefined, + }), + }), }, bindings: { compileConfiguredBinding: ({ conversationId }) => diff --git a/extensions/discord/src/directory-config.ts b/extensions/discord/src/directory-config.ts index 69b39d4f9a5..19ec9ce18b5 100644 --- a/extensions/discord/src/directory-config.ts +++ b/extensions/discord/src/directory-config.ts @@ -1,54 +1,43 @@ import { - applyDirectoryQueryAndLimit, - collectNormalizedDirectoryIds, - toDirectoryEntries, + listInspectedDirectoryEntriesFromSources, type DirectoryConfigParams, } from "openclaw/plugin-sdk/directory-runtime"; import { inspectDiscordAccount, type InspectedDiscordAccount } from "../api.js"; export async function listDiscordDirectoryPeersFromConfig(params: DirectoryConfigParams) { - const account: InspectedDiscordAccount = inspectDiscordAccount({ - cfg: params.cfg, - accountId: params.accountId, - }); - if (!account.config) { - return []; - } - - const allowFrom = account.config.allowFrom ?? account.config.dm?.allowFrom ?? []; - const guildUsers = Object.values(account.config.guilds ?? {}).flatMap((guild) => [ - ...(guild.users ?? []), - ...Object.values(guild.channels ?? {}).flatMap((channel) => channel.users ?? []), - ]); - const ids = collectNormalizedDirectoryIds({ - sources: [allowFrom, Object.keys(account.config.dms ?? {}), guildUsers], + return listInspectedDirectoryEntriesFromSources({ + ...params, + kind: "user", + inspectAccount: (cfg, accountId) => + inspectDiscordAccount({ cfg, accountId }) as InspectedDiscordAccount | null, + resolveSources: (account) => { + const allowFrom = account.config.allowFrom ?? account.config.dm?.allowFrom ?? []; + const guildUsers = Object.values(account.config.guilds ?? {}).flatMap((guild) => [ + ...(guild.users ?? []), + ...Object.values(guild.channels ?? {}).flatMap((channel) => channel.users ?? []), + ]); + return [allowFrom, Object.keys(account.config.dms ?? {}), guildUsers]; + }, normalizeId: (raw) => { const mention = raw.match(/^<@!?(\d+)>$/); const cleaned = (mention?.[1] ?? raw).replace(/^(discord|user):/i, "").trim(); return /^\d+$/.test(cleaned) ? `user:${cleaned}` : null; }, }); - return toDirectoryEntries("user", applyDirectoryQueryAndLimit(ids, params)); } export async function listDiscordDirectoryGroupsFromConfig(params: DirectoryConfigParams) { - const account: InspectedDiscordAccount = inspectDiscordAccount({ - cfg: params.cfg, - accountId: params.accountId, - }); - if (!account.config) { - return []; - } - - const ids = collectNormalizedDirectoryIds({ - sources: Object.values(account.config.guilds ?? {}).map((guild) => - Object.keys(guild.channels ?? {}), - ), + return listInspectedDirectoryEntriesFromSources({ + ...params, + kind: "group", + inspectAccount: (cfg, accountId) => + inspectDiscordAccount({ cfg, accountId }) as InspectedDiscordAccount | null, + resolveSources: (account) => + Object.values(account.config.guilds ?? {}).map((guild) => Object.keys(guild.channels ?? {})), normalizeId: (raw) => { const mention = raw.match(/^<#(\d+)>$/); const cleaned = (mention?.[1] ?? raw).replace(/^(discord|channel|group):/i, "").trim(); return /^\d+$/.test(cleaned) ? `channel:${cleaned}` : null; }, }); - return toDirectoryEntries("group", applyDirectoryQueryAndLimit(ids, params)); } diff --git a/extensions/discord/src/monitor/message-handler.process.ts b/extensions/discord/src/monitor/message-handler.process.ts index 526ca4ecb71..f24a9e27774 100644 --- a/extensions/discord/src/monitor/message-handler.process.ts +++ b/extensions/discord/src/monitor/message-handler.process.ts @@ -16,6 +16,7 @@ import { resolveDiscordPreviewStreamMode } from "openclaw/plugin-sdk/config-runt import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime"; import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime"; import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime"; import { @@ -610,7 +611,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) } if (draftStream && isFinal) { await flushDraft(); - const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; + const reply = resolveSendableOutboundReplyParts(payload); + const hasMedia = reply.hasMedia; const finalText = payload.text; const previewFinalText = resolvePreviewFinalText(finalText); const previewMessageId = draftStream.messageId(); diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index 58e6083eef0..39bdad5b738 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -25,6 +25,10 @@ import { import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime"; import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; import { executePluginCommand, matchPluginCommand } from "openclaw/plugin-sdk/plugin-runtime"; +import { + resolveSendableOutboundReplyParts, + resolveTextChunksWithFallback, +} from "openclaw/plugin-sdk/reply-payload"; import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; import type { ChatCommandDefinition, @@ -232,13 +236,7 @@ function isDiscordUnknownInteraction(error: unknown): boolean { } function hasRenderableReplyPayload(payload: ReplyPayload): boolean { - if ((payload.text ?? "").trim()) { - return true; - } - if ((payload.mediaUrl ?? "").trim()) { - return true; - } - if (payload.mediaUrls?.some((entry) => entry.trim())) { + if (resolveSendableOutboundReplyParts(payload).hasContent) { return true; } const discordData = payload.channelData?.discord as @@ -887,8 +885,7 @@ async function deliverDiscordInteractionReply(params: { chunkMode: "length" | "newline"; }) { const { interaction, payload, textLimit, maxLinesPerMessage, preferFollowUp, chunkMode } = params; - const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); - const text = payload.text ?? ""; + const reply = resolveSendableOutboundReplyParts(payload); const discordData = payload.channelData?.discord as | { components?: TopLevelComponents[] } | undefined; @@ -933,9 +930,9 @@ async function deliverDiscordInteractionReply(params: { }); }; - if (mediaList.length > 0) { + if (reply.hasMedia) { const media = await Promise.all( - mediaList.map(async (url) => { + reply.mediaUrls.map(async (url) => { const loaded = await loadWebMedia(url, { localRoots: params.mediaLocalRoots, }); @@ -945,14 +942,14 @@ async function deliverDiscordInteractionReply(params: { }; }), ); - const chunks = chunkDiscordTextWithMode(text, { - maxChars: textLimit, - maxLines: maxLinesPerMessage, - chunkMode, - }); - if (!chunks.length && text) { - chunks.push(text); - } + const chunks = resolveTextChunksWithFallback( + reply.text, + chunkDiscordTextWithMode(reply.text, { + maxChars: textLimit, + maxLines: maxLinesPerMessage, + chunkMode, + }), + ); const caption = chunks[0] ?? ""; await sendMessage(caption, media, firstMessageComponents); for (const chunk of chunks.slice(1)) { @@ -964,17 +961,20 @@ async function deliverDiscordInteractionReply(params: { return; } - if (!text.trim() && !firstMessageComponents) { + if (!reply.hasText && !firstMessageComponents) { return; } - const chunks = chunkDiscordTextWithMode(text, { - maxChars: textLimit, - maxLines: maxLinesPerMessage, - chunkMode, - }); - if (!chunks.length && (text || firstMessageComponents)) { - chunks.push(text); - } + const chunks = + reply.text || firstMessageComponents + ? resolveTextChunksWithFallback( + reply.text, + chunkDiscordTextWithMode(reply.text, { + maxChars: textLimit, + maxLines: maxLinesPerMessage, + chunkMode, + }), + ) + : []; for (const chunk of chunks) { if (!chunk.trim() && !firstMessageComponents) { continue; diff --git a/extensions/discord/src/monitor/reply-delivery.ts b/extensions/discord/src/monitor/reply-delivery.ts index 6e495d420ce..a098c41d056 100644 --- a/extensions/discord/src/monitor/reply-delivery.ts +++ b/extensions/discord/src/monitor/reply-delivery.ts @@ -8,6 +8,11 @@ import { retryAsync, type RetryConfig, } from "openclaw/plugin-sdk/infra-runtime"; +import { + resolveSendableOutboundReplyParts, + resolveTextChunksWithFallback, + sendMediaWithLeadingCaption, +} from "openclaw/plugin-sdk/reply-payload"; import type { ChunkMode } from "openclaw/plugin-sdk/reply-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; @@ -209,35 +214,6 @@ async function sendDiscordChunkWithFallback(params: { ); } -async function sendAdditionalDiscordMedia(params: { - cfg: OpenClawConfig; - target: string; - token: string; - rest?: RequestClient; - accountId?: string; - mediaUrls: string[]; - mediaLocalRoots?: readonly string[]; - resolveReplyTo: () => string | undefined; - retryConfig: ResolvedRetryConfig; -}) { - for (const mediaUrl of params.mediaUrls) { - const replyTo = params.resolveReplyTo(); - await sendWithRetry( - () => - sendMessageDiscord(params.target, "", { - cfg: params.cfg, - token: params.token, - rest: params.rest, - mediaUrl, - accountId: params.accountId, - mediaLocalRoots: params.mediaLocalRoots, - replyTo, - }), - params.retryConfig, - ); - } -} - export async function deliverDiscordReply(params: { cfg: OpenClawConfig; replies: ReplyPayload[]; @@ -292,23 +268,23 @@ export async function deliverDiscordReply(params: { : undefined; let deliveredAny = false; for (const payload of params.replies) { - const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); - const rawText = payload.text ?? ""; const tableMode = params.tableMode ?? "code"; - const text = convertMarkdownTables(rawText, tableMode); - if (!text && mediaList.length === 0) { + const reply = resolveSendableOutboundReplyParts(payload, { + text: convertMarkdownTables(payload.text ?? "", tableMode), + }); + if (!reply.hasContent) { continue; } - if (mediaList.length === 0) { + if (!reply.hasMedia) { const mode = params.chunkMode ?? "length"; - const chunks = chunkDiscordTextWithMode(text, { - maxChars: chunkLimit, - maxLines: params.maxLinesPerMessage, - chunkMode: mode, - }); - if (!chunks.length && text) { - chunks.push(text); - } + const chunks = resolveTextChunksWithFallback( + reply.text, + chunkDiscordTextWithMode(reply.text, { + maxChars: chunkLimit, + maxLines: params.maxLinesPerMessage, + chunkMode: mode, + }), + ); for (const chunk of chunks) { if (!chunk.trim()) { continue; @@ -336,23 +312,10 @@ export async function deliverDiscordReply(params: { continue; } - const firstMedia = mediaList[0]; + const firstMedia = reply.mediaUrls[0]; if (!firstMedia) { continue; } - const sendRemainingMedia = () => - sendAdditionalDiscordMedia({ - cfg: params.cfg, - target: params.target, - token: params.token, - rest: params.rest, - accountId: params.accountId, - mediaUrls: mediaList.slice(1), - mediaLocalRoots: params.mediaLocalRoots, - resolveReplyTo, - retryConfig, - }); - // Voice message path: audioAsVoice flag routes through sendVoiceMessageDiscord. if (payload.audioAsVoice) { const replyTo = resolveReplyTo(); @@ -368,7 +331,7 @@ export async function deliverDiscordReply(params: { await sendDiscordChunkWithFallback({ cfg: params.cfg, target: params.target, - text, + text: reply.text, token: params.token, rest: params.rest, accountId: params.accountId, @@ -383,22 +346,50 @@ export async function deliverDiscordReply(params: { retryConfig, }); // Additional media items are sent as regular attachments (voice is single-file only). - await sendRemainingMedia(); + await sendMediaWithLeadingCaption({ + mediaUrls: reply.mediaUrls.slice(1), + caption: "", + send: async ({ mediaUrl }) => { + const replyTo = resolveReplyTo(); + await sendWithRetry( + () => + sendMessageDiscord(params.target, "", { + cfg: params.cfg, + token: params.token, + rest: params.rest, + mediaUrl, + accountId: params.accountId, + mediaLocalRoots: params.mediaLocalRoots, + replyTo, + }), + retryConfig, + ); + }, + }); continue; } - const replyTo = resolveReplyTo(); - await sendMessageDiscord(params.target, text, { - cfg: params.cfg, - token: params.token, - rest: params.rest, - mediaUrl: firstMedia, - accountId: params.accountId, - mediaLocalRoots: params.mediaLocalRoots, - replyTo, + await sendMediaWithLeadingCaption({ + mediaUrls: reply.mediaUrls, + caption: reply.text, + send: async ({ mediaUrl, caption }) => { + const replyTo = resolveReplyTo(); + await sendWithRetry( + () => + sendMessageDiscord(params.target, caption ?? "", { + cfg: params.cfg, + token: params.token, + rest: params.rest, + mediaUrl, + accountId: params.accountId, + mediaLocalRoots: params.mediaLocalRoots, + replyTo, + }), + retryConfig, + ); + }, }); deliveredAny = true; - await sendRemainingMedia(); } if (binding && deliveredAny) { diff --git a/extensions/discord/src/outbound-adapter.test.ts b/extensions/discord/src/outbound-adapter.test.ts index 3321a9cb59b..c3833972f44 100644 --- a/extensions/discord/src/outbound-adapter.test.ts +++ b/extensions/discord/src/outbound-adapter.test.ts @@ -3,11 +3,13 @@ import { normalizeDiscordOutboundTarget } from "./normalize.js"; const hoisted = vi.hoisted(() => { const sendMessageDiscordMock = vi.fn(); + const sendDiscordComponentMessageMock = vi.fn(); const sendPollDiscordMock = vi.fn(); const sendWebhookMessageDiscordMock = vi.fn(); const getThreadBindingManagerMock = vi.fn(); return { sendMessageDiscordMock, + sendDiscordComponentMessageMock, sendPollDiscordMock, sendWebhookMessageDiscordMock, getThreadBindingManagerMock, @@ -19,6 +21,8 @@ vi.mock("./send.js", async (importOriginal) => { return { ...actual, sendMessageDiscord: (...args: unknown[]) => hoisted.sendMessageDiscordMock(...args), + sendDiscordComponentMessage: (...args: unknown[]) => + hoisted.sendDiscordComponentMessageMock(...args), sendPollDiscord: (...args: unknown[]) => hoisted.sendPollDiscordMock(...args), sendWebhookMessageDiscord: (...args: unknown[]) => hoisted.sendWebhookMessageDiscordMock(...args), @@ -114,6 +118,10 @@ describe("discordOutbound", () => { messageId: "msg-1", channelId: "ch-1", }); + hoisted.sendDiscordComponentMessageMock.mockClear().mockResolvedValue({ + messageId: "component-1", + channelId: "ch-1", + }); hoisted.sendPollDiscordMock.mockClear().mockResolvedValue({ messageId: "poll-1", channelId: "ch-1", @@ -249,8 +257,61 @@ describe("discordOutbound", () => { }), ); expect(result).toEqual({ + channel: "discord", messageId: "poll-1", channelId: "ch-1", }); }); + + it("sends component payload media sequences with the component message first", async () => { + hoisted.sendDiscordComponentMessageMock.mockResolvedValueOnce({ + messageId: "component-1", + channelId: "ch-1", + }); + hoisted.sendMessageDiscordMock.mockResolvedValueOnce({ + messageId: "msg-2", + channelId: "ch-1", + }); + + const result = await discordOutbound.sendPayload?.({ + cfg: {}, + to: "channel:123456", + text: "", + payload: { + text: "hello", + mediaUrls: ["https://example.com/1.png", "https://example.com/2.png"], + channelData: { + discord: { + components: { text: "hello", components: [] }, + }, + }, + }, + accountId: "default", + mediaLocalRoots: ["/tmp/media"], + }); + + expect(hoisted.sendDiscordComponentMessageMock).toHaveBeenCalledWith( + "channel:123456", + expect.objectContaining({ text: "hello" }), + expect.objectContaining({ + mediaUrl: "https://example.com/1.png", + mediaLocalRoots: ["/tmp/media"], + accountId: "default", + }), + ); + expect(hoisted.sendMessageDiscordMock).toHaveBeenCalledWith( + "channel:123456", + "", + expect.objectContaining({ + mediaUrl: "https://example.com/2.png", + mediaLocalRoots: ["/tmp/media"], + accountId: "default", + }), + ); + expect(result).toEqual({ + channel: "discord", + messageId: "msg-2", + channelId: "ch-1", + }); + }); }); diff --git a/extensions/discord/src/outbound-adapter.ts b/extensions/discord/src/outbound-adapter.ts index 93fd1cb8bfb..8b18fffec90 100644 --- a/extensions/discord/src/outbound-adapter.ts +++ b/extensions/discord/src/outbound-adapter.ts @@ -1,10 +1,14 @@ import { resolvePayloadMediaUrls, - sendPayloadMediaSequence, + sendPayloadMediaSequenceOrFallback, sendTextMediaPayload, } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime"; import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; +import { + attachChannelToResult, + createAttachedChannelResultAdapter, +} from "openclaw/plugin-sdk/channel-send-result"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { OutboundIdentity } from "openclaw/plugin-sdk/infra-runtime"; import type { DiscordComponentMessageSpec } from "./components.js"; @@ -123,18 +127,17 @@ export const discordOutbound: ChannelOutboundAdapter = { resolveOutboundSendDep(ctx.deps, "discord") ?? sendMessageDiscord; const target = resolveDiscordOutboundTarget({ to: ctx.to, threadId: ctx.threadId }); const mediaUrls = resolvePayloadMediaUrls(payload); - if (mediaUrls.length === 0) { - const result = await sendDiscordComponentMessage(target, componentSpec, { - replyTo: ctx.replyToId ?? undefined, - accountId: ctx.accountId ?? undefined, - silent: ctx.silent ?? undefined, - cfg: ctx.cfg, - }); - return { channel: "discord", ...result }; - } - const lastResult = await sendPayloadMediaSequence({ + const result = await sendPayloadMediaSequenceOrFallback({ text: payload.text ?? "", mediaUrls, + fallbackResult: { messageId: "", channelId: target }, + sendNoMedia: async () => + await sendDiscordComponentMessage(target, componentSpec, { + replyTo: ctx.replyToId ?? undefined, + accountId: ctx.accountId ?? undefined, + silent: ctx.silent ?? undefined, + cfg: ctx.cfg, + }), send: async ({ text, mediaUrl, isFirst }) => { if (isFirst) { return await sendDiscordComponentMessage(target, componentSpec, { @@ -157,68 +160,63 @@ export const discordOutbound: ChannelOutboundAdapter = { }); }, }); - return lastResult - ? { channel: "discord", ...lastResult } - : { channel: "discord", messageId: "" }; + return attachChannelToResult("discord", result); }, - sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity, silent }) => { - if (!silent) { - const webhookResult = await maybeSendDiscordWebhookText({ - cfg, - text, - threadId, - accountId, - identity, - replyToId, - }).catch(() => null); - if (webhookResult) { - return { channel: "discord", ...webhookResult }; + ...createAttachedChannelResultAdapter({ + channel: "discord", + sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity, silent }) => { + if (!silent) { + const webhookResult = await maybeSendDiscordWebhookText({ + cfg, + text, + threadId, + accountId, + identity, + replyToId, + }).catch(() => null); + if (webhookResult) { + return webhookResult; + } } - } - const send = - resolveOutboundSendDep(deps, "discord") ?? sendMessageDiscord; - const target = resolveDiscordOutboundTarget({ to, threadId }); - const result = await send(target, text, { - verbose: false, - replyTo: replyToId ?? undefined, - accountId: accountId ?? undefined, - silent: silent ?? undefined, + const send = + resolveOutboundSendDep(deps, "discord") ?? sendMessageDiscord; + return await send(resolveDiscordOutboundTarget({ to, threadId }), text, { + verbose: false, + replyTo: replyToId ?? undefined, + accountId: accountId ?? undefined, + silent: silent ?? undefined, + cfg, + }); + }, + sendMedia: async ({ cfg, - }); - return { channel: "discord", ...result }; - }, - sendMedia: async ({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId, - deps, - replyToId, - threadId, - silent, - }) => { - const send = - resolveOutboundSendDep(deps, "discord") ?? sendMessageDiscord; - const target = resolveDiscordOutboundTarget({ to, threadId }); - const result = await send(target, text, { - verbose: false, + to, + text, mediaUrl, mediaLocalRoots, - replyTo: replyToId ?? undefined, - accountId: accountId ?? undefined, - silent: silent ?? undefined, - cfg, - }); - return { channel: "discord", ...result }; - }, - sendPoll: async ({ cfg, to, poll, accountId, threadId, silent }) => { - const target = resolveDiscordOutboundTarget({ to, threadId }); - return await sendPollDiscord(target, poll, { - accountId: accountId ?? undefined, - silent: silent ?? undefined, - cfg, - }); - }, + accountId, + deps, + replyToId, + threadId, + silent, + }) => { + const send = + resolveOutboundSendDep(deps, "discord") ?? sendMessageDiscord; + return await send(resolveDiscordOutboundTarget({ to, threadId }), text, { + verbose: false, + mediaUrl, + mediaLocalRoots, + replyTo: replyToId ?? undefined, + accountId: accountId ?? undefined, + silent: silent ?? undefined, + cfg, + }); + }, + sendPoll: async ({ cfg, to, poll, accountId, threadId, silent }) => + await sendPollDiscord(resolveDiscordOutboundTarget({ to, threadId }), poll, { + accountId: accountId ?? undefined, + silent: silent ?? undefined, + cfg, + }), + }), }; diff --git a/extensions/discord/src/send.shared.ts b/extensions/discord/src/send.shared.ts index d3b248a3c6f..8cdc8ce2805 100644 --- a/extensions/discord/src/send.shared.ts +++ b/extensions/discord/src/send.shared.ts @@ -17,6 +17,7 @@ import { normalizePollInput, type PollInput, } from "openclaw/plugin-sdk/media-runtime"; +import { resolveTextChunksWithFallback } from "openclaw/plugin-sdk/reply-payload"; import type { ChunkMode } from "openclaw/plugin-sdk/reply-runtime"; import { loadWebMedia } from "openclaw/plugin-sdk/web-media"; import { resolveDiscordAccount } from "./accounts.js"; @@ -276,10 +277,7 @@ export function buildDiscordTextChunks( maxLines: opts.maxLinesPerMessage, chunkMode: opts.chunkMode, }); - if (!chunks.length && text) { - chunks.push(text); - } - return chunks; + return resolveTextChunksWithFallback(text, chunks); } function hasV2Components(components?: TopLevelComponents[]): boolean { diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 0aa071e7abd..97fd5dd068d 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -1,7 +1,17 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; import { createHybridChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers"; -import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; -import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime"; +import { + createAllowlistProviderGroupPolicyWarningCollector, + projectWarningCollector, +} from "openclaw/plugin-sdk/channel-policy"; +import { + createChannelDirectoryAdapter, + createMessageToolCardSchema, + createPairingPrefixStripper, + createRuntimeDirectoryLiveAdapter, + createRuntimeOutboundDelegates, + createTextPairingAdapter, +} from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelMessageActionAdapter, ChannelMessageToolDiscovery, @@ -53,6 +63,24 @@ const loadFeishuChannelRuntime = createLazyRuntimeNamedExport( "feishuChannelRuntime", ); +const collectFeishuSecurityWarnings = createAllowlistProviderGroupPolicyWarningCollector<{ + cfg: ClawdbotConfig; + accountId?: string | null; +}>({ + providerConfigPresent: (cfg) => cfg.channels?.feishu !== undefined, + resolveGroupPolicy: ({ cfg, accountId }) => + resolveFeishuAccount({ cfg, accountId }).config?.groupPolicy, + collect: ({ cfg, accountId, groupPolicy }) => { + if (groupPolicy !== "open") { + return []; + } + const account = resolveFeishuAccount({ cfg, accountId }); + return [ + `- Feishu[${account.accountId}] groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" + channels.feishu.groupAllowFrom to restrict senders.`, + ]; + }, +}); + function describeFeishuMessageTool({ cfg, }: Parameters< @@ -355,18 +383,19 @@ export const feishuPlugin: ChannelPlugin = { meta: { ...meta, }, - pairing: { + pairing: createTextPairingAdapter({ idLabel: "feishuUserId", - normalizeAllowEntry: (entry) => entry.replace(/^(feishu|user|open_id):/i, ""), - notifyApproval: async ({ cfg, id }) => { + message: PAIRING_APPROVED_MESSAGE, + normalizeAllowEntry: createPairingPrefixStripper(/^(feishu|user|open_id):/i), + notify: async ({ cfg, id, message }) => { const { sendMessageFeishu } = await loadFeishuChannelRuntime(); await sendMessageFeishu({ cfg, to: id, - text: PAIRING_APPROVED_MESSAGE, + text: message, }); }, - }, + }), capabilities: { chatTypes: ["direct", "channel"], polls: false, @@ -839,19 +868,13 @@ export const feishuPlugin: ChannelPlugin = { }, }, security: { - collectWarnings: ({ cfg, accountId }) => { - const account = resolveFeishuAccount({ cfg, accountId }); - const feishuCfg = account.config; - return collectAllowlistProviderRestrictSendersWarnings({ + collectWarnings: projectWarningCollector( + ({ cfg, accountId }: { cfg: ClawdbotConfig; accountId?: string | null }) => ({ cfg, - providerConfigPresent: cfg.channels?.feishu !== undefined, - configuredGroupPolicy: feishuCfg?.groupPolicy, - surface: `Feishu[${account.accountId}] groups`, - openScope: "any member", - groupPolicyPath: "channels.feishu.groupPolicy", - groupAllowFromPath: "channels.feishu.groupAllowFrom", - }); - }, + accountId, + }), + collectFeishuSecurityWarnings, + ), }, bindings: { compileConfiguredBinding: ({ conversationId }) => @@ -873,8 +896,7 @@ export const feishuPlugin: ChannelPlugin = { hint: "", }, }, - directory: { - self: async () => null, + directory: createChannelDirectoryAdapter({ listPeers: async ({ cfg, query, limit, accountId }) => listFeishuDirectoryPeers({ cfg, @@ -889,29 +911,38 @@ export const feishuPlugin: ChannelPlugin = { limit: limit ?? undefined, accountId: accountId ?? undefined, }), - listPeersLive: async ({ cfg, query, limit, accountId }) => - (await loadFeishuChannelRuntime()).listFeishuDirectoryPeersLive({ - cfg, - query: query ?? undefined, - limit: limit ?? undefined, - accountId: accountId ?? undefined, - }), - listGroupsLive: async ({ cfg, query, limit, accountId }) => - (await loadFeishuChannelRuntime()).listFeishuDirectoryGroupsLive({ - cfg, - query: query ?? undefined, - limit: limit ?? undefined, - accountId: accountId ?? undefined, - }), - }, + ...createRuntimeDirectoryLiveAdapter({ + getRuntime: loadFeishuChannelRuntime, + listPeersLive: + (runtime) => + async ({ cfg, query, limit, accountId }) => + await runtime.listFeishuDirectoryPeersLive({ + cfg, + query: query ?? undefined, + limit: limit ?? undefined, + accountId: accountId ?? undefined, + }), + listGroupsLive: + (runtime) => + async ({ cfg, query, limit, accountId }) => + await runtime.listFeishuDirectoryGroupsLive({ + cfg, + query: query ?? undefined, + limit: limit ?? undefined, + accountId: accountId ?? undefined, + }), + }), + }), outbound: { deliveryMode: "direct", chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit), chunkerMode: "markdown", textChunkLimit: 4000, - sendText: async (params) => (await loadFeishuChannelRuntime()).feishuOutbound.sendText!(params), - sendMedia: async (params) => - (await loadFeishuChannelRuntime()).feishuOutbound.sendMedia!(params), + ...createRuntimeOutboundDelegates({ + getRuntime: loadFeishuChannelRuntime, + sendText: { resolve: (runtime) => runtime.feishuOutbound.sendText }, + sendMedia: { resolve: (runtime) => runtime.feishuOutbound.sendMedia }, + }), }, status: { defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { port: null }), diff --git a/extensions/feishu/src/outbound.ts b/extensions/feishu/src/outbound.ts index fd79bff869f..0c449f82bd2 100644 --- a/extensions/feishu/src/outbound.ts +++ b/extensions/feishu/src/outbound.ts @@ -1,5 +1,6 @@ import fs from "fs"; import path from "path"; +import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result"; import type { ChannelOutboundAdapter } from "../runtime-api.js"; import { resolveFeishuAccount } from "./accounts.js"; import { sendMediaFeishu } from "./media.js"; @@ -81,128 +82,124 @@ export const feishuOutbound: ChannelOutboundAdapter = { chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit), chunkerMode: "markdown", textChunkLimit: 4000, - sendText: async ({ - cfg, - to, - text, - accountId, - replyToId, - threadId, - mediaLocalRoots, - identity, - }) => { - const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId }); - // Scheme A compatibility shim: - // when upstream accidentally returns a local image path as plain text, - // auto-upload and send as Feishu image message instead of leaking path text. - const localImagePath = normalizePossibleLocalImagePath(text); - if (localImagePath) { - try { - const result = await sendMediaFeishu({ - cfg, - to, - mediaUrl: localImagePath, - accountId: accountId ?? undefined, - replyToMessageId, - mediaLocalRoots, - }); - return { channel: "feishu", ...result }; - } catch (err) { - console.error(`[feishu] local image path auto-send failed:`, err); - // fall through to plain text as last resort - } - } - - const account = resolveFeishuAccount({ cfg, accountId: accountId ?? undefined }); - const renderMode = account.config?.renderMode ?? "auto"; - const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text)); - if (useCard) { - const header = identity - ? { - title: identity.emoji - ? `${identity.emoji} ${identity.name ?? ""}`.trim() - : (identity.name ?? ""), - template: "blue" as const, - } - : undefined; - const result = await sendStructuredCardFeishu({ - cfg, - to, - text, - replyToMessageId, - replyInThread: threadId != null && !replyToId, - accountId: accountId ?? undefined, - header: header?.title ? header : undefined, - }); - return { channel: "feishu", ...result }; - } - const result = await sendOutboundText({ + ...createAttachedChannelResultAdapter({ + channel: "feishu", + sendText: async ({ cfg, to, text, - accountId: accountId ?? undefined, - replyToMessageId, - }); - return { channel: "feishu", ...result }; - }, - sendMedia: async ({ - cfg, - to, - text, - mediaUrl, - accountId, - mediaLocalRoots, - replyToId, - threadId, - }) => { - const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId }); - // Send text first if provided - if (text?.trim()) { - await sendOutboundText({ + accountId, + replyToId, + threadId, + mediaLocalRoots, + identity, + }) => { + const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId }); + // Scheme A compatibility shim: + // when upstream accidentally returns a local image path as plain text, + // auto-upload and send as Feishu image message instead of leaking path text. + const localImagePath = normalizePossibleLocalImagePath(text); + if (localImagePath) { + try { + return await sendMediaFeishu({ + cfg, + to, + mediaUrl: localImagePath, + accountId: accountId ?? undefined, + replyToMessageId, + mediaLocalRoots, + }); + } catch (err) { + console.error(`[feishu] local image path auto-send failed:`, err); + // fall through to plain text as last resort + } + } + + const account = resolveFeishuAccount({ cfg, accountId: accountId ?? undefined }); + const renderMode = account.config?.renderMode ?? "auto"; + const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text)); + if (useCard) { + const header = identity + ? { + title: identity.emoji + ? `${identity.emoji} ${identity.name ?? ""}`.trim() + : (identity.name ?? ""), + template: "blue" as const, + } + : undefined; + return await sendStructuredCardFeishu({ + cfg, + to, + text, + replyToMessageId, + replyInThread: threadId != null && !replyToId, + accountId: accountId ?? undefined, + header: header?.title ? header : undefined, + }); + } + return await sendOutboundText({ cfg, to, text, accountId: accountId ?? undefined, replyToMessageId, }); - } - - // Upload and send media if URL or local path provided - if (mediaUrl) { - try { - const result = await sendMediaFeishu({ - cfg, - to, - mediaUrl, - accountId: accountId ?? undefined, - mediaLocalRoots, - replyToMessageId, - }); - return { channel: "feishu", ...result }; - } catch (err) { - // Log the error for debugging - console.error(`[feishu] sendMediaFeishu failed:`, err); - // Fallback to URL link if upload fails - const fallbackText = `📎 ${mediaUrl}`; - const result = await sendOutboundText({ - cfg, - to, - text: fallbackText, - accountId: accountId ?? undefined, - replyToMessageId, - }); - return { channel: "feishu", ...result }; - } - } - - // No media URL, just return text result - const result = await sendOutboundText({ + }, + sendMedia: async ({ cfg, to, - text: text ?? "", - accountId: accountId ?? undefined, - replyToMessageId, - }); - return { channel: "feishu", ...result }; - }, + text, + mediaUrl, + accountId, + mediaLocalRoots, + replyToId, + threadId, + }) => { + const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId }); + // Send text first if provided + if (text?.trim()) { + await sendOutboundText({ + cfg, + to, + text, + accountId: accountId ?? undefined, + replyToMessageId, + }); + } + + // Upload and send media if URL or local path provided + if (mediaUrl) { + try { + return await sendMediaFeishu({ + cfg, + to, + mediaUrl, + accountId: accountId ?? undefined, + mediaLocalRoots, + replyToMessageId, + }); + } catch (err) { + // Log the error for debugging + console.error(`[feishu] sendMediaFeishu failed:`, err); + // Fallback to URL link if upload fails + return await sendOutboundText({ + cfg, + to, + text: `📎 ${mediaUrl}`, + accountId: accountId ?? undefined, + replyToMessageId, + }); + } + } + + // No media URL, just return text result + return await sendOutboundText({ + cfg, + to, + text: text ?? "", + accountId: accountId ?? undefined, + replyToMessageId, + }); + }, + }), }; diff --git a/extensions/feishu/src/reply-dispatcher.ts b/extensions/feishu/src/reply-dispatcher.ts index 8c2d533fbfa..ff787bc7cb0 100644 --- a/extensions/feishu/src/reply-dispatcher.ts +++ b/extensions/feishu/src/reply-dispatcher.ts @@ -1,3 +1,8 @@ +import { + resolveSendableOutboundReplyParts, + resolveTextChunksWithFallback, + sendMediaWithLeadingCaption, +} from "openclaw/plugin-sdk/reply-payload"; import { createReplyPrefixContext, createTypingCallbacks, @@ -13,12 +18,7 @@ import { sendMediaFeishu } from "./media.js"; import type { MentionTarget } from "./mention.js"; import { buildMentionedCardContent } from "./mention.js"; import { getFeishuRuntime } from "./runtime.js"; -import { - sendMarkdownCardFeishu, - sendMessageFeishu, - sendStructuredCardFeishu, - type CardHeaderConfig, -} from "./send.js"; +import { sendMessageFeishu, sendStructuredCardFeishu, type CardHeaderConfig } from "./send.js"; import { FeishuStreamingSession, mergeStreamingText } from "./streaming-card.js"; import { resolveReceiveIdType } from "./targets.js"; import { addTypingIndicator, removeTypingIndicator, type TypingIndicatorState } from "./typing.js"; @@ -300,37 +300,43 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP text: string; useCard: boolean; infoKind?: string; + sendChunk: (params: { chunk: string; isFirst: boolean }) => Promise; }) => { - let first = true; const chunkSource = params.useCard ? params.text : core.channel.text.convertMarkdownTables(params.text, tableMode); - for (const chunk of core.channel.text.chunkTextWithMode( + const chunks = resolveTextChunksWithFallback( chunkSource, - textChunkLimit, - chunkMode, - )) { - const message = { - cfg, - to: chatId, - text: chunk, - replyToMessageId: sendReplyToMessageId, - replyInThread: effectiveReplyInThread, - mentions: first ? mentionTargets : undefined, - accountId, - }; - if (params.useCard) { - await sendMarkdownCardFeishu(message); - } else { - await sendMessageFeishu(message); - } - first = false; + core.channel.text.chunkTextWithMode(chunkSource, textChunkLimit, chunkMode), + ); + for (const [index, chunk] of chunks.entries()) { + await params.sendChunk({ + chunk, + isFirst: index === 0, + }); } if (params.infoKind === "final") { deliveredFinalTexts.add(params.text); } }; + const sendMediaReplies = async (payload: ReplyPayload) => { + await sendMediaWithLeadingCaption({ + mediaUrls: resolveSendableOutboundReplyParts(payload).mediaUrls, + caption: "", + send: async ({ mediaUrl }) => { + await sendMediaFeishu({ + cfg, + to: chatId, + mediaUrl, + replyToMessageId: sendReplyToMessageId, + replyInThread: effectiveReplyInThread, + accountId, + }); + }, + }); + }; + const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({ responsePrefix: prefixContext.responsePrefix, @@ -344,15 +350,10 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP void typingCallbacks.onReplyStart?.(); }, deliver: async (payload: ReplyPayload, info) => { - const text = payload.text ?? ""; - const mediaList = - payload.mediaUrls && payload.mediaUrls.length > 0 - ? payload.mediaUrls - : payload.mediaUrl - ? [payload.mediaUrl] - : []; - const hasText = Boolean(text.trim()); - const hasMedia = mediaList.length > 0; + const reply = resolveSendableOutboundReplyParts(payload); + const text = reply.text; + const hasText = reply.hasText; + const hasMedia = reply.hasMedia; const skipTextForDuplicateFinal = info?.kind === "final" && hasText && deliveredFinalTexts.has(text); const shouldDeliverText = hasText && !skipTextForDuplicateFinal; @@ -363,7 +364,6 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP if (shouldDeliverText) { const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text)); - let first = true; if (info?.kind === "block") { // Drop internal block chunks unless we can safely consume them as @@ -397,16 +397,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP } // Send media even when streaming handled the text if (hasMedia) { - for (const mediaUrl of mediaList) { - await sendMediaFeishu({ - cfg, - to: chatId, - mediaUrl, - replyToMessageId: sendReplyToMessageId, - replyInThread: effectiveReplyInThread, - accountId, - }); - } + await sendMediaReplies(payload); } return; } @@ -414,43 +405,46 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP if (useCard) { const cardHeader = resolveCardHeader(agentId, identity); const cardNote = resolveCardNote(agentId, identity, prefixContext.prefixContext); - for (const chunk of core.channel.text.chunkTextWithMode( + await sendChunkedTextReply({ text, - textChunkLimit, - chunkMode, - )) { - await sendStructuredCardFeishu({ - cfg, - to: chatId, - text: chunk, - replyToMessageId: sendReplyToMessageId, - replyInThread: effectiveReplyInThread, - mentions: first ? mentionTargets : undefined, - accountId, - header: cardHeader, - note: cardNote, - }); - first = false; - } - if (info?.kind === "final") { - deliveredFinalTexts.add(text); - } + useCard: true, + infoKind: info?.kind, + sendChunk: async ({ chunk, isFirst }) => { + await sendStructuredCardFeishu({ + cfg, + to: chatId, + text: chunk, + replyToMessageId: sendReplyToMessageId, + replyInThread: effectiveReplyInThread, + mentions: isFirst ? mentionTargets : undefined, + accountId, + header: cardHeader, + note: cardNote, + }); + }, + }); } else { - await sendChunkedTextReply({ text, useCard: false, infoKind: info?.kind }); + await sendChunkedTextReply({ + text, + useCard: false, + infoKind: info?.kind, + sendChunk: async ({ chunk, isFirst }) => { + await sendMessageFeishu({ + cfg, + to: chatId, + text: chunk, + replyToMessageId: sendReplyToMessageId, + replyInThread: effectiveReplyInThread, + mentions: isFirst ? mentionTargets : undefined, + accountId, + }); + }, + }); } } if (hasMedia) { - for (const mediaUrl of mediaList) { - await sendMediaFeishu({ - cfg, - to: chatId, - mediaUrl, - replyToMessageId: sendReplyToMessageId, - replyInThread: effectiveReplyInThread, - accountId, - }); - } + await sendMediaReplies(payload); } }, onError: async (error, info) => { diff --git a/extensions/googlechat/src/channel.directory.test.ts b/extensions/googlechat/src/channel.directory.test.ts new file mode 100644 index 00000000000..7dbf68a0934 --- /dev/null +++ b/extensions/googlechat/src/channel.directory.test.ts @@ -0,0 +1,58 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/googlechat"; +import { describe, expect, it } from "vitest"; +import { + createDirectoryTestRuntime, + expectDirectorySurface, +} from "../../../test/helpers/extensions/directory.ts"; +import { googlechatPlugin } from "./channel.js"; + +describe("googlechat directory", () => { + const runtimeEnv = createDirectoryTestRuntime() as never; + + it("lists peers and groups from config", async () => { + const cfg = { + channels: { + googlechat: { + serviceAccount: { client_email: "bot@example.com" }, + dm: { allowFrom: ["users/alice", "googlechat:bob"] }, + groups: { + "spaces/AAA": {}, + "spaces/BBB": {}, + }, + }, + }, + } as unknown as OpenClawConfig; + + const directory = expectDirectorySurface(googlechatPlugin.directory); + + await expect( + directory.listPeers({ + cfg, + accountId: undefined, + query: undefined, + limit: undefined, + runtime: runtimeEnv, + }), + ).resolves.toEqual( + expect.arrayContaining([ + { kind: "user", id: "users/alice" }, + { kind: "user", id: "bob" }, + ]), + ); + + await expect( + directory.listGroups({ + cfg, + accountId: undefined, + query: undefined, + limit: undefined, + runtime: runtimeEnv, + }), + ).resolves.toEqual( + expect.arrayContaining([ + { kind: "group", id: "spaces/AAA" }, + { kind: "group", id: "spaces/BBB" }, + ]), + ); + }); +}); diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index 7cc86e81cda..29dfeae6ac0 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -4,9 +4,21 @@ import { createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; import { - buildOpenGroupPolicyConfigureRouteAllowlistWarning, - collectAllowlistProviderGroupPolicyWarnings, + composeWarningCollectors, + createAllowlistProviderGroupPolicyWarningCollector, + createConditionalWarningCollector, + createAllowlistProviderOpenWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; +import { + createAttachedChannelResultAdapter, + createChannelDirectoryAdapter, + createTopLevelChannelReplyToModeResolver, + createTextPairingAdapter, +} from "openclaw/plugin-sdk/channel-runtime"; +import { + listResolvedDirectoryGroupEntriesFromMapKeys, + listResolvedDirectoryUserEntriesFromAllowFrom, +} from "openclaw/plugin-sdk/directory-runtime"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { @@ -15,8 +27,6 @@ import { DEFAULT_ACCOUNT_ID, createAccountStatusSink, getChatChannelMeta, - listDirectoryGroupEntriesFromMapKeys, - listDirectoryUserEntriesFromAllowFrom, missingTargetError, PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes, @@ -103,15 +113,40 @@ const googlechatActions: ChannelMessageActionAdapter = { }, }; +const collectGoogleChatGroupPolicyWarnings = + createAllowlistProviderOpenWarningCollector({ + providerConfigPresent: (cfg) => cfg.channels?.googlechat !== undefined, + resolveGroupPolicy: (account) => account.config.groupPolicy, + buildOpenWarning: { + surface: "Google Chat spaces", + openBehavior: "allows any space to trigger (mention-gated)", + remediation: + 'Set channels.googlechat.groupPolicy="allowlist" and configure channels.googlechat.groups', + }, + }); + +const collectGoogleChatSecurityWarnings = composeWarningCollectors<{ + cfg: OpenClawConfig; + account: ResolvedGoogleChatAccount; +}>( + collectGoogleChatGroupPolicyWarnings, + createConditionalWarningCollector( + ({ account }) => + account.config.dm?.policy === "open" && + '- Google Chat DMs are open to anyone. Set channels.googlechat.dm.policy="pairing" or "allowlist".', + ), +); + export const googlechatPlugin: ChannelPlugin = { id: "googlechat", meta: { ...meta }, setup: googlechatSetupAdapter, setupWizard: googlechatSetupWizard, - pairing: { + pairing: createTextPairingAdapter({ idLabel: "googlechatUserId", + message: PAIRING_APPROVED_MESSAGE, normalizeAllowEntry: (entry) => formatAllowFromEntry(entry), - notifyApproval: async ({ cfg, id }) => { + notify: async ({ cfg, id, message }) => { const account = resolveGoogleChatAccount({ cfg: cfg }); if (account.credentialSource === "none") { return; @@ -123,10 +158,10 @@ export const googlechatPlugin: ChannelPlugin = { await sendGoogleChatMessage({ account, space, - text: PAIRING_APPROVED_MESSAGE, + text: message, }); }, - }, + }), capabilities: { chatTypes: ["direct", "group", "thread"], reactions: true, @@ -153,36 +188,13 @@ export const googlechatPlugin: ChannelPlugin = { }, security: { resolveDmPolicy: resolveGoogleChatDmPolicy, - collectWarnings: ({ account, cfg }) => { - const warnings = collectAllowlistProviderGroupPolicyWarnings({ - cfg, - providerConfigPresent: cfg.channels?.googlechat !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - collect: (groupPolicy) => - groupPolicy === "open" - ? [ - buildOpenGroupPolicyConfigureRouteAllowlistWarning({ - surface: "Google Chat spaces", - openScope: "any space", - groupPolicyPath: "channels.googlechat.groupPolicy", - routeAllowlistPath: "channels.googlechat.groups", - }), - ] - : [], - }); - if (account.config.dm?.policy === "open") { - warnings.push( - `- Google Chat DMs are open to anyone. Set channels.googlechat.dm.policy="pairing" or "allowlist".`, - ); - } - return warnings; - }, + collectWarnings: collectGoogleChatSecurityWarnings, }, groups: { resolveRequireMention: resolveGoogleChatGroupRequireMention, }, threading: { - resolveReplyToMode: ({ cfg }) => cfg.channels?.["googlechat"]?.replyToMode ?? "off", + resolveReplyToMode: createTopLevelChannelReplyToModeResolver("googlechat"), }, messaging: { normalizeTarget: normalizeGoogleChatTarget, @@ -194,32 +206,21 @@ export const googlechatPlugin: ChannelPlugin = { hint: "", }, }, - directory: { - self: async () => null, - listPeers: async ({ cfg, accountId, query, limit }) => { - const account = resolveGoogleChatAccount({ - cfg: cfg, - accountId, - }); - return listDirectoryUserEntriesFromAllowFrom({ - allowFrom: account.config.dm?.allowFrom, - query, - limit, + directory: createChannelDirectoryAdapter({ + listPeers: async (params) => + listResolvedDirectoryUserEntriesFromAllowFrom({ + ...params, + resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg, accountId }), + resolveAllowFrom: (account) => account.config.dm?.allowFrom, normalizeId: (entry) => normalizeGoogleChatTarget(entry) ?? entry, - }); - }, - listGroups: async ({ cfg, accountId, query, limit }) => { - const account = resolveGoogleChatAccount({ - cfg: cfg, - accountId, - }); - return listDirectoryGroupEntriesFromMapKeys({ - groups: account.config.groups, - query, - limit, - }); - }, - }, + }), + listGroups: async (params) => + listResolvedDirectoryGroupEntriesFromMapKeys({ + ...params, + resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg, accountId }), + resolveGroups: (account) => account.config.groups, + }), + }), resolver: { resolveTargets: async ({ inputs, kind }) => { const resolved = inputs.map((input) => { @@ -267,91 +268,97 @@ export const googlechatPlugin: ChannelPlugin = { error: missingTargetError("Google Chat", ""), }; }, - sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => { - const account = resolveGoogleChatAccount({ - cfg: cfg, - accountId, - }); - const space = await resolveGoogleChatOutboundSpace({ account, target: to }); - const thread = (threadId ?? replyToId ?? undefined) as string | undefined; - const { sendGoogleChatMessage } = await loadGoogleChatChannelRuntime(); - const result = await sendGoogleChatMessage({ - account, - space, + ...createAttachedChannelResultAdapter({ + channel: "googlechat", + sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => { + const account = resolveGoogleChatAccount({ + cfg: cfg, + accountId, + }); + const space = await resolveGoogleChatOutboundSpace({ account, target: to }); + const thread = (threadId ?? replyToId ?? undefined) as string | undefined; + const { sendGoogleChatMessage } = await loadGoogleChatChannelRuntime(); + const result = await sendGoogleChatMessage({ + account, + space, + text, + thread, + }); + return { + messageId: result?.messageName ?? "", + chatId: space, + }; + }, + sendMedia: async ({ + cfg, + to, text, - thread, - }); - return { - channel: "googlechat", - messageId: result?.messageName ?? "", - chatId: space, - }; - }, - sendMedia: async ({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId, - replyToId, - threadId, - }) => { - if (!mediaUrl) { - throw new Error("Google Chat mediaUrl is required."); - } - const account = resolveGoogleChatAccount({ - cfg: cfg, + mediaUrl, + mediaLocalRoots, accountId, - }); - const space = await resolveGoogleChatOutboundSpace({ account, target: to }); - const thread = (threadId ?? replyToId ?? undefined) as string | undefined; - const runtime = getGoogleChatRuntime(); - const maxBytes = resolveChannelMediaMaxBytes({ - cfg: cfg, - resolveChannelLimitMb: ({ cfg, accountId }) => - ( - cfg.channels?.["googlechat"] as - | { accounts?: Record; mediaMaxMb?: number } - | undefined - )?.accounts?.[accountId]?.mediaMaxMb ?? - (cfg.channels?.["googlechat"] as { mediaMaxMb?: number } | undefined)?.mediaMaxMb, - accountId, - }); - const effectiveMaxBytes = maxBytes ?? (account.config.mediaMaxMb ?? 20) * 1024 * 1024; - const loaded = /^https?:\/\//i.test(mediaUrl) - ? await runtime.channel.media.fetchRemoteMedia({ - url: mediaUrl, - maxBytes: effectiveMaxBytes, - }) - : await runtime.media.loadWebMedia(mediaUrl, { - maxBytes: effectiveMaxBytes, - localRoots: mediaLocalRoots?.length ? mediaLocalRoots : undefined, - }); - const { sendGoogleChatMessage, uploadGoogleChatAttachment } = - await loadGoogleChatChannelRuntime(); - const upload = await uploadGoogleChatAttachment({ - account, - space, - filename: loaded.fileName ?? "attachment", - buffer: loaded.buffer, - contentType: loaded.contentType, - }); - const result = await sendGoogleChatMessage({ - account, - space, - text, - thread, - attachments: upload.attachmentUploadToken - ? [{ attachmentUploadToken: upload.attachmentUploadToken, contentName: loaded.fileName }] - : undefined, - }); - return { - channel: "googlechat", - messageId: result?.messageName ?? "", - chatId: space, - }; - }, + replyToId, + threadId, + }) => { + if (!mediaUrl) { + throw new Error("Google Chat mediaUrl is required."); + } + const account = resolveGoogleChatAccount({ + cfg: cfg, + accountId, + }); + const space = await resolveGoogleChatOutboundSpace({ account, target: to }); + const thread = (threadId ?? replyToId ?? undefined) as string | undefined; + const runtime = getGoogleChatRuntime(); + const maxBytes = resolveChannelMediaMaxBytes({ + cfg: cfg, + resolveChannelLimitMb: ({ cfg, accountId }) => + ( + cfg.channels?.["googlechat"] as + | { accounts?: Record; mediaMaxMb?: number } + | undefined + )?.accounts?.[accountId]?.mediaMaxMb ?? + (cfg.channels?.["googlechat"] as { mediaMaxMb?: number } | undefined)?.mediaMaxMb, + accountId, + }); + const effectiveMaxBytes = maxBytes ?? (account.config.mediaMaxMb ?? 20) * 1024 * 1024; + const loaded = /^https?:\/\//i.test(mediaUrl) + ? await runtime.channel.media.fetchRemoteMedia({ + url: mediaUrl, + maxBytes: effectiveMaxBytes, + }) + : await runtime.media.loadWebMedia(mediaUrl, { + maxBytes: effectiveMaxBytes, + localRoots: mediaLocalRoots?.length ? mediaLocalRoots : undefined, + }); + const { sendGoogleChatMessage, uploadGoogleChatAttachment } = + await loadGoogleChatChannelRuntime(); + const upload = await uploadGoogleChatAttachment({ + account, + space, + filename: loaded.fileName ?? "attachment", + buffer: loaded.buffer, + contentType: loaded.contentType, + }); + const result = await sendGoogleChatMessage({ + account, + space, + text, + thread, + attachments: upload.attachmentUploadToken + ? [ + { + attachmentUploadToken: upload.attachmentUploadToken, + contentName: loaded.fileName, + }, + ] + : undefined, + }); + return { + messageId: result?.messageName ?? "", + chatId: space, + }; + }, + }), }, status: { defaultRuntime: { diff --git a/extensions/googlechat/src/monitor.ts b/extensions/googlechat/src/monitor.ts index 80ba9ff3939..b0612842919 100644 --- a/extensions/googlechat/src/monitor.ts +++ b/extensions/googlechat/src/monitor.ts @@ -1,4 +1,8 @@ import type { IncomingMessage, ServerResponse } from "node:http"; +import { + deliverTextOrMediaReply, + resolveSendableOutboundReplyParts, +} from "openclaw/plugin-sdk/reply-payload"; import type { OpenClawConfig } from "../runtime-api.js"; import { createWebhookInFlightLimiter, @@ -375,14 +379,14 @@ async function deliverGoogleChatReply(params: { }): Promise { const { payload, account, spaceId, runtime, core, config, statusSink, typingMessageName } = params; - const mediaList = payload.mediaUrls?.length - ? payload.mediaUrls - : payload.mediaUrl - ? [payload.mediaUrl] - : []; + const reply = resolveSendableOutboundReplyParts(payload); + const mediaCount = reply.mediaCount; + const hasMedia = reply.hasMedia; + const text = reply.text; + let firstTextChunk = true; + let suppressCaption = false; - if (mediaList.length > 0) { - let suppressCaption = false; + if (hasMedia) { if (typingMessageName) { try { await deleteGoogleChatMessage({ @@ -391,9 +395,9 @@ async function deliverGoogleChatReply(params: { }); } catch (err) { runtime.error?.(`Google Chat typing cleanup failed: ${String(err)}`); - const fallbackText = payload.text?.trim() - ? payload.text - : mediaList.length > 1 + const fallbackText = reply.hasText + ? text + : mediaCount > 1 ? "Sent attachments." : "Sent attachment."; try { @@ -402,16 +406,43 @@ async function deliverGoogleChatReply(params: { messageName: typingMessageName, text: fallbackText, }); - suppressCaption = Boolean(payload.text?.trim()); + suppressCaption = Boolean(text.trim()); } catch (updateErr) { runtime.error?.(`Google Chat typing update failed: ${String(updateErr)}`); } } } - let first = true; - for (const mediaUrl of mediaList) { - const caption = first && !suppressCaption ? payload.text : undefined; - first = false; + } + + const chunkLimit = account.config.textChunkLimit ?? 4000; + const chunkMode = core.channel.text.resolveChunkMode(config, "googlechat", account.accountId); + await deliverTextOrMediaReply({ + payload, + text: suppressCaption ? "" : reply.text, + chunkText: (value) => core.channel.text.chunkMarkdownTextWithMode(value, chunkLimit, chunkMode), + sendText: async (chunk) => { + try { + if (firstTextChunk && typingMessageName) { + await updateGoogleChatMessage({ + account, + messageName: typingMessageName, + text: chunk, + }); + } else { + await sendGoogleChatMessage({ + account, + space: spaceId, + text: chunk, + thread: payload.replyToId, + }); + } + firstTextChunk = false; + statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + runtime.error?.(`Google Chat message send failed: ${String(err)}`); + } + }, + sendMedia: async ({ mediaUrl, caption }) => { try { const loaded = await core.channel.media.fetchRemoteMedia({ url: mediaUrl, @@ -440,38 +471,8 @@ async function deliverGoogleChatReply(params: { } catch (err) { runtime.error?.(`Google Chat attachment send failed: ${String(err)}`); } - } - return; - } - - if (payload.text) { - const chunkLimit = account.config.textChunkLimit ?? 4000; - const chunkMode = core.channel.text.resolveChunkMode(config, "googlechat", account.accountId); - const chunks = core.channel.text.chunkMarkdownTextWithMode(payload.text, chunkLimit, chunkMode); - for (let i = 0; i < chunks.length; i++) { - const chunk = chunks[i]; - try { - // Edit typing message with first chunk if available - if (i === 0 && typingMessageName) { - await updateGoogleChatMessage({ - account, - messageName: typingMessageName, - text: chunk, - }); - } else { - await sendGoogleChatMessage({ - account, - space: spaceId, - text: chunk, - thread: payload.replyToId, - }); - } - statusSink?.({ lastOutboundAt: Date.now() }); - } catch (err) { - runtime.error?.(`Google Chat message send failed: ${String(err)}`); - } - } - } + }, + }); } async function uploadAttachmentForReply(params: { diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 27a26a9db88..514b798b7df 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -1,5 +1,8 @@ -import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit"; -import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; +import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit"; +import { + createAttachedChannelResultAdapter, + resolveOutboundSendDep, +} from "openclaw/plugin-sdk/channel-runtime"; import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/core"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { type RoutePeer } from "openclaw/plugin-sdk/routing"; @@ -21,6 +24,7 @@ import { imessageSetupAdapter } from "./setup-core.js"; import { collectIMessageSecurityWarnings, createIMessagePluginBase, + imessageConfigAdapter, imessageResolveDmPolicy, imessageSetupWizard, } from "./shared.js"; @@ -113,26 +117,15 @@ export const imessagePlugin: ChannelPlugin = { notifyApproval: async ({ id }) => await (await loadIMessageChannelRuntime()).notifyIMessageApproval(id), }, - allowlist: { - supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", - readConfig: ({ cfg, accountId }) => { - const account = resolveIMessageAccount({ cfg, accountId }); - return { - dmAllowFrom: (account.config.allowFrom ?? []).map(String), - groupAllowFrom: (account.config.groupAllowFrom ?? []).map(String), - dmPolicy: account.config.dmPolicy, - groupPolicy: account.config.groupPolicy, - }; - }, - applyConfigEdit: buildAccountScopedAllowlistConfigEditor({ - channelId: "imessage", - normalize: ({ values }) => formatTrimmedAllowFromEntries(values), - resolvePaths: (scope) => ({ - readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]], - writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"], - }), - }), - }, + allowlist: buildDmGroupAccountAllowlistAdapter({ + channelId: "imessage", + resolveAccount: ({ cfg, accountId }) => resolveIMessageAccount({ cfg, accountId }), + normalize: ({ values }) => formatTrimmedAllowFromEntries(values), + resolveDmAllowFrom: (account) => account.config.allowFrom, + resolveGroupAllowFrom: (account) => account.config.groupAllowFrom, + resolveDmPolicy: (account) => account.config.dmPolicy, + resolveGroupPolicy: (account) => account.config.groupPolicy, + }), security: { resolveDmPolicy: imessageResolveDmPolicy, collectWarnings: collectIMessageSecurityWarnings, @@ -170,34 +163,33 @@ export const imessagePlugin: ChannelPlugin = { chunker: (text, limit) => getIMessageRuntime().channel.text.chunkText(text, limit), chunkerMode: "text", textChunkLimit: 4000, - sendText: async ({ cfg, to, text, accountId, deps, replyToId }) => { - const result = await ( - await loadIMessageChannelRuntime() - ).sendIMessageOutbound({ - cfg, - to, - text, - accountId: accountId ?? undefined, - deps, - replyToId: replyToId ?? undefined, - }); - return { channel: "imessage", ...result }; - }, - sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps, replyToId }) => { - const result = await ( - await loadIMessageChannelRuntime() - ).sendIMessageOutbound({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId: accountId ?? undefined, - deps, - replyToId: replyToId ?? undefined, - }); - return { channel: "imessage", ...result }; - }, + ...createAttachedChannelResultAdapter({ + channel: "imessage", + sendText: async ({ cfg, to, text, accountId, deps, replyToId }) => + await ( + await loadIMessageChannelRuntime() + ).sendIMessageOutbound({ + cfg, + to, + text, + accountId: accountId ?? undefined, + deps, + replyToId: replyToId ?? undefined, + }), + sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps, replyToId }) => + await ( + await loadIMessageChannelRuntime() + ).sendIMessageOutbound({ + cfg, + to, + text, + mediaUrl, + mediaLocalRoots, + accountId: accountId ?? undefined, + deps, + replyToId: replyToId ?? undefined, + }), + }), }, status: { defaultRuntime: { diff --git a/extensions/imessage/src/monitor/deliver.ts b/extensions/imessage/src/monitor/deliver.ts index 65dc125be68..708d319b640 100644 --- a/extensions/imessage/src/monitor/deliver.ts +++ b/extensions/imessage/src/monitor/deliver.ts @@ -1,5 +1,9 @@ import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +import { + deliverTextOrMediaReply, + resolveSendableOutboundReplyParts, +} from "openclaw/plugin-sdk/reply-payload"; import { chunkTextWithMode, resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; @@ -30,15 +34,18 @@ export async function deliverReplies(params: { }); const chunkMode = resolveChunkMode(cfg, "imessage", accountId); for (const payload of replies) { - const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const rawText = sanitizeOutboundText(payload.text ?? ""); - const text = convertMarkdownTables(rawText, tableMode); - if (!text && mediaList.length === 0) { - continue; + const reply = resolveSendableOutboundReplyParts(payload, { + text: convertMarkdownTables(rawText, tableMode), + }); + if (!reply.hasMedia && reply.hasText) { + sentMessageCache?.remember(scope, { text: reply.text }); } - if (mediaList.length === 0) { - sentMessageCache?.remember(scope, { text }); - for (const chunk of chunkTextWithMode(text, textLimit, chunkMode)) { + const delivered = await deliverTextOrMediaReply({ + payload, + text: reply.text, + chunkText: (value) => chunkTextWithMode(value, textLimit, chunkMode), + sendText: async (chunk) => { const sent = await sendMessageIMessage(target, chunk, { maxBytes, client, @@ -46,14 +53,10 @@ export async function deliverReplies(params: { replyToId: payload.replyToId, }); sentMessageCache?.remember(scope, { text: chunk, messageId: sent.messageId }); - } - } else { - let first = true; - for (const url of mediaList) { - const caption = first ? text : ""; - first = false; - const sent = await sendMessageIMessage(target, caption, { - mediaUrl: url, + }, + sendMedia: async ({ mediaUrl, caption }) => { + const sent = await sendMessageIMessage(target, caption ?? "", { + mediaUrl, maxBytes, client, accountId, @@ -63,8 +66,10 @@ export async function deliverReplies(params: { text: caption || undefined, messageId: sent.messageId, }); - } + }, + }); + if (delivered !== "empty") { + runtime.log?.(`imessage: delivered reply to ${target}`); } - runtime.log?.(`imessage: delivered reply to ${target}`); } } diff --git a/extensions/imessage/src/probe.test.ts b/extensions/imessage/src/probe.test.ts index ef69337984b..fad23896170 100644 --- a/extensions/imessage/src/probe.test.ts +++ b/extensions/imessage/src/probe.test.ts @@ -1,13 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import * as onboardHelpers from "../../../src/commands/onboard-helpers.js"; -import * as execModule from "../../../src/process/exec.js"; +import * as processRuntime from "../../../src/plugin-sdk/process-runtime.js"; +import * as setupRuntime from "../../../src/plugin-sdk/setup.js"; import * as clientModule from "./client.js"; import { probeIMessage } from "./probe.js"; beforeEach(() => { vi.restoreAllMocks(); - vi.spyOn(onboardHelpers, "detectBinary").mockResolvedValue(true); - vi.spyOn(execModule, "runCommandWithTimeout").mockResolvedValue({ + vi.spyOn(setupRuntime, "detectBinary").mockResolvedValue(true); + vi.spyOn(processRuntime, "runCommandWithTimeout").mockResolvedValue({ stdout: "", stderr: 'unknown command "rpc" for "imsg"', code: 1, @@ -25,7 +25,7 @@ describe("probeIMessage", () => { request: vi.fn(), stop: vi.fn(), } as unknown as Awaited>); - const result = await probeIMessage(1000, { cliPath: "imsg" }); + const result = await probeIMessage(1000, { cliPath: "imsg-test-rpc" }); expect(result.ok).toBe(false); expect(result.fatal).toBe(true); expect(result.error).toMatch(/rpc/i); diff --git a/extensions/imessage/src/shared.ts b/extensions/imessage/src/shared.ts index cf3e7b173cf..41275715c36 100644 --- a/extensions/imessage/src/shared.ts +++ b/extensions/imessage/src/shared.ts @@ -1,9 +1,9 @@ import { - collectAllowlistProviderRestrictSendersWarnings, createScopedChannelConfigAdapter, createScopedDmSecurityResolver, formatTrimmedAllowFromEntries, } from "openclaw/plugin-sdk/channel-config-helpers"; +import { createAllowlistProviderRestrictSendersWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { createChannelPluginBase } from "openclaw/plugin-sdk/core"; import { buildChannelConfigSchema, @@ -47,21 +47,16 @@ export const imessageResolveDmPolicy = createScopedDmSecurityResolver[0]["cfg"]; -}) { - return collectAllowlistProviderRestrictSendersWarnings({ - cfg: params.cfg, - providerConfigPresent: params.cfg.channels?.imessage !== undefined, - configuredGroupPolicy: params.account.config.groupPolicy, +export const collectIMessageSecurityWarnings = + createAllowlistProviderRestrictSendersWarningCollector({ + providerConfigPresent: (cfg) => cfg.channels?.imessage !== undefined, + resolveGroupPolicy: (account) => account.config.groupPolicy, surface: "iMessage groups", openScope: "any member", groupPolicyPath: "channels.imessage.groupPolicy", groupAllowFromPath: "channels.imessage.groupAllowFrom", mentionGated: false, }); -} export function createIMessagePluginBase(params: { setupWizard?: NonNullable["setupWizard"]>; diff --git a/extensions/imessage/src/targets.test.ts b/extensions/imessage/src/targets.test.ts index 2a29a7ea167..ec5360a50b0 100644 --- a/extensions/imessage/src/targets.test.ts +++ b/extensions/imessage/src/targets.test.ts @@ -10,9 +10,13 @@ import { const spawnMock = vi.hoisted(() => vi.fn()); -vi.mock("node:child_process", () => ({ - spawn: (...args: unknown[]) => spawnMock(...args), -})); +vi.mock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawn: (...args: unknown[]) => spawnMock(...args), + }; +}); describe("imessage targets", () => { it("parses chat_id targets", () => { diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts index a0f6c9a5bc8..a4e75f72af5 100644 --- a/extensions/irc/src/channel.ts +++ b/extensions/irc/src/channel.ts @@ -4,9 +4,16 @@ import { createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; import { - buildOpenGroupPolicyWarning, - collectAllowlistProviderGroupPolicyWarnings, + composeWarningCollectors, + createAllowlistProviderOpenWarningCollector, + createConditionalWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; +import { + createAttachedChannelResultAdapter, + createChannelDirectoryAdapter, + createTextPairingAdapter, + listResolvedDirectoryEntriesFromSources, +} from "openclaw/plugin-sdk/channel-runtime"; import { runStoppablePassiveMonitor } from "../../shared/passive-monitor.js"; import { listIrcAccountIds, @@ -88,6 +95,36 @@ const resolveIrcDmPolicy = createScopedDmSecurityResolver({ normalizeEntry: (raw) => normalizeIrcAllowEntry(raw), }); +const collectIrcGroupPolicyWarnings = + createAllowlistProviderOpenWarningCollector({ + providerConfigPresent: (cfg) => cfg.channels?.irc !== undefined, + resolveGroupPolicy: (account) => account.config.groupPolicy, + buildOpenWarning: { + surface: "IRC channels", + openBehavior: "allows all channels and senders (mention-gated)", + remediation: 'Prefer channels.irc.groupPolicy="allowlist" with channels.irc.groups', + }, + }); + +const collectIrcSecurityWarnings = composeWarningCollectors<{ + account: ResolvedIrcAccount; + cfg: CoreConfig; +}>( + collectIrcGroupPolicyWarnings, + createConditionalWarningCollector( + ({ account }) => + !account.config.tls && + "- IRC TLS is disabled (channels.irc.tls=false); traffic and credentials are plaintext.", + ({ account }) => + account.config.nickserv?.register && + '- IRC NickServ registration is enabled (channels.irc.nickserv.register=true); this sends "REGISTER" on every connect. Disable after first successful registration.', + ({ account }) => + account.config.nickserv?.register && + !account.config.nickserv.password?.trim() && + "- IRC NickServ registration is enabled but no NickServ password is resolved; set channels.irc.nickserv.password, channels.irc.nickserv.passwordFile, or IRC_NICKSERV_PASSWORD.", + ), +); + export const ircPlugin: ChannelPlugin = { id: "irc", meta: { @@ -96,17 +133,18 @@ export const ircPlugin: ChannelPlugin = { }, setup: ircSetupAdapter, setupWizard: ircSetupWizard, - pairing: { + pairing: createTextPairingAdapter({ idLabel: "ircUser", + message: PAIRING_APPROVED_MESSAGE, normalizeAllowEntry: (entry) => normalizeIrcAllowEntry(entry), - notifyApproval: async ({ id }) => { + notify: async ({ id, message }) => { const target = normalizePairingTarget(id); if (!target) { throw new Error(`invalid IRC pairing id: ${id}`); } - await sendMessageIrc(target, PAIRING_APPROVED_MESSAGE); + await sendMessageIrc(target, message); }, - }, + }), capabilities: { chatTypes: ["direct", "group"], media: true, @@ -131,40 +169,7 @@ export const ircPlugin: ChannelPlugin = { }, security: { resolveDmPolicy: resolveIrcDmPolicy, - collectWarnings: ({ account, cfg }) => { - const warnings = collectAllowlistProviderGroupPolicyWarnings({ - cfg, - providerConfigPresent: cfg.channels?.irc !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - collect: (groupPolicy) => - groupPolicy === "open" - ? [ - buildOpenGroupPolicyWarning({ - surface: "IRC channels", - openBehavior: "allows all channels and senders (mention-gated)", - remediation: - 'Prefer channels.irc.groupPolicy="allowlist" with channels.irc.groups', - }), - ] - : [], - }); - if (!account.config.tls) { - warnings.push( - "- IRC TLS is disabled (channels.irc.tls=false); traffic and credentials are plaintext.", - ); - } - if (account.config.nickserv?.register) { - warnings.push( - '- IRC NickServ registration is enabled (channels.irc.nickserv.register=true); this sends "REGISTER" on every connect. Disable after first successful registration.', - ); - if (!account.config.nickserv.password?.trim()) { - warnings.push( - "- IRC NickServ registration is enabled but no NickServ password is resolved; set channels.irc.nickserv.password, channels.irc.nickserv.passwordFile, or IRC_NICKSERV_PASSWORD.", - ); - } - } - return warnings; - }, + collectWarnings: collectIrcSecurityWarnings, }, groups: { resolveRequireMention: ({ cfg, accountId, groupId }) => { @@ -230,88 +235,58 @@ export const ircPlugin: ChannelPlugin = { }); }, }, - directory: { - self: async () => null, - listPeers: async ({ cfg, accountId, query, limit }) => { - const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }); - const q = query?.trim().toLowerCase() ?? ""; - const ids = new Set(); - - for (const entry of account.config.allowFrom ?? []) { - const normalized = normalizePairingTarget(String(entry)); - if (normalized && normalized !== "*") { - ids.add(normalized); - } - } - for (const entry of account.config.groupAllowFrom ?? []) { - const normalized = normalizePairingTarget(String(entry)); - if (normalized && normalized !== "*") { - ids.add(normalized); - } - } - for (const group of Object.values(account.config.groups ?? {})) { - for (const entry of group.allowFrom ?? []) { - const normalized = normalizePairingTarget(String(entry)); - if (normalized && normalized !== "*") { - ids.add(normalized); - } - } - } - - return Array.from(ids) - .filter((id) => (q ? id.includes(q) : true)) - .slice(0, limit && limit > 0 ? limit : undefined) - .map((id) => ({ kind: "user", id })); + directory: createChannelDirectoryAdapter({ + listPeers: async (params) => + listResolvedDirectoryEntriesFromSources({ + ...params, + kind: "user", + resolveAccount: (cfg, accountId) => + resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }), + resolveSources: (account) => [ + account.config.allowFrom ?? [], + account.config.groupAllowFrom ?? [], + ...Object.values(account.config.groups ?? {}).map((group) => group.allowFrom ?? []), + ], + normalizeId: (entry) => normalizePairingTarget(entry) || null, + }), + listGroups: async (params) => { + const entries = listResolvedDirectoryEntriesFromSources({ + ...params, + kind: "group", + resolveAccount: (cfg, accountId) => + resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }), + resolveSources: (account) => [ + account.config.channels ?? [], + Object.keys(account.config.groups ?? {}), + ], + normalizeId: (entry) => { + const normalized = normalizeIrcMessagingTarget(entry); + return normalized && isChannelTarget(normalized) ? normalized : null; + }, + }); + return entries.map((entry) => ({ ...entry, name: entry.id })); }, - listGroups: async ({ cfg, accountId, query, limit }) => { - const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }); - const q = query?.trim().toLowerCase() ?? ""; - const groupIds = new Set(); - - for (const channel of account.config.channels ?? []) { - const normalized = normalizeIrcMessagingTarget(channel); - if (normalized && isChannelTarget(normalized)) { - groupIds.add(normalized); - } - } - for (const group of Object.keys(account.config.groups ?? {})) { - if (group === "*") { - continue; - } - const normalized = normalizeIrcMessagingTarget(group); - if (normalized && isChannelTarget(normalized)) { - groupIds.add(normalized); - } - } - - return Array.from(groupIds) - .filter((id) => (q ? id.toLowerCase().includes(q) : true)) - .slice(0, limit && limit > 0 ? limit : undefined) - .map((id) => ({ kind: "group", id, name: id })); - }, - }, + }), outbound: { deliveryMode: "direct", chunker: (text, limit) => getIrcRuntime().channel.text.chunkMarkdownText(text, limit), chunkerMode: "markdown", textChunkLimit: 350, - sendText: async ({ cfg, to, text, accountId, replyToId }) => { - const result = await sendMessageIrc(to, text, { - cfg: cfg as CoreConfig, - accountId: accountId ?? undefined, - replyTo: replyToId ?? undefined, - }); - return { channel: "irc", ...result }; - }, - sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) => { - const combined = mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text; - const result = await sendMessageIrc(to, combined, { - cfg: cfg as CoreConfig, - accountId: accountId ?? undefined, - replyTo: replyToId ?? undefined, - }); - return { channel: "irc", ...result }; - }, + ...createAttachedChannelResultAdapter({ + channel: "irc", + sendText: async ({ cfg, to, text, accountId, replyToId }) => + await sendMessageIrc(to, text, { + cfg: cfg as CoreConfig, + accountId: accountId ?? undefined, + replyTo: replyToId ?? undefined, + }), + sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) => + await sendMessageIrc(to, mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text, { + cfg: cfg as CoreConfig, + accountId: accountId ?? undefined, + replyTo: replyToId ?? undefined, + }), + }), }, status: { defaultRuntime: { diff --git a/extensions/irc/src/inbound.ts b/extensions/irc/src/inbound.ts index 8d1995336b4..aa763d4c561 100644 --- a/extensions/irc/src/inbound.ts +++ b/extensions/irc/src/inbound.ts @@ -10,14 +10,13 @@ import { import { GROUP_POLICY_BLOCKED_LABEL, createScopedPairingAccess, + deliverFormattedTextWithAttachments, dispatchInboundReplyWithBase, - formatTextWithAttachmentLinks, issuePairingChallenge, logInboundDrop, isDangerousNameMatchingEnabled, readStoreAllowFromForDmPolicy, resolveControlCommandGate, - resolveOutboundMediaUrls, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, resolveEffectiveAllowFromLists, @@ -61,23 +60,23 @@ async function deliverIrcReply(params: { sendReply?: (target: string, text: string, replyToId?: string) => Promise; statusSink?: (patch: { lastOutboundAt?: number }) => void; }) { - const combined = formatTextWithAttachmentLinks( - params.payload.text, - resolveOutboundMediaUrls(params.payload), - ); - if (!combined) { + const delivered = await deliverFormattedTextWithAttachments({ + payload: params.payload, + send: async ({ text, replyToId }) => { + if (params.sendReply) { + await params.sendReply(params.target, text, replyToId); + } else { + await sendMessageIrc(params.target, text, { + accountId: params.accountId, + replyTo: replyToId, + }); + } + params.statusSink?.({ lastOutboundAt: Date.now() }); + }, + }); + if (!delivered) { return; } - - if (params.sendReply) { - await params.sendReply(params.target, combined, params.payload.replyToId); - } else { - await sendMessageIrc(params.target, combined, { - accountId: params.accountId, - replyTo: params.payload.replyToId, - }); - } - params.statusSink?.({ lastOutboundAt: Date.now() }); } export async function handleIrcInbound(params: { diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index 33f2b7aa247..d983d2a0172 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -1,5 +1,13 @@ import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; -import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; +import { createAllowlistProviderRestrictSendersWarningCollector } from "openclaw/plugin-sdk/channel-policy"; +import { + createAttachedChannelResultAdapter, + createEmptyChannelDirectoryAdapter, + createEmptyChannelResult, + createPairingPrefixStripper, + createTextPairingAdapter, +} from "openclaw/plugin-sdk/channel-runtime"; +import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload"; import { buildChannelConfigSchema, buildComputedAccountStatusSnapshot, @@ -42,29 +50,39 @@ const resolveLineDmPolicy = createScopedDmSecurityResolver( normalizeEntry: (raw) => raw.replace(/^line:(?:user:)?/i, ""), }); +const collectLineSecurityWarnings = + createAllowlistProviderRestrictSendersWarningCollector({ + providerConfigPresent: (cfg) => cfg.channels?.line !== undefined, + resolveGroupPolicy: (account) => account.config.groupPolicy, + surface: "LINE groups", + openScope: "any member in groups", + groupPolicyPath: "channels.line.groupPolicy", + groupAllowFromPath: "channels.line.groupAllowFrom", + mentionGated: false, + }); + export const linePlugin: ChannelPlugin = { id: "line", meta: { ...meta, quickstartAllowFrom: true, }, - pairing: { + pairing: createTextPairingAdapter({ idLabel: "lineUserId", - normalizeAllowEntry: (entry) => { - // LINE IDs are case-sensitive; only strip prefix variants (line: / line:user:). - return entry.replace(/^line:(?:user:)?/i, ""); - }, - notifyApproval: async ({ cfg, id }) => { + message: "OpenClaw: your access has been approved.", + // LINE IDs are case-sensitive; only strip prefix variants (line: / line:user:). + normalizeAllowEntry: createPairingPrefixStripper(/^line:(?:user:)?/i), + notify: async ({ cfg, id, message }) => { const line = getLineRuntime().channel.line; const account = line.resolveLineAccount({ cfg }); if (!account.channelAccessToken) { throw new Error("LINE channel access token not configured"); } - await line.pushMessageLine(id, "OpenClaw: your access has been approved.", { + await line.pushMessageLine(id, message, { channelAccessToken: account.channelAccessToken, }); }, - }, + }), capabilities: { chatTypes: ["direct", "group"], reactions: false, @@ -90,18 +108,7 @@ export const linePlugin: ChannelPlugin = { }, security: { resolveDmPolicy: resolveLineDmPolicy, - collectWarnings: ({ account, cfg }) => { - return collectAllowlistProviderRestrictSendersWarnings({ - cfg, - providerConfigPresent: cfg.channels?.line !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - surface: "LINE groups", - openScope: "any member in groups", - groupPolicyPath: "channels.line.groupPolicy", - groupAllowFromPath: "channels.line.groupAllowFrom", - mentionGated: false, - }); - }, + collectWarnings: collectLineSecurityWarnings, }, groups: { resolveRequireMention: resolveLineGroupRequireMention, @@ -128,11 +135,7 @@ export const linePlugin: ChannelPlugin = { hint: "", }, }, - directory: { - self: async () => null, - listPeers: async () => [], - listGroups: async () => [], - }, + directory: createEmptyChannelDirectoryAdapter(), setup: lineSetupAdapter, outbound: { deliveryMode: "direct", @@ -184,7 +187,7 @@ export const linePlugin: ChannelPlugin = { const chunks = processed.text ? runtime.channel.text.chunkMarkdownText(processed.text, chunkLimit) : []; - const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + const mediaUrls = resolveOutboundMediaUrls(payload); const shouldSendQuickRepliesInline = chunks.length === 0 && hasQuickReplies; const sendMediaMessages = async () => { for (const url of mediaUrls) { @@ -317,54 +320,45 @@ export const linePlugin: ChannelPlugin = { } if (lastResult) { - return { channel: "line", ...lastResult }; + return createEmptyChannelResult("line", { ...lastResult }); } - return { channel: "line", messageId: "empty", chatId: to }; + return createEmptyChannelResult("line", { messageId: "empty", chatId: to }); }, - sendText: async ({ cfg, to, text, accountId }) => { - const runtime = getLineRuntime(); - const sendText = runtime.channel.line.pushMessageLine; - const sendFlex = runtime.channel.line.pushFlexMessage; - - // Process markdown: extract tables/code blocks, strip formatting - const processed = processLineMessage(text); - - // Send cleaned text first (if non-empty) - let result: { messageId: string; chatId: string }; - if (processed.text.trim()) { - result = await sendText(to, processed.text, { + ...createAttachedChannelResultAdapter({ + channel: "line", + sendText: async ({ cfg, to, text, accountId }) => { + const runtime = getLineRuntime(); + const sendText = runtime.channel.line.pushMessageLine; + const sendFlex = runtime.channel.line.pushFlexMessage; + const processed = processLineMessage(text); + let result: { messageId: string; chatId: string }; + if (processed.text.trim()) { + result = await sendText(to, processed.text, { + verbose: false, + cfg, + accountId: accountId ?? undefined, + }); + } else { + result = { messageId: "processed", chatId: to }; + } + for (const flexMsg of processed.flexMessages) { + const flexContents = flexMsg.contents as Parameters[2]; + await sendFlex(to, flexMsg.altText, flexContents, { + verbose: false, + cfg, + accountId: accountId ?? undefined, + }); + } + return result; + }, + sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => + await getLineRuntime().channel.line.sendMessageLine(to, text, { verbose: false, + mediaUrl, cfg, accountId: accountId ?? undefined, - }); - } else { - // If text is empty after processing, still need a result - result = { messageId: "processed", chatId: to }; - } - - // Send flex messages for tables/code blocks - for (const flexMsg of processed.flexMessages) { - // LINE SDK expects FlexContainer but we receive contents as unknown - const flexContents = flexMsg.contents as Parameters[2]; - await sendFlex(to, flexMsg.altText, flexContents, { - verbose: false, - cfg, - accountId: accountId ?? undefined, - }); - } - - return { channel: "line", ...result }; - }, - sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => { - const send = getLineRuntime().channel.line.sendMessageLine; - const result = await send(to, text, { - verbose: false, - mediaUrl, - cfg, - accountId: accountId ?? undefined, - }); - return { channel: "line", ...result }; - }, + }), + }), }, status: { defaultRuntime: { diff --git a/extensions/line/src/config-adapter.ts b/extensions/line/src/config-adapter.ts index 118159f16b2..1b10989b45c 100644 --- a/extensions/line/src/config-adapter.ts +++ b/extensions/line/src/config-adapter.ts @@ -1,13 +1,11 @@ import { createScopedChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers"; -import type { OpenClawConfig, ResolvedLineAccount } from "../api.js"; -import { getLineRuntime } from "./runtime.js"; - -function resolveLineRuntimeAccount(cfg: OpenClawConfig, accountId?: string | null) { - return getLineRuntime().channel.line.resolveLineAccount({ - cfg, - accountId: accountId ?? undefined, - }); -} +import { + listLineAccountIds, + resolveDefaultLineAccountId, + resolveLineAccount, + type OpenClawConfig, + type ResolvedLineAccount, +} from "../runtime-api.js"; export function normalizeLineAllowFrom(entry: string): string { return entry.replace(/^line:(?:user:)?/i, ""); @@ -19,9 +17,10 @@ export const lineConfigAdapter = createScopedChannelConfigAdapter< OpenClawConfig >({ sectionKey: "line", - listAccountIds: (cfg) => getLineRuntime().channel.line.listLineAccountIds(cfg), - resolveAccount: (cfg, accountId) => resolveLineRuntimeAccount(cfg, accountId), - defaultAccountId: (cfg) => getLineRuntime().channel.line.resolveDefaultLineAccountId(cfg), + listAccountIds: listLineAccountIds, + resolveAccount: (cfg, accountId) => + resolveLineAccount({ cfg, accountId: accountId ?? undefined }), + defaultAccountId: resolveDefaultLineAccountId, clearBaseFields: ["channelSecret", "tokenFile", "secretFile"], resolveAllowFrom: (account) => account.config.allowFrom, formatAllowFrom: (allowFrom) => diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index aaf18e3f94b..4c83f627261 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -3,9 +3,18 @@ import { createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; import { - buildOpenGroupPolicyWarning, - collectAllowlistProviderGroupPolicyWarnings, + createAllowlistProviderOpenWarningCollector, + projectWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; +import { + createChannelDirectoryAdapter, + createPairingPrefixStripper, + createScopedAccountReplyToModeResolver, + createRuntimeDirectoryLiveAdapter, + createRuntimeOutboundDelegates, + createTextPairingAdapter, + listResolvedDirectoryEntriesFromSources, +} from "openclaw/plugin-sdk/channel-runtime"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { buildTrafficStatusSummary } from "../../shared/channel-status-summary.js"; import { @@ -100,18 +109,31 @@ const resolveMatrixDmPolicy = createScopedDmSecurityResolver normalizeMatrixUserId(raw), }); +const collectMatrixSecurityWarnings = + createAllowlistProviderOpenWarningCollector({ + providerConfigPresent: (cfg) => (cfg as CoreConfig).channels?.matrix !== undefined, + resolveGroupPolicy: (account) => account.config.groupPolicy, + buildOpenWarning: { + surface: "Matrix rooms", + openBehavior: "allows any room to trigger (mention-gated)", + remediation: + 'Set channels.matrix.groupPolicy="allowlist" + channels.matrix.groups (and optionally channels.matrix.groupAllowFrom) to restrict rooms', + }, + }); + export const matrixPlugin: ChannelPlugin = { id: "matrix", meta, setupWizard: matrixSetupWizard, - pairing: { + pairing: createTextPairingAdapter({ idLabel: "matrixUserId", - normalizeAllowEntry: (entry) => entry.replace(/^matrix:/i, ""), - notifyApproval: async ({ id }) => { + message: PAIRING_APPROVED_MESSAGE, + normalizeAllowEntry: createPairingPrefixStripper(/^matrix:/i), + notify: async ({ id, message }) => { const { sendMessageMatrix } = await loadMatrixChannelRuntime(); - await sendMessageMatrix(`user:${id}`, PAIRING_APPROVED_MESSAGE); + await sendMessageMatrix(`user:${id}`, message); }, - }, + }), capabilities: { chatTypes: ["direct", "group", "thread"], polls: true, @@ -134,32 +156,24 @@ export const matrixPlugin: ChannelPlugin = { }, security: { resolveDmPolicy: resolveMatrixDmPolicy, - collectWarnings: ({ account, cfg }) => { - return collectAllowlistProviderGroupPolicyWarnings({ + collectWarnings: projectWarningCollector( + ({ account, cfg }: { account: ResolvedMatrixAccount; cfg: unknown }) => ({ + account, cfg: cfg as CoreConfig, - providerConfigPresent: (cfg as CoreConfig).channels?.matrix !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - collect: (groupPolicy) => - groupPolicy === "open" - ? [ - buildOpenGroupPolicyWarning({ - surface: "Matrix rooms", - openBehavior: "allows any room to trigger (mention-gated)", - remediation: - 'Set channels.matrix.groupPolicy="allowlist" + channels.matrix.groups (and optionally channels.matrix.groupAllowFrom) to restrict rooms', - }), - ] - : [], - }); - }, + }), + collectMatrixSecurityWarnings, + ), }, groups: { resolveRequireMention: resolveMatrixGroupRequireMention, resolveToolPolicy: resolveMatrixGroupToolPolicy, }, threading: { - resolveReplyToMode: ({ cfg, accountId }) => - resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId }).replyToMode ?? "off", + resolveReplyToMode: createScopedAccountReplyToModeResolver({ + resolveAccount: (cfg, accountId) => + resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId }), + resolveReplyToMode: (account) => account.replyToMode, + }), buildToolContext: ({ context, hasRepliedRef }) => { const currentTarget = context.To; return { @@ -187,101 +201,63 @@ export const matrixPlugin: ChannelPlugin = { hint: "", }, }, - directory: { - self: async () => null, - listPeers: async ({ cfg, accountId, query, limit }) => { - const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }); - const q = query?.trim().toLowerCase() || ""; - const ids = new Set(); - - for (const entry of account.config.dm?.allowFrom ?? []) { - const raw = String(entry).trim(); - if (!raw || raw === "*") { - continue; - } - ids.add(raw.replace(/^matrix:/i, "")); - } - - for (const entry of account.config.groupAllowFrom ?? []) { - const raw = String(entry).trim(); - if (!raw || raw === "*") { - continue; - } - ids.add(raw.replace(/^matrix:/i, "")); - } - - const groups = account.config.groups ?? account.config.rooms ?? {}; - for (const room of Object.values(groups)) { - for (const entry of room.users ?? []) { - const raw = String(entry).trim(); + directory: createChannelDirectoryAdapter({ + listPeers: async (params) => { + const entries = listResolvedDirectoryEntriesFromSources({ + ...params, + kind: "user", + resolveAccount: (cfg, accountId) => + resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }), + resolveSources: (account) => [ + account.config.dm?.allowFrom ?? [], + account.config.groupAllowFrom ?? [], + ...Object.values(account.config.groups ?? account.config.rooms ?? {}).map( + (room) => room.users ?? [], + ), + ], + normalizeId: (entry) => { + const raw = entry.replace(/^matrix:/i, "").trim(); if (!raw || raw === "*") { - continue; + return null; } - ids.add(raw.replace(/^matrix:/i, "")); - } - } - - return Array.from(ids) - .map((raw) => raw.trim()) - .filter(Boolean) - .map((raw) => { const lowered = raw.toLowerCase(); const cleaned = lowered.startsWith("user:") ? raw.slice("user:".length).trim() : raw; - if (cleaned.startsWith("@")) { - return `user:${cleaned}`; - } - return cleaned; - }) - .filter((id) => (q ? id.toLowerCase().includes(q) : true)) - .slice(0, limit && limit > 0 ? limit : undefined) - .map((id) => { - const raw = id.startsWith("user:") ? id.slice("user:".length) : id; - const incomplete = !raw.startsWith("@") || !raw.includes(":"); - return { - kind: "user", - id, - ...(incomplete ? { name: "incomplete id; expected @user:server" } : {}), - }; - }); + return cleaned.startsWith("@") ? `user:${cleaned}` : cleaned; + }, + }); + return entries.map((entry) => { + const raw = entry.id.startsWith("user:") ? entry.id.slice("user:".length) : entry.id; + const incomplete = !raw.startsWith("@") || !raw.includes(":"); + return incomplete ? { ...entry, name: "incomplete id; expected @user:server" } : entry; + }); }, - listGroups: async ({ cfg, accountId, query, limit }) => { - const account = resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }); - const q = query?.trim().toLowerCase() || ""; - const groups = account.config.groups ?? account.config.rooms ?? {}; - const ids = Object.keys(groups) - .map((raw) => raw.trim()) - .filter((raw) => Boolean(raw) && raw !== "*") - .map((raw) => raw.replace(/^matrix:/i, "")) - .map((raw) => { + listGroups: async (params) => + listResolvedDirectoryEntriesFromSources({ + ...params, + kind: "group", + resolveAccount: (cfg, accountId) => + resolveMatrixAccount({ cfg: cfg as CoreConfig, accountId }), + resolveSources: (account) => [ + Object.keys(account.config.groups ?? account.config.rooms ?? {}), + ], + normalizeId: (entry) => { + const raw = entry.replace(/^matrix:/i, "").trim(); + if (!raw || raw === "*") { + return null; + } const lowered = raw.toLowerCase(); if (lowered.startsWith("room:") || lowered.startsWith("channel:")) { return raw; } - if (raw.startsWith("!")) { - return `room:${raw}`; - } - return raw; - }) - .filter((id) => (q ? id.toLowerCase().includes(q) : true)) - .slice(0, limit && limit > 0 ? limit : undefined) - .map((id) => ({ kind: "group", id }) as const); - return ids; - }, - listPeersLive: async ({ cfg, accountId, query, limit }) => - (await loadMatrixChannelRuntime()).listMatrixDirectoryPeersLive({ - cfg, - accountId, - query, - limit, + return raw.startsWith("!") ? `room:${raw}` : raw; + }, }), - listGroupsLive: async ({ cfg, accountId, query, limit }) => - (await loadMatrixChannelRuntime()).listMatrixDirectoryGroupsLive({ - cfg, - accountId, - query, - limit, - }), - }, + ...createRuntimeDirectoryLiveAdapter({ + getRuntime: loadMatrixChannelRuntime, + listPeersLive: (runtime) => runtime.listMatrixDirectoryPeersLive, + listGroupsLive: (runtime) => runtime.listMatrixDirectoryGroupsLive, + }), + }), resolver: { resolveTargets: async ({ cfg, inputs, kind, runtime }) => (await loadMatrixChannelRuntime()).resolveMatrixTargets({ cfg, inputs, kind, runtime }), @@ -293,27 +269,21 @@ export const matrixPlugin: ChannelPlugin = { chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText!(text, limit), chunkerMode: "markdown", textChunkLimit: 4000, - sendText: async (params) => { - const outbound = (await loadMatrixChannelRuntime()).matrixOutbound; - if (!outbound.sendText) { - throw new Error("Matrix outbound text delivery is unavailable"); - } - return await outbound.sendText(params); - }, - sendMedia: async (params) => { - const outbound = (await loadMatrixChannelRuntime()).matrixOutbound; - if (!outbound.sendMedia) { - throw new Error("Matrix outbound media delivery is unavailable"); - } - return await outbound.sendMedia(params); - }, - sendPoll: async (params) => { - const outbound = (await loadMatrixChannelRuntime()).matrixOutbound; - if (!outbound.sendPoll) { - throw new Error("Matrix outbound poll delivery is unavailable"); - } - return await outbound.sendPoll(params); - }, + ...createRuntimeOutboundDelegates({ + getRuntime: loadMatrixChannelRuntime, + sendText: { + resolve: (runtime) => runtime.matrixOutbound.sendText, + unavailableMessage: "Matrix outbound text delivery is unavailable", + }, + sendMedia: { + resolve: (runtime) => runtime.matrixOutbound.sendMedia, + unavailableMessage: "Matrix outbound media delivery is unavailable", + }, + sendPoll: { + resolve: (runtime) => runtime.matrixOutbound.sendPoll, + unavailableMessage: "Matrix outbound poll delivery is unavailable", + }, + }), }, status: { defaultRuntime: { diff --git a/extensions/matrix/src/matrix/monitor/replies.ts b/extensions/matrix/src/matrix/monitor/replies.ts index 004701edae4..dac58c680ed 100644 --- a/extensions/matrix/src/matrix/monitor/replies.ts +++ b/extensions/matrix/src/matrix/monitor/replies.ts @@ -1,4 +1,8 @@ import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import { + deliverTextOrMediaReply, + resolveSendableOutboundReplyParts, +} from "openclaw/plugin-sdk/reply-payload"; import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "../../../runtime-api.js"; import { getMatrixRuntime } from "../../runtime.js"; import { sendMessageMatrix } from "../send.js"; @@ -32,8 +36,10 @@ export async function deliverMatrixReplies(params: { const chunkMode = core.channel.text.resolveChunkMode(cfg, "matrix", params.accountId); let hasReplied = false; for (const reply of params.replies) { - const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0; - if (!reply?.text && !hasMedia) { + const rawText = reply.text ?? ""; + const text = core.channel.text.convertMarkdownTables(rawText, tableMode); + const replyContent = resolveSendableOutboundReplyParts(reply, { text }); + if (!replyContent.hasContent) { if (reply?.audioAsVoice) { logVerbose("matrix reply has audioAsVoice without media/text; skipping"); continue; @@ -48,57 +54,39 @@ export async function deliverMatrixReplies(params: { } const replyToIdRaw = reply.replyToId?.trim(); const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw; - const rawText = reply.text ?? ""; - const text = core.channel.text.convertMarkdownTables(rawText, tableMode); - const mediaList = reply.mediaUrls?.length - ? reply.mediaUrls - : reply.mediaUrl - ? [reply.mediaUrl] - : []; const shouldIncludeReply = (id?: string) => Boolean(id) && (params.replyToMode === "all" || !hasReplied); const replyToIdForReply = shouldIncludeReply(replyToId) ? replyToId : undefined; - if (mediaList.length === 0) { - let sentTextChunk = false; - for (const chunk of core.channel.text.chunkMarkdownTextWithMode( - text, - chunkLimit, - chunkMode, - )) { - const trimmed = chunk.trim(); - if (!trimmed) { - continue; - } + const delivered = await deliverTextOrMediaReply({ + payload: reply, + text: replyContent.text, + chunkText: (value) => + core.channel.text + .chunkMarkdownTextWithMode(value, chunkLimit, chunkMode) + .map((chunk) => chunk.trim()) + .filter(Boolean), + sendText: async (trimmed) => { await sendMessageMatrix(params.roomId, trimmed, { client: params.client, replyToId: replyToIdForReply, threadId: params.threadId, accountId: params.accountId, }); - sentTextChunk = true; - } - if (replyToIdForReply && !hasReplied && sentTextChunk) { - hasReplied = true; - } - continue; - } - - let first = true; - for (const mediaUrl of mediaList) { - const caption = first ? text : ""; - await sendMessageMatrix(params.roomId, caption, { - client: params.client, - mediaUrl, - replyToId: replyToIdForReply, - threadId: params.threadId, - audioAsVoice: reply.audioAsVoice, - accountId: params.accountId, - }); - first = false; - } - if (replyToIdForReply && !hasReplied) { + }, + sendMedia: async ({ mediaUrl, caption }) => { + await sendMessageMatrix(params.roomId, caption ?? "", { + client: params.client, + mediaUrl, + replyToId: replyToIdForReply, + threadId: params.threadId, + audioAsVoice: reply.audioAsVoice, + accountId: params.accountId, + }); + }, + }); + if (replyToIdForReply && !hasReplied && delivered !== "empty") { hasReplied = true; } } diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 8c32e068165..cf8f51c245c 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -3,9 +3,15 @@ import { createScopedChannelConfigAdapter, createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; -import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; -import { createMessageToolButtonsSchema } from "openclaw/plugin-sdk/channel-runtime"; -import type { ChannelMessageToolDiscovery } from "openclaw/plugin-sdk/channel-runtime"; +import { createAllowlistProviderRestrictSendersWarningCollector } from "openclaw/plugin-sdk/channel-policy"; +import { + createAttachedChannelResultAdapter, + createChannelDirectoryAdapter, + createLoggedPairingApprovalNotifier, + createMessageToolButtonsSchema, + createScopedAccountReplyToModeResolver, + type ChannelMessageToolDiscovery, +} from "openclaw/plugin-sdk/channel-runtime"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import { MattermostConfigSchema } from "./config-schema.js"; import { resolveMattermostGroupRequireMention } from "./group-mentions.js"; @@ -42,6 +48,16 @@ import { resolveMattermostOutboundSessionRoute } from "./session-route.js"; import { mattermostSetupAdapter } from "./setup-core.js"; import { mattermostSetupWizard } from "./setup-surface.js"; +const collectMattermostSecurityWarnings = + createAllowlistProviderRestrictSendersWarningCollector({ + providerConfigPresent: (cfg) => cfg.channels?.mattermost !== undefined, + resolveGroupPolicy: (account) => account.config.groupPolicy, + surface: "Mattermost channels", + openScope: "any member", + groupPolicyPath: "channels.mattermost.groupPolicy", + groupAllowFromPath: "channels.mattermost.groupAllowFrom", + }); + function describeMattermostMessageTool({ cfg, }: Parameters< @@ -279,9 +295,9 @@ export const mattermostPlugin: ChannelPlugin = { pairing: { idLabel: "mattermostUserId", normalizeAllowEntry: (entry) => normalizeAllowEntry(entry), - notifyApproval: async ({ id }) => { - console.log(`[mattermost] User ${id} approved for pairing`); - }, + notifyApproval: createLoggedPairingApprovalNotifier( + ({ id }) => `[mattermost] User ${id} approved for pairing`, + ), }, capabilities: { chatTypes: ["direct", "channel", "group", "thread"], @@ -294,14 +310,17 @@ export const mattermostPlugin: ChannelPlugin = { blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 }, }, threading: { - resolveReplyToMode: ({ cfg, accountId, chatType }) => { - const account = resolveMattermostAccount({ cfg, accountId: accountId ?? "default" }); - const kind = - chatType === "direct" || chatType === "group" || chatType === "channel" - ? chatType - : "channel"; - return resolveMattermostReplyToMode(account, kind); - }, + resolveReplyToMode: createScopedAccountReplyToModeResolver({ + resolveAccount: (cfg, accountId) => + resolveMattermostAccount({ cfg, accountId: accountId ?? "default" }), + resolveReplyToMode: (account, chatType) => + resolveMattermostReplyToMode( + account, + chatType === "direct" || chatType === "group" || chatType === "channel" + ? chatType + : "channel", + ), + }), }, reload: { configPrefixes: ["channels.mattermost"] }, configSchema: buildChannelConfigSchema(MattermostConfigSchema), @@ -319,28 +338,18 @@ export const mattermostPlugin: ChannelPlugin = { }, security: { resolveDmPolicy: resolveMattermostDmPolicy, - collectWarnings: ({ account, cfg }) => { - return collectAllowlistProviderRestrictSendersWarnings({ - cfg, - providerConfigPresent: cfg.channels?.mattermost !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - surface: "Mattermost channels", - openScope: "any member", - groupPolicyPath: "channels.mattermost.groupPolicy", - groupAllowFromPath: "channels.mattermost.groupAllowFrom", - }); - }, + collectWarnings: collectMattermostSecurityWarnings, }, groups: { resolveRequireMention: resolveMattermostGroupRequireMention, }, actions: mattermostMessageActions, - directory: { + directory: createChannelDirectoryAdapter({ listGroups: async (params) => listMattermostDirectoryGroups(params), listGroupsLive: async (params) => listMattermostDirectoryGroups(params), listPeers: async (params) => listMattermostDirectoryPeers(params), listPeersLive: async (params) => listMattermostDirectoryPeers(params), - }, + }), messaging: { normalizeTarget: normalizeMattermostMessagingTarget, resolveOutboundSessionRoute: (params) => resolveMattermostOutboundSessionRoute(params), @@ -381,33 +390,32 @@ export const mattermostPlugin: ChannelPlugin = { } return { ok: true, to: trimmed }; }, - sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => { - const result = await sendMessageMattermost(to, text, { + ...createAttachedChannelResultAdapter({ + channel: "mattermost", + sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => + await sendMessageMattermost(to, text, { + cfg, + accountId: accountId ?? undefined, + replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), + }), + sendMedia: async ({ cfg, - accountId: accountId ?? undefined, - replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), - }); - return { channel: "mattermost", ...result }; - }, - sendMedia: async ({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId, - replyToId, - threadId, - }) => { - const result = await sendMessageMattermost(to, text, { - cfg, - accountId: accountId ?? undefined, + to, + text, mediaUrl, mediaLocalRoots, - replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), - }); - return { channel: "mattermost", ...result }; - }, + accountId, + replyToId, + threadId, + }) => + await sendMessageMattermost(to, text, { + cfg, + accountId: accountId ?? undefined, + mediaUrl, + mediaLocalRoots, + replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined), + }), + }), }, status: { defaultRuntime: { diff --git a/extensions/mattermost/src/mattermost/reply-delivery.ts b/extensions/mattermost/src/mattermost/reply-delivery.ts index 6fc88c8ba83..5f2c2e7191d 100644 --- a/extensions/mattermost/src/mattermost/reply-delivery.ts +++ b/extensions/mattermost/src/mattermost/reply-delivery.ts @@ -1,3 +1,7 @@ +import { + deliverTextOrMediaReply, + resolveSendableOutboundReplyParts, +} from "openclaw/plugin-sdk/reply-payload"; import type { OpenClawConfig, PluginRuntime, ReplyPayload } from "../runtime-api.js"; import { getAgentScopedMediaLocalRoots } from "../runtime-api.js"; @@ -26,46 +30,36 @@ export async function deliverMattermostReplyPayload(params: { tableMode: MarkdownTableMode; sendMessage: SendMattermostMessage; }): Promise { - const mediaUrls = - params.payload.mediaUrls ?? (params.payload.mediaUrl ? [params.payload.mediaUrl] : []); - const text = params.core.channel.text.convertMarkdownTables( - params.payload.text ?? "", - params.tableMode, + const reply = resolveSendableOutboundReplyParts(params.payload, { + text: params.core.channel.text.convertMarkdownTables( + params.payload.text ?? "", + params.tableMode, + ), + }); + const mediaLocalRoots = getAgentScopedMediaLocalRoots(params.cfg, params.agentId); + const chunkMode = params.core.channel.text.resolveChunkMode( + params.cfg, + "mattermost", + params.accountId, ); - - if (mediaUrls.length === 0) { - const chunkMode = params.core.channel.text.resolveChunkMode( - params.cfg, - "mattermost", - params.accountId, - ); - const chunks = params.core.channel.text.chunkMarkdownTextWithMode( - text, - params.textLimit, - chunkMode, - ); - for (const chunk of chunks.length > 0 ? chunks : [text]) { - if (!chunk) { - continue; - } + await deliverTextOrMediaReply({ + payload: params.payload, + text: reply.text, + chunkText: (value) => + params.core.channel.text.chunkMarkdownTextWithMode(value, params.textLimit, chunkMode), + sendText: async (chunk) => { await params.sendMessage(params.to, chunk, { accountId: params.accountId, replyToId: params.replyToId, }); - } - return; - } - - const mediaLocalRoots = getAgentScopedMediaLocalRoots(params.cfg, params.agentId); - let first = true; - for (const mediaUrl of mediaUrls) { - const caption = first ? text : ""; - first = false; - await params.sendMessage(params.to, caption, { - accountId: params.accountId, - mediaUrl, - mediaLocalRoots, - replyToId: params.replyToId, - }); - } + }, + sendMedia: async ({ mediaUrl, caption }) => { + await params.sendMessage(params.to, caption ?? "", { + accountId: params.accountId, + mediaUrl, + mediaLocalRoots, + replyToId: params.replyToId, + }); + }, + }); } diff --git a/extensions/minimax/index.ts b/extensions/minimax/index.ts index d1a97cb43dc..5cb40be22b2 100644 --- a/extensions/minimax/index.ts +++ b/extensions/minimax/index.ts @@ -23,7 +23,7 @@ import { buildMinimaxPortalProvider, buildMinimaxProvider } from "./provider-cat const API_PROVIDER_ID = "minimax"; const PORTAL_PROVIDER_ID = "minimax-portal"; const PROVIDER_LABEL = "MiniMax"; -const DEFAULT_MODEL = "MiniMax-M2.5"; +const DEFAULT_MODEL = "MiniMax-M2.7"; const DEFAULT_BASE_URL_CN = "https://api.minimaxi.com/anthropic"; const DEFAULT_BASE_URL_GLOBAL = "https://api.minimax.io/anthropic"; @@ -40,7 +40,8 @@ function portalModelRef(modelId: string): string { } function isModernMiniMaxModel(modelId: string): boolean { - return modelId.trim().toLowerCase().startsWith("minimax-m2.5"); + const lower = modelId.trim().toLowerCase(); + return lower.startsWith("minimax-m2.7") || lower.startsWith("minimax-m2.5"); } function buildPortalProviderCatalog(params: { baseUrl: string; apiKey: string }) { @@ -129,6 +130,10 @@ function createOAuthHandler(region: MiniMaxRegion) { agents: { defaults: { models: { + [portalModelRef("MiniMax-M2.7")]: { alias: "minimax-m2.7" }, + [portalModelRef("MiniMax-M2.7-highspeed")]: { + alias: "minimax-m2.7-highspeed", + }, [portalModelRef("MiniMax-M2.5")]: { alias: "minimax-m2.5" }, [portalModelRef("MiniMax-M2.5-highspeed")]: { alias: "minimax-m2.5-highspeed", @@ -190,7 +195,7 @@ export default definePluginEntry({ choiceHint: "Global endpoint - api.minimax.io", groupId: "minimax", groupLabel: "MiniMax", - groupHint: "M2.5 (recommended)", + groupHint: "M2.7 (recommended)", }, }), createProviderApiKeyAuthMethod({ @@ -214,7 +219,7 @@ export default definePluginEntry({ choiceHint: "CN endpoint - api.minimaxi.com", groupId: "minimax", groupLabel: "MiniMax", - groupHint: "M2.5 (recommended)", + groupHint: "M2.7 (recommended)", }, }), ], @@ -253,7 +258,7 @@ export default definePluginEntry({ choiceHint: "Global endpoint - api.minimax.io", groupId: "minimax", groupLabel: "MiniMax", - groupHint: "M2.5 (recommended)", + groupHint: "M2.7 (recommended)", }, run: createOAuthHandler("global"), }, @@ -268,7 +273,7 @@ export default definePluginEntry({ choiceHint: "CN endpoint - api.minimaxi.com", groupId: "minimax", groupLabel: "MiniMax", - groupHint: "M2.5 (recommended)", + groupHint: "M2.7 (recommended)", }, run: createOAuthHandler("cn"), }, diff --git a/extensions/minimax/model-definitions.test.ts b/extensions/minimax/model-definitions.test.ts new file mode 100644 index 00000000000..e92bc512a0c --- /dev/null +++ b/extensions/minimax/model-definitions.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { + buildMinimaxApiModelDefinition, + buildMinimaxModelDefinition, + DEFAULT_MINIMAX_CONTEXT_WINDOW, + DEFAULT_MINIMAX_MAX_TOKENS, + MINIMAX_API_COST, + MINIMAX_HOSTED_MODEL_ID, +} from "./model-definitions.js"; + +describe("minimax model definitions", () => { + it("uses M2.7 as default hosted model", () => { + expect(MINIMAX_HOSTED_MODEL_ID).toBe("MiniMax-M2.7"); + }); + + it("builds catalog model with name and reasoning from catalog", () => { + const model = buildMinimaxModelDefinition({ + id: "MiniMax-M2.7", + cost: MINIMAX_API_COST, + contextWindow: DEFAULT_MINIMAX_CONTEXT_WINDOW, + maxTokens: DEFAULT_MINIMAX_MAX_TOKENS, + }); + expect(model).toMatchObject({ + id: "MiniMax-M2.7", + name: "MiniMax M2.7", + reasoning: true, + }); + }); + + it("builds API model definition with standard cost", () => { + const model = buildMinimaxApiModelDefinition("MiniMax-M2.7"); + expect(model.cost).toEqual(MINIMAX_API_COST); + expect(model.contextWindow).toBe(DEFAULT_MINIMAX_CONTEXT_WINDOW); + expect(model.maxTokens).toBe(DEFAULT_MINIMAX_MAX_TOKENS); + }); + + it("falls back to generated name for unknown model id", () => { + const model = buildMinimaxApiModelDefinition("MiniMax-Future"); + expect(model.name).toBe("MiniMax MiniMax-Future"); + expect(model.reasoning).toBe(false); + }); +}); diff --git a/extensions/minimax/model-definitions.ts b/extensions/minimax/model-definitions.ts index 48396f21240..1de1c6aee5b 100644 --- a/extensions/minimax/model-definitions.ts +++ b/extensions/minimax/model-definitions.ts @@ -3,7 +3,7 @@ import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-models" export const DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.io/v1"; export const MINIMAX_API_BASE_URL = "https://api.minimax.io/anthropic"; export const MINIMAX_CN_API_BASE_URL = "https://api.minimaxi.com/anthropic"; -export const MINIMAX_HOSTED_MODEL_ID = "MiniMax-M2.5"; +export const MINIMAX_HOSTED_MODEL_ID = "MiniMax-M2.7"; export const MINIMAX_HOSTED_MODEL_REF = `minimax/${MINIMAX_HOSTED_MODEL_ID}`; export const DEFAULT_MINIMAX_CONTEXT_WINDOW = 200000; export const DEFAULT_MINIMAX_MAX_TOKENS = 8192; @@ -28,6 +28,8 @@ export const MINIMAX_LM_STUDIO_COST = { }; const MINIMAX_MODEL_CATALOG = { + "MiniMax-M2.7": { name: "MiniMax M2.7", reasoning: true }, + "MiniMax-M2.7-highspeed": { name: "MiniMax M2.7 Highspeed", reasoning: true }, "MiniMax-M2.5": { name: "MiniMax M2.5", reasoning: true }, "MiniMax-M2.5-highspeed": { name: "MiniMax M2.5 Highspeed", reasoning: true }, } as const; diff --git a/extensions/minimax/onboard.ts b/extensions/minimax/onboard.ts index 2edcf9637e4..ee0066b563d 100644 --- a/extensions/minimax/onboard.ts +++ b/extensions/minimax/onboard.ts @@ -61,7 +61,7 @@ function applyMinimaxApiConfigWithBaseUrl( export function applyMinimaxApiProviderConfig( cfg: OpenClawConfig, - modelId: string = "MiniMax-M2.5", + modelId: string = "MiniMax-M2.7", ): OpenClawConfig { return applyMinimaxApiProviderConfigWithBaseUrl(cfg, { providerId: "minimax", @@ -72,7 +72,7 @@ export function applyMinimaxApiProviderConfig( export function applyMinimaxApiConfig( cfg: OpenClawConfig, - modelId: string = "MiniMax-M2.5", + modelId: string = "MiniMax-M2.7", ): OpenClawConfig { return applyMinimaxApiConfigWithBaseUrl(cfg, { providerId: "minimax", @@ -83,7 +83,7 @@ export function applyMinimaxApiConfig( export function applyMinimaxApiProviderConfigCn( cfg: OpenClawConfig, - modelId: string = "MiniMax-M2.5", + modelId: string = "MiniMax-M2.7", ): OpenClawConfig { return applyMinimaxApiProviderConfigWithBaseUrl(cfg, { providerId: "minimax", @@ -94,7 +94,7 @@ export function applyMinimaxApiProviderConfigCn( export function applyMinimaxApiConfigCn( cfg: OpenClawConfig, - modelId: string = "MiniMax-M2.5", + modelId: string = "MiniMax-M2.7", ): OpenClawConfig { return applyMinimaxApiConfigWithBaseUrl(cfg, { providerId: "minimax", diff --git a/extensions/minimax/openclaw.plugin.json b/extensions/minimax/openclaw.plugin.json index 848ce80699a..60a77127713 100644 --- a/extensions/minimax/openclaw.plugin.json +++ b/extensions/minimax/openclaw.plugin.json @@ -14,7 +14,7 @@ "choiceHint": "Global endpoint - api.minimax.io", "groupId": "minimax", "groupLabel": "MiniMax", - "groupHint": "M2.5 (recommended)" + "groupHint": "M2.7 (recommended)" }, { "provider": "minimax", @@ -24,7 +24,7 @@ "choiceHint": "Global endpoint - api.minimax.io", "groupId": "minimax", "groupLabel": "MiniMax", - "groupHint": "M2.5 (recommended)", + "groupHint": "M2.7 (recommended)", "optionKey": "minimaxApiKey", "cliFlag": "--minimax-api-key", "cliOption": "--minimax-api-key ", @@ -38,7 +38,7 @@ "choiceHint": "CN endpoint - api.minimaxi.com", "groupId": "minimax", "groupLabel": "MiniMax", - "groupHint": "M2.5 (recommended)" + "groupHint": "M2.7 (recommended)" }, { "provider": "minimax", @@ -48,7 +48,7 @@ "choiceHint": "CN endpoint - api.minimaxi.com", "groupId": "minimax", "groupLabel": "MiniMax", - "groupHint": "M2.5 (recommended)", + "groupHint": "M2.7 (recommended)", "optionKey": "minimaxApiKey", "cliFlag": "--minimax-api-key", "cliOption": "--minimax-api-key ", diff --git a/extensions/minimax/provider-catalog.ts b/extensions/minimax/provider-catalog.ts index ab8cceb9c53..61549e8a883 100644 --- a/extensions/minimax/provider-catalog.ts +++ b/extensions/minimax/provider-catalog.ts @@ -4,7 +4,7 @@ import type { } from "openclaw/plugin-sdk/provider-models"; const MINIMAX_PORTAL_BASE_URL = "https://api.minimax.io/anthropic"; -export const MINIMAX_DEFAULT_MODEL_ID = "MiniMax-M2.5"; +export const MINIMAX_DEFAULT_MODEL_ID = "MiniMax-M2.7"; const MINIMAX_DEFAULT_VISION_MODEL_ID = "MiniMax-VL-01"; const MINIMAX_DEFAULT_CONTEXT_WINDOW = 200000; const MINIMAX_DEFAULT_MAX_TOKENS = 8192; @@ -50,6 +50,16 @@ function buildMinimaxCatalog(): ModelDefinitionConfig[] { }), buildMinimaxTextModel({ id: MINIMAX_DEFAULT_MODEL_ID, + name: "MiniMax M2.7", + reasoning: true, + }), + buildMinimaxTextModel({ + id: "MiniMax-M2.7-highspeed", + name: "MiniMax M2.7 Highspeed", + reasoning: true, + }), + buildMinimaxTextModel({ + id: "MiniMax-M2.5", name: "MiniMax M2.5", reasoning: true, }), diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index 6365de0b725..c29afcfebbb 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -5,7 +5,8 @@ "type": "module", "dependencies": { "@microsoft/agents-hosting": "^1.3.1", - "express": "^5.2.1" + "express": "^5.2.1", + "uuid": "^11.1.0" }, "openclaw": { "extensions": [ diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index b1379e311df..9d59b042167 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -1,11 +1,22 @@ import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; import { createTopLevelChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers"; -import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy"; -import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime"; +import { + createAllowlistProviderGroupPolicyWarningCollector, + projectWarningCollector, +} from "openclaw/plugin-sdk/channel-policy"; +import { + createChannelDirectoryAdapter, + createMessageToolCardSchema, + createPairingPrefixStripper, + createRuntimeDirectoryLiveAdapter, + createRuntimeOutboundDelegates, + createTextPairingAdapter, +} from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelMessageActionAdapter, ChannelMessageToolDiscovery, } from "openclaw/plugin-sdk/channel-runtime"; +import { listDirectoryEntriesFromSources } from "openclaw/plugin-sdk/directory-runtime"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import type { ChannelMessageActionName, ChannelPlugin, OpenClawConfig } from "../runtime-api.js"; import { @@ -60,6 +71,19 @@ const TEAMS_GRAPH_PERMISSION_HINTS: Record = { "Files.Read.All": "files (OneDrive)", }; +const collectMSTeamsSecurityWarnings = createAllowlistProviderGroupPolicyWarningCollector<{ + cfg: OpenClawConfig; +}>({ + providerConfigPresent: (cfg) => cfg.channels?.msteams !== undefined, + resolveGroupPolicy: ({ cfg }) => cfg.channels?.msteams?.groupPolicy, + collect: ({ groupPolicy }) => + groupPolicy === "open" + ? [ + '- MS Teams groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.msteams.groupPolicy="allowlist" + channels.msteams.groupAllowFrom to restrict senders.', + ] + : [], +}); + const loadMSTeamsChannelRuntime = createLazyRuntimeNamedExport( () => import("./channel.runtime.js"), "msTeamsChannelRuntime", @@ -117,18 +141,19 @@ export const msteamsPlugin: ChannelPlugin = { aliases: [...meta.aliases], }, setupWizard: msteamsSetupWizard, - pairing: { + pairing: createTextPairingAdapter({ idLabel: "msteamsUserId", - normalizeAllowEntry: (entry) => entry.replace(/^(msteams|user):/i, ""), - notifyApproval: async ({ cfg, id }) => { + message: PAIRING_APPROVED_MESSAGE, + normalizeAllowEntry: createPairingPrefixStripper(/^(msteams|user):/i), + notify: async ({ cfg, id, message }) => { const { sendMessageMSTeams } = await loadMSTeamsChannelRuntime(); await sendMessageMSTeams({ cfg, to: id, - text: PAIRING_APPROVED_MESSAGE, + text: message, }); }, - }, + }), capabilities: { chatTypes: ["direct", "channel", "thread"], polls: true, @@ -163,17 +188,10 @@ export const msteamsPlugin: ChannelPlugin = { }), }, security: { - collectWarnings: ({ cfg }) => { - return collectAllowlistProviderRestrictSendersWarnings({ - cfg, - providerConfigPresent: cfg.channels?.msteams !== undefined, - configuredGroupPolicy: cfg.channels?.msteams?.groupPolicy, - surface: "MS Teams groups", - openScope: "any member", - groupPolicyPath: "channels.msteams.groupPolicy", - groupAllowFromPath: "channels.msteams.groupAllowFrom", - }); - }, + collectWarnings: projectWarningCollector( + ({ cfg }: { cfg: OpenClawConfig }) => ({ cfg }), + collectMSTeamsSecurityWarnings, + ), }, setup: msteamsSetupAdapter, messaging: { @@ -198,66 +216,43 @@ export const msteamsPlugin: ChannelPlugin = { hint: "", }, }, - directory: { - self: async () => null, - listPeers: async ({ cfg, query, limit }) => { - const q = query?.trim().toLowerCase() || ""; - const ids = new Set(); - for (const entry of cfg.channels?.msteams?.allowFrom ?? []) { - const trimmed = String(entry).trim(); - if (trimmed && trimmed !== "*") { - ids.add(trimmed); - } - } - for (const userId of Object.keys(cfg.channels?.msteams?.dms ?? {})) { - const trimmed = userId.trim(); - if (trimmed) { - ids.add(trimmed); - } - } - return Array.from(ids) - .map((raw) => raw.trim()) - .filter(Boolean) - .map((raw) => normalizeMSTeamsMessagingTarget(raw) ?? raw) - .map((raw) => { - const lowered = raw.toLowerCase(); - if (lowered.startsWith("user:")) { - return raw; + directory: createChannelDirectoryAdapter({ + listPeers: async ({ cfg, query, limit }) => + listDirectoryEntriesFromSources({ + kind: "user", + sources: [ + cfg.channels?.msteams?.allowFrom ?? [], + Object.keys(cfg.channels?.msteams?.dms ?? {}), + ], + query, + limit, + normalizeId: (raw) => { + const normalized = normalizeMSTeamsMessagingTarget(raw) ?? raw; + const lowered = normalized.toLowerCase(); + if (lowered.startsWith("user:") || lowered.startsWith("conversation:")) { + return normalized; } - if (lowered.startsWith("conversation:")) { - return raw; - } - return `user:${raw}`; - }) - .filter((id) => (q ? id.toLowerCase().includes(q) : true)) - .slice(0, limit && limit > 0 ? limit : undefined) - .map((id) => ({ kind: "user", id }) as const); - }, - listGroups: async ({ cfg, query, limit }) => { - const q = query?.trim().toLowerCase() || ""; - const ids = new Set(); - for (const team of Object.values(cfg.channels?.msteams?.teams ?? {})) { - for (const channelId of Object.keys(team.channels ?? {})) { - const trimmed = channelId.trim(); - if (trimmed && trimmed !== "*") { - ids.add(trimmed); - } - } - } - return Array.from(ids) - .map((raw) => raw.trim()) - .filter(Boolean) - .map((raw) => raw.replace(/^conversation:/i, "").trim()) - .map((id) => `conversation:${id}`) - .filter((id) => (q ? id.toLowerCase().includes(q) : true)) - .slice(0, limit && limit > 0 ? limit : undefined) - .map((id) => ({ kind: "group", id }) as const); - }, - listPeersLive: async ({ cfg, query, limit }) => - (await loadMSTeamsChannelRuntime()).listMSTeamsDirectoryPeersLive({ cfg, query, limit }), - listGroupsLive: async ({ cfg, query, limit }) => - (await loadMSTeamsChannelRuntime()).listMSTeamsDirectoryGroupsLive({ cfg, query, limit }), - }, + return `user:${normalized}`; + }, + }), + listGroups: async ({ cfg, query, limit }) => + listDirectoryEntriesFromSources({ + kind: "group", + sources: [ + Object.values(cfg.channels?.msteams?.teams ?? {}).flatMap((team) => + Object.keys(team.channels ?? {}), + ), + ], + query, + limit, + normalizeId: (raw) => `conversation:${raw.replace(/^conversation:/i, "").trim()}`, + }), + ...createRuntimeDirectoryLiveAdapter({ + getRuntime: loadMSTeamsChannelRuntime, + listPeersLive: (runtime) => runtime.listMSTeamsDirectoryPeersLive, + listGroupsLive: (runtime) => runtime.listMSTeamsDirectoryGroupsLive, + }), + }), resolver: { resolveTargets: async ({ cfg, inputs, kind, runtime }) => { const results = inputs.map((input) => ({ @@ -436,12 +431,12 @@ export const msteamsPlugin: ChannelPlugin = { chunkerMode: "markdown", textChunkLimit: 4000, pollMaxOptions: 12, - sendText: async (params) => - (await loadMSTeamsChannelRuntime()).msteamsOutbound.sendText!(params), - sendMedia: async (params) => - (await loadMSTeamsChannelRuntime()).msteamsOutbound.sendMedia!(params), - sendPoll: async (params) => - (await loadMSTeamsChannelRuntime()).msteamsOutbound.sendPoll!(params), + ...createRuntimeOutboundDelegates({ + getRuntime: loadMSTeamsChannelRuntime, + sendText: { resolve: (runtime) => runtime.msteamsOutbound.sendText }, + sendMedia: { resolve: (runtime) => runtime.msteamsOutbound.sendMedia }, + sendPoll: { resolve: (runtime) => runtime.msteamsOutbound.sendPoll }, + }), }, status: { defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { port: null }), diff --git a/extensions/msteams/src/messenger.ts b/extensions/msteams/src/messenger.ts index f03431391ed..c2263a4975f 100644 --- a/extensions/msteams/src/messenger.ts +++ b/extensions/msteams/src/messenger.ts @@ -5,6 +5,7 @@ import { type MarkdownTableMode, type MSTeamsReplyStyle, type ReplyPayload, + resolveSendableOutboundReplyParts, SILENT_REPLY_TOKEN, sleep, } from "../runtime-api.js"; @@ -216,41 +217,39 @@ export function renderReplyPayloadsToMessages( }); for (const payload of replies) { - const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); - const text = getMSTeamsRuntime().channel.text.convertMarkdownTables( - payload.text ?? "", - tableMode, - ); + const reply = resolveSendableOutboundReplyParts(payload, { + text: getMSTeamsRuntime().channel.text.convertMarkdownTables(payload.text ?? "", tableMode), + }); - if (!text && mediaList.length === 0) { + if (!reply.hasContent) { continue; } - if (mediaList.length === 0) { - pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode }); + if (!reply.hasMedia) { + pushTextMessages(out, reply.text, { chunkText, chunkLimit, chunkMode }); continue; } if (mediaMode === "inline") { // For inline mode, combine text with first media as attachment - const firstMedia = mediaList[0]; + const firstMedia = reply.mediaUrls[0]; if (firstMedia) { - out.push({ text: text || undefined, mediaUrl: firstMedia }); + out.push({ text: reply.text || undefined, mediaUrl: firstMedia }); // Additional media URLs as separate messages - for (let i = 1; i < mediaList.length; i++) { - if (mediaList[i]) { - out.push({ mediaUrl: mediaList[i] }); + for (let i = 1; i < reply.mediaUrls.length; i++) { + if (reply.mediaUrls[i]) { + out.push({ mediaUrl: reply.mediaUrls[i] }); } } } else { - pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode }); + pushTextMessages(out, reply.text, { chunkText, chunkLimit, chunkMode }); } continue; } // mediaMode === "split" - pushTextMessages(out, text, { chunkText, chunkLimit, chunkMode }); - for (const mediaUrl of mediaList) { + pushTextMessages(out, reply.text, { chunkText, chunkLimit, chunkMode }); + for (const mediaUrl of reply.mediaUrls) { if (!mediaUrl) { continue; } diff --git a/extensions/msteams/src/outbound.ts b/extensions/msteams/src/outbound.ts index 6334bb8c6b5..cf482825ed2 100644 --- a/extensions/msteams/src/outbound.ts +++ b/extensions/msteams/src/outbound.ts @@ -1,4 +1,5 @@ import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; +import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result"; import type { ChannelOutboundAdapter } from "../runtime-api.js"; import { createMSTeamsPollStoreFs } from "./polls.js"; import { getMSTeamsRuntime } from "./runtime.js"; @@ -10,56 +11,57 @@ export const msteamsOutbound: ChannelOutboundAdapter = { chunkerMode: "markdown", textChunkLimit: 4000, pollMaxOptions: 12, - sendText: async ({ cfg, to, text, deps }) => { - type SendFn = ( - to: string, - text: string, - ) => Promise<{ messageId: string; conversationId: string }>; - const send = - resolveOutboundSendDep(deps, "msteams") ?? - ((to, text) => sendMessageMSTeams({ cfg, to, text })); - const result = await send(to, text); - return { channel: "msteams", ...result }; - }, - sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, deps }) => { - type SendFn = ( - to: string, - text: string, - opts?: { mediaUrl?: string; mediaLocalRoots?: readonly string[] }, - ) => Promise<{ messageId: string; conversationId: string }>; - const send = - resolveOutboundSendDep(deps, "msteams") ?? - ((to, text, opts) => - sendMessageMSTeams({ - cfg, - to, - text, - mediaUrl: opts?.mediaUrl, - mediaLocalRoots: opts?.mediaLocalRoots, - })); - const result = await send(to, text, { mediaUrl, mediaLocalRoots }); - return { channel: "msteams", ...result }; - }, - sendPoll: async ({ cfg, to, poll }) => { - const maxSelections = poll.maxSelections ?? 1; - const result = await sendPollMSTeams({ - cfg, - to, - question: poll.question, - options: poll.options, - maxSelections, - }); - const pollStore = createMSTeamsPollStoreFs(); - await pollStore.createPoll({ - id: result.pollId, - question: poll.question, - options: poll.options, - maxSelections, - createdAt: new Date().toISOString(), - conversationId: result.conversationId, - messageId: result.messageId, - votes: {}, - }); - return result; - }, + ...createAttachedChannelResultAdapter({ + channel: "msteams", + sendText: async ({ cfg, to, text, deps }) => { + type SendFn = ( + to: string, + text: string, + ) => Promise<{ messageId: string; conversationId: string }>; + const send = + resolveOutboundSendDep(deps, "msteams") ?? + ((to, text) => sendMessageMSTeams({ cfg, to, text })); + return await send(to, text); + }, + sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, deps }) => { + type SendFn = ( + to: string, + text: string, + opts?: { mediaUrl?: string; mediaLocalRoots?: readonly string[] }, + ) => Promise<{ messageId: string; conversationId: string }>; + const send = + resolveOutboundSendDep(deps, "msteams") ?? + ((to, text, opts) => + sendMessageMSTeams({ + cfg, + to, + text, + mediaUrl: opts?.mediaUrl, + mediaLocalRoots: opts?.mediaLocalRoots, + })); + return await send(to, text, { mediaUrl, mediaLocalRoots }); + }, + sendPoll: async ({ cfg, to, poll }) => { + const maxSelections = poll.maxSelections ?? 1; + const result = await sendPollMSTeams({ + cfg, + to, + question: poll.question, + options: poll.options, + maxSelections, + }); + const pollStore = createMSTeamsPollStoreFs(); + await pollStore.createPoll({ + id: result.pollId, + question: poll.question, + options: poll.options, + maxSelections, + createdAt: new Date().toISOString(), + conversationId: result.conversationId, + messageId: result.messageId, + votes: {}, + }); + return result; + }, + }), }; diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index ce2f281a3e6..d24822efb26 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -4,10 +4,12 @@ import { createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; +import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { - collectAllowlistProviderGroupPolicyWarnings, - collectOpenGroupPolicyRouteAllowlistWarnings, -} from "openclaw/plugin-sdk/channel-policy"; + createAttachedChannelResultAdapter, + createLoggedPairingApprovalNotifier, + createPairingPrefixStripper, +} from "openclaw/plugin-sdk/channel-runtime"; import { runStoppablePassiveMonitor } from "../../shared/passive-monitor.js"; import { buildBaseChannelStatusSummary, @@ -76,17 +78,40 @@ const resolveNextcloudTalkDmPolicy = createScopedDmSecurityResolver raw.replace(/^(nextcloud-talk|nc-talk|nc):/i, "").toLowerCase(), }); +const collectNextcloudTalkSecurityWarnings = + createAllowlistProviderRouteAllowlistWarningCollector({ + providerConfigPresent: (cfg) => + (cfg.channels as Record | undefined)?.["nextcloud-talk"] !== undefined, + resolveGroupPolicy: (account) => account.config.groupPolicy, + resolveRouteAllowlistConfigured: (account) => + Boolean(account.config.rooms) && Object.keys(account.config.rooms ?? {}).length > 0, + restrictSenders: { + surface: "Nextcloud Talk rooms", + openScope: "any member in allowed rooms", + groupPolicyPath: "channels.nextcloud-talk.groupPolicy", + groupAllowFromPath: "channels.nextcloud-talk.groupAllowFrom", + }, + noRouteAllowlist: { + surface: "Nextcloud Talk rooms", + routeAllowlistPath: "channels.nextcloud-talk.rooms", + routeScope: "room", + groupPolicyPath: "channels.nextcloud-talk.groupPolicy", + groupAllowFromPath: "channels.nextcloud-talk.groupAllowFrom", + }, + }); + export const nextcloudTalkPlugin: ChannelPlugin = { id: "nextcloud-talk", meta, setupWizard: nextcloudTalkSetupWizard, pairing: { idLabel: "nextcloudUserId", - normalizeAllowEntry: (entry) => - entry.replace(/^(nextcloud-talk|nc-talk|nc):/i, "").toLowerCase(), - notifyApproval: async ({ id }) => { - console.log(`[nextcloud-talk] User ${id} approved for pairing`); - }, + normalizeAllowEntry: createPairingPrefixStripper(/^(nextcloud-talk|nc-talk|nc):/i, (entry) => + entry.toLowerCase(), + ), + notifyApproval: createLoggedPairingApprovalNotifier( + ({ id }) => `[nextcloud-talk] User ${id} approved for pairing`, + ), }, capabilities: { chatTypes: ["direct", "group"], @@ -112,34 +137,7 @@ export const nextcloudTalkPlugin: ChannelPlugin = }, security: { resolveDmPolicy: resolveNextcloudTalkDmPolicy, - collectWarnings: ({ account, cfg }) => { - const roomAllowlistConfigured = - account.config.rooms && Object.keys(account.config.rooms).length > 0; - return collectAllowlistProviderGroupPolicyWarnings({ - cfg, - providerConfigPresent: - (cfg.channels as Record | undefined)?.["nextcloud-talk"] !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - collect: (groupPolicy) => - collectOpenGroupPolicyRouteAllowlistWarnings({ - groupPolicy, - routeAllowlistConfigured: Boolean(roomAllowlistConfigured), - restrictSenders: { - surface: "Nextcloud Talk rooms", - openScope: "any member in allowed rooms", - groupPolicyPath: "channels.nextcloud-talk.groupPolicy", - groupAllowFromPath: "channels.nextcloud-talk.groupAllowFrom", - }, - noRouteAllowlist: { - surface: "Nextcloud Talk rooms", - routeAllowlistPath: "channels.nextcloud-talk.rooms", - routeScope: "room", - groupPolicyPath: "channels.nextcloud-talk.groupPolicy", - groupAllowFromPath: "channels.nextcloud-talk.groupAllowFrom", - }, - }), - }); - }, + collectWarnings: collectNextcloudTalkSecurityWarnings, }, groups: { resolveRequireMention: ({ cfg, accountId, groupId }) => { @@ -177,23 +175,21 @@ export const nextcloudTalkPlugin: ChannelPlugin = chunker: (text, limit) => getNextcloudTalkRuntime().channel.text.chunkMarkdownText(text, limit), chunkerMode: "markdown", textChunkLimit: 4000, - sendText: async ({ cfg, to, text, accountId, replyToId }) => { - const result = await sendMessageNextcloudTalk(to, text, { - accountId: accountId ?? undefined, - replyTo: replyToId ?? undefined, - cfg: cfg as CoreConfig, - }); - return { channel: "nextcloud-talk", ...result }; - }, - sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) => { - const messageWithMedia = mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text; - const result = await sendMessageNextcloudTalk(to, messageWithMedia, { - accountId: accountId ?? undefined, - replyTo: replyToId ?? undefined, - cfg: cfg as CoreConfig, - }); - return { channel: "nextcloud-talk", ...result }; - }, + ...createAttachedChannelResultAdapter({ + channel: "nextcloud-talk", + sendText: async ({ cfg, to, text, accountId, replyToId }) => + await sendMessageNextcloudTalk(to, text, { + accountId: accountId ?? undefined, + replyTo: replyToId ?? undefined, + cfg: cfg as CoreConfig, + }), + sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) => + await sendMessageNextcloudTalk(to, mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text, { + accountId: accountId ?? undefined, + replyTo: replyToId ?? undefined, + cfg: cfg as CoreConfig, + }), + }), }, status: { defaultRuntime: { diff --git a/extensions/nextcloud-talk/src/inbound.ts b/extensions/nextcloud-talk/src/inbound.ts index 9eefe831835..d9f4de2f9a2 100644 --- a/extensions/nextcloud-talk/src/inbound.ts +++ b/extensions/nextcloud-talk/src/inbound.ts @@ -1,13 +1,12 @@ import { GROUP_POLICY_BLOCKED_LABEL, createScopedPairingAccess, + deliverFormattedTextWithAttachments, dispatchInboundReplyWithBase, - formatTextWithAttachmentLinks, issuePairingChallenge, logInboundDrop, readStoreAllowFromForDmPolicy, resolveDmGroupAccessWithCommandGate, - resolveOutboundMediaUrls, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, @@ -38,16 +37,16 @@ async function deliverNextcloudTalkReply(params: { statusSink?: (patch: { lastOutboundAt?: number }) => void; }): Promise { const { payload, roomToken, accountId, statusSink } = params; - const combined = formatTextWithAttachmentLinks(payload.text, resolveOutboundMediaUrls(payload)); - if (!combined) { - return; - } - - await sendMessageNextcloudTalk(roomToken, combined, { - accountId, - replyTo: payload.replyToId, + await deliverFormattedTextWithAttachments({ + payload, + send: async ({ text, replyToId }) => { + await sendMessageNextcloudTalk(roomToken, text, { + accountId, + replyTo: replyToId, + }); + statusSink?.({ lastOutboundAt: Date.now() }); + }, }); - statusSink?.({ lastOutboundAt: Date.now() }); } export async function handleNextcloudTalkInbound(params: { diff --git a/extensions/nostr/api.ts b/extensions/nostr/api.ts index 2de81f11142..3f3d64cc3bf 100644 --- a/extensions/nostr/api.ts +++ b/extensions/nostr/api.ts @@ -1,2 +1 @@ export * from "openclaw/plugin-sdk/nostr"; -export * from "./setup-api.js"; diff --git a/extensions/nostr/src/channel.ts b/extensions/nostr/src/channel.ts index 3db834e8ad6..a11a882b81e 100644 --- a/extensions/nostr/src/channel.ts +++ b/extensions/nostr/src/channel.ts @@ -2,6 +2,7 @@ import { createScopedDmSecurityResolver, createTopLevelChannelConfigAdapter, } from "openclaw/plugin-sdk/channel-config-helpers"; +import { attachChannelToResult } from "openclaw/plugin-sdk/channel-send-result"; import { buildPassiveChannelStatusSummary, buildTrafficStatusSummary, @@ -176,11 +177,10 @@ export const nostrPlugin: ChannelPlugin = { const message = core.channel.text.convertMarkdownTables(text ?? "", tableMode); const normalizedTo = normalizePubkey(to); await bus.sendDm(normalizedTo, message); - return { - channel: "nostr" as const, + return attachChannelToResult("nostr", { to: normalizedTo, messageId: `nostr-${Date.now()}`, - }; + }); }, }, diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 1879c85a7b0..6ba7fce6084 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -1,5 +1,12 @@ -import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit"; -import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; +import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit"; +import { + attachChannelToResult, + createAttachedChannelResultAdapter, + createPairingPrefixStripper, + createTextPairingAdapter, + resolveOutboundSendDep, +} from "openclaw/plugin-sdk/channel-runtime"; +import { attachChannelToResults } from "openclaw/plugin-sdk/channel-send-result"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/core"; import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; @@ -219,9 +226,9 @@ async function sendFormattedSignalText(ctx: { textMode: "plain", textStyles: chunk.styles, }); - results.push({ channel: "signal" as const, ...result }); + results.push(result); } - return results; + return attachChannelToResults("signal", results); } async function sendFormattedSignalMedia(ctx: { @@ -260,7 +267,7 @@ async function sendFormattedSignalMedia(ctx: { textMode: "plain", textStyles: formatted.styles, }); - return { channel: "signal" as const, ...result }; + return attachChannelToResult("signal", result); } export const signalPlugin: ChannelPlugin = { @@ -268,35 +275,25 @@ export const signalPlugin: ChannelPlugin = { setupWizard: signalSetupWizard, setup: signalSetupAdapter, }), - pairing: { + pairing: createTextPairingAdapter({ idLabel: "signalNumber", - normalizeAllowEntry: (entry) => entry.replace(/^signal:/i, ""), - notifyApproval: async ({ id }) => { - await getSignalRuntime().channel.signal.sendMessageSignal(id, PAIRING_APPROVED_MESSAGE); + message: PAIRING_APPROVED_MESSAGE, + normalizeAllowEntry: createPairingPrefixStripper(/^signal:/i), + notify: async ({ id, message }) => { + await getSignalRuntime().channel.signal.sendMessageSignal(id, message); }, - }, + }), actions: signalMessageActions, - allowlist: { - supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", - readConfig: ({ cfg, accountId }) => { - const account = resolveSignalAccount({ cfg, accountId }); - return { - dmAllowFrom: (account.config.allowFrom ?? []).map(String), - groupAllowFrom: (account.config.groupAllowFrom ?? []).map(String), - dmPolicy: account.config.dmPolicy, - groupPolicy: account.config.groupPolicy, - }; - }, - applyConfigEdit: buildAccountScopedAllowlistConfigEditor({ - channelId: "signal", - normalize: ({ cfg, accountId, values }) => - signalConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }), - resolvePaths: (scope) => ({ - readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]], - writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"], - }), - }), - }, + allowlist: buildDmGroupAccountAllowlistAdapter({ + channelId: "signal", + resolveAccount: ({ cfg, accountId }) => resolveSignalAccount({ cfg, accountId }), + normalize: ({ cfg, accountId, values }) => + signalConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }), + resolveDmAllowFrom: (account) => account.config.allowFrom, + resolveGroupAllowFrom: (account) => account.config.groupAllowFrom, + resolveDmPolicy: (account) => account.config.dmPolicy, + resolveGroupPolicy: (account) => account.config.groupPolicy, + }), security: { resolveDmPolicy: signalResolveDmPolicy, collectWarnings: collectSignalSecurityWarnings, @@ -346,28 +343,27 @@ export const signalPlugin: ChannelPlugin = { deps, abortSignal, }), - sendText: async ({ cfg, to, text, accountId, deps }) => { - const result = await sendSignalOutbound({ - cfg, - to, - text, - accountId: accountId ?? undefined, - deps, - }); - return { channel: "signal", ...result }; - }, - sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) => { - const result = await sendSignalOutbound({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId: accountId ?? undefined, - deps, - }); - return { channel: "signal", ...result }; - }, + ...createAttachedChannelResultAdapter({ + channel: "signal", + sendText: async ({ cfg, to, text, accountId, deps }) => + await sendSignalOutbound({ + cfg, + to, + text, + accountId: accountId ?? undefined, + deps, + }), + sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) => + await sendSignalOutbound({ + cfg, + to, + text, + mediaUrl, + mediaLocalRoots, + accountId: accountId ?? undefined, + deps, + }), + }), }, status: { defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID), diff --git a/extensions/signal/src/monitor.ts b/extensions/signal/src/monitor.ts index 02fd94ff8b8..20f0c943823 100644 --- a/extensions/signal/src/monitor.ts +++ b/extensions/signal/src/monitor.ts @@ -9,6 +9,10 @@ import type { SignalReactionNotificationMode } from "openclaw/plugin-sdk/config- import type { BackoffPolicy } from "openclaw/plugin-sdk/infra-runtime"; import { waitForTransportReady } from "openclaw/plugin-sdk/infra-runtime"; import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime"; +import { + deliverTextOrMediaReply, + resolveSendableOutboundReplyParts, +} from "openclaw/plugin-sdk/reply-payload"; import { chunkTextWithMode, resolveChunkMode, @@ -296,35 +300,32 @@ async function deliverReplies(params: { const { replies, target, baseUrl, account, accountId, runtime, maxBytes, textLimit, chunkMode } = params; for (const payload of replies) { - const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); - const text = payload.text ?? ""; - if (!text && mediaList.length === 0) { - continue; - } - if (mediaList.length === 0) { - for (const chunk of chunkTextWithMode(text, textLimit, chunkMode)) { + const reply = resolveSendableOutboundReplyParts(payload); + const delivered = await deliverTextOrMediaReply({ + payload, + text: reply.text, + chunkText: (value) => chunkTextWithMode(value, textLimit, chunkMode), + sendText: async (chunk) => { await sendMessageSignal(target, chunk, { baseUrl, account, maxBytes, accountId, }); - } - } else { - let first = true; - for (const url of mediaList) { - const caption = first ? text : ""; - first = false; - await sendMessageSignal(target, caption, { + }, + sendMedia: async ({ mediaUrl, caption }) => { + await sendMessageSignal(target, caption ?? "", { baseUrl, account, - mediaUrl: url, + mediaUrl, maxBytes, accountId, }); - } + }, + }); + if (delivered !== "empty") { + runtime.log?.(`delivered reply to ${target}`); } - runtime.log?.(`delivered reply to ${target}`); } } diff --git a/extensions/signal/src/outbound-adapter.ts b/extensions/signal/src/outbound-adapter.ts index cd61b825981..4471871b69b 100644 --- a/extensions/signal/src/outbound-adapter.ts +++ b/extensions/signal/src/outbound-adapter.ts @@ -1,6 +1,11 @@ import { createScopedChannelMediaMaxBytesResolver } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime"; import { resolveOutboundSendDep, type OutboundSendDeps } from "openclaw/plugin-sdk/channel-runtime"; +import { + attachChannelToResult, + attachChannelToResults, + createAttachedChannelResultAdapter, +} from "openclaw/plugin-sdk/channel-send-result"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; import { markdownToSignalTextChunks } from "./format.js"; @@ -53,9 +58,9 @@ export const signalOutbound: ChannelOutboundAdapter = { textMode: "plain", textStyles: chunk.styles, }); - results.push({ channel: "signal" as const, ...result }); + results.push(result); } - return results; + return attachChannelToResults("signal", results); }, sendFormattedMedia: async ({ cfg, @@ -89,34 +94,35 @@ export const signalOutbound: ChannelOutboundAdapter = { textStyles: formatted.styles, mediaLocalRoots, }); - return { channel: "signal", ...result }; - }, - sendText: async ({ cfg, to, text, accountId, deps }) => { - const send = resolveSignalSender(deps); - const maxBytes = resolveSignalMaxBytes({ - cfg, - accountId: accountId ?? undefined, - }); - const result = await send(to, text, { - cfg, - maxBytes, - accountId: accountId ?? undefined, - }); - return { channel: "signal", ...result }; - }, - sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) => { - const send = resolveSignalSender(deps); - const maxBytes = resolveSignalMaxBytes({ - cfg, - accountId: accountId ?? undefined, - }); - const result = await send(to, text, { - cfg, - mediaUrl, - maxBytes, - accountId: accountId ?? undefined, - mediaLocalRoots, - }); - return { channel: "signal", ...result }; + return attachChannelToResult("signal", result); }, + ...createAttachedChannelResultAdapter({ + channel: "signal", + sendText: async ({ cfg, to, text, accountId, deps }) => { + const send = resolveSignalSender(deps); + const maxBytes = resolveSignalMaxBytes({ + cfg, + accountId: accountId ?? undefined, + }); + return await send(to, text, { + cfg, + maxBytes, + accountId: accountId ?? undefined, + }); + }, + sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) => { + const send = resolveSignalSender(deps); + const maxBytes = resolveSignalMaxBytes({ + cfg, + accountId: accountId ?? undefined, + }); + return await send(to, text, { + cfg, + mediaUrl, + maxBytes, + accountId: accountId ?? undefined, + mediaLocalRoots, + }); + }, + }), }; diff --git a/extensions/signal/src/shared.ts b/extensions/signal/src/shared.ts index 1622dc207e4..c1c0e8055dc 100644 --- a/extensions/signal/src/shared.ts +++ b/extensions/signal/src/shared.ts @@ -1,8 +1,8 @@ import { - collectAllowlistProviderRestrictSendersWarnings, createScopedChannelConfigAdapter, createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; +import { createAllowlistProviderRestrictSendersWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { createChannelPluginBase } from "openclaw/plugin-sdk/core"; import { listSignalAccountIds, @@ -53,21 +53,16 @@ export const signalResolveDmPolicy = createScopedDmSecurityResolver normalizeE164(raw.replace(/^signal:/i, "").trim()), }); -export function collectSignalSecurityWarnings(params: { - account: ResolvedSignalAccount; - cfg: Parameters[0]["cfg"]; -}) { - return collectAllowlistProviderRestrictSendersWarnings({ - cfg: params.cfg, - providerConfigPresent: params.cfg.channels?.signal !== undefined, - configuredGroupPolicy: params.account.config.groupPolicy, +export const collectSignalSecurityWarnings = + createAllowlistProviderRestrictSendersWarningCollector({ + providerConfigPresent: (cfg) => cfg.channels?.signal !== undefined, + resolveGroupPolicy: (account) => account.config.groupPolicy, surface: "Signal groups", openScope: "any member", groupPolicyPath: "channels.signal.groupPolicy", groupAllowFromPath: "channels.signal.groupAllowFrom", mentionGated: false, }); -} export function createSignalPluginBase(params: { setupWizard?: NonNullable["setupWizard"]>; diff --git a/extensions/slack/src/channel.test.ts b/extensions/slack/src/channel.test.ts index 4f22cd91263..73acfe3aeb7 100644 --- a/extensions/slack/src/channel.test.ts +++ b/extensions/slack/src/channel.test.ts @@ -1,5 +1,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/slack"; import { describe, expect, it, vi } from "vitest"; +import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js"; +import { slackOutbound } from "./outbound-adapter.js"; const handleSlackActionMock = vi.fn(); @@ -169,6 +171,101 @@ describe("slackPlugin outbound", () => { ); expect(result).toEqual({ channel: "slack", messageId: "m-media-local" }); }); + + it("sends block payload media first, then the final block message", async () => { + const sendSlack = vi + .fn() + .mockResolvedValueOnce({ messageId: "m-media-1" }) + .mockResolvedValueOnce({ messageId: "m-media-2" }) + .mockResolvedValueOnce({ messageId: "m-final" }); + const sendPayload = slackOutbound.sendPayload; + expect(sendPayload).toBeDefined(); + + const result = await sendPayload!({ + cfg, + to: "C999", + text: "", + payload: { + text: "hello", + mediaUrls: ["https://example.com/1.png", "https://example.com/2.png"], + channelData: { + slack: { + blocks: [ + { + type: "section", + text: { + type: "plain_text", + text: "Block body", + }, + }, + ], + }, + }, + }, + accountId: "default", + deps: { sendSlack }, + mediaLocalRoots: ["/tmp/media"], + }); + + expect(sendSlack).toHaveBeenCalledTimes(3); + expect(sendSlack).toHaveBeenNthCalledWith( + 1, + "C999", + "", + expect.objectContaining({ + mediaUrl: "https://example.com/1.png", + mediaLocalRoots: ["/tmp/media"], + }), + ); + expect(sendSlack).toHaveBeenNthCalledWith( + 2, + "C999", + "", + expect.objectContaining({ + mediaUrl: "https://example.com/2.png", + mediaLocalRoots: ["/tmp/media"], + }), + ); + expect(sendSlack).toHaveBeenNthCalledWith( + 3, + "C999", + "hello", + expect.objectContaining({ + blocks: [ + { + type: "section", + text: { + type: "plain_text", + text: "Block body", + }, + }, + ], + }), + ); + expect(result).toEqual({ channel: "slack", messageId: "m-final" }); + }); +}); + +describe("slackPlugin directory", () => { + it("lists configured peers without throwing a ReferenceError", async () => { + const listPeers = slackPlugin.directory?.listPeers; + expect(listPeers).toBeDefined(); + + await expect( + listPeers!({ + cfg: { + channels: { + slack: { + dms: { + U123: {}, + }, + }, + }, + }, + runtime: createRuntimeEnv(), + }), + ).resolves.toEqual([{ id: "user:u123", kind: "user" }]); + }); }); describe("slackPlugin agentPrompt", () => { diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index cbb86a1dff1..379d0537e2b 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -1,13 +1,20 @@ import { - buildAccountScopedAllowlistConfigEditor, - resolveLegacyDmAllowlistConfigPaths, + buildLegacyDmAccountAllowlistAdapter, + createAccountScopedAllowlistNameResolver, + createFlatAllowlistOverrideResolver, } from "openclaw/plugin-sdk/allowlist-config-edit"; +import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; +import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { - createScopedDmSecurityResolver, - collectOpenGroupPolicyConfiguredRouteWarnings, - collectOpenProviderGroupPolicyWarnings, -} from "openclaw/plugin-sdk/channel-config-helpers"; -import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; + createAttachedChannelResultAdapter, + createChannelDirectoryAdapter, + createPairingPrefixStripper, + createScopedAccountReplyToModeResolver, + createRuntimeDirectoryLiveAdapter, + createTextPairingAdapter, + resolveOutboundSendDep, + resolveTargetsWithOptionalToken, +} from "openclaw/plugin-sdk/channel-runtime"; import { buildOutboundBaseSessionKey, normalizeOutboundThreadId } from "openclaw/plugin-sdk/core"; import { resolveThreadSessionKeys, type RoutePeer } from "openclaw/plugin-sdk/routing"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; @@ -21,6 +28,10 @@ import type { SlackActionContext } from "./action-runtime.js"; import { parseSlackBlocksInput } from "./blocks-input.js"; import { createSlackActions } from "./channel-actions.js"; import { createSlackWebClient } from "./client.js"; +import { + listSlackDirectoryGroupsFromConfig, + listSlackDirectoryPeersFromConfig, +} from "./directory-config.js"; import { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy } from "./group-policy.js"; import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; import { normalizeAllowListLower } from "./monitor/allow-list.js"; @@ -29,8 +40,6 @@ import { resolveSlackUserAllowlist } from "./resolve-users.js"; import { buildComputedAccountStatusSnapshot, DEFAULT_ACCOUNT_ID, - listSlackDirectoryGroupsFromConfig, - listSlackDirectoryPeersFromConfig, looksLikeSlackTargetId, normalizeSlackMessagingTarget, PAIRING_APPROVED_MESSAGE, @@ -286,41 +295,49 @@ function formatSlackScopeDiagnostic(params: { } as const; } -function readSlackAllowlistConfig(account: ResolvedSlackAccount) { - return { - dmAllowFrom: (account.config.allowFrom ?? account.config.dm?.allowFrom ?? []).map(String), - groupPolicy: account.groupPolicy, - groupOverrides: Object.entries(account.channels ?? {}) - .map(([key, value]) => { - const entries = (value?.users ?? []).map(String).filter(Boolean); - return entries.length > 0 ? { label: key, entries } : null; - }) - .filter(Boolean) as Array<{ label: string; entries: string[] }>, - }; -} +const resolveSlackAllowlistGroupOverrides = createFlatAllowlistOverrideResolver({ + resolveRecord: (account: ResolvedSlackAccount) => account.channels, + label: (key) => key, + resolveEntries: (value) => value?.users, +}); -async function resolveSlackAllowlistNames(params: { - cfg: Parameters[0]["cfg"]; - accountId?: string | null; - entries: string[]; -}) { - const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId }); - const token = account.config.userToken?.trim() || account.botToken?.trim(); - if (!token) { - return []; - } - return await resolveSlackUserAllowlist({ token, entries: params.entries }); -} +const resolveSlackAllowlistNames = createAccountScopedAllowlistNameResolver({ + resolveAccount: ({ cfg, accountId }) => resolveSlackAccount({ cfg, accountId }), + resolveToken: (account: ResolvedSlackAccount) => + account.config.userToken?.trim() || account.botToken?.trim(), + resolveNames: ({ token, entries }) => resolveSlackUserAllowlist({ token, entries }), +}); + +const collectSlackSecurityWarnings = + createOpenProviderConfiguredRouteWarningCollector({ + providerConfigPresent: (cfg) => cfg.channels?.slack !== undefined, + resolveGroupPolicy: (account) => account.config.groupPolicy, + resolveRouteAllowlistConfigured: (account) => + Boolean(account.config.channels) && Object.keys(account.config.channels ?? {}).length > 0, + configureRouteAllowlist: { + surface: "Slack channels", + openScope: "any channel not explicitly denied", + groupPolicyPath: "channels.slack.groupPolicy", + routeAllowlistPath: "channels.slack.channels", + }, + missingRouteAllowlist: { + surface: "Slack channels", + openBehavior: "with no channel allowlist; any channel can trigger (mention-gated)", + remediation: + 'Set channels.slack.groupPolicy="allowlist" and configure channels.slack.channels', + }, + }); export const slackPlugin: ChannelPlugin = { ...createSlackPluginBase({ setupWizard: slackSetupWizard, setup: slackSetupAdapter, }), - pairing: { + pairing: createTextPairingAdapter({ idLabel: "slackUserId", - normalizeAllowEntry: (entry) => entry.replace(/^(slack|user):/i, ""), - notifyApproval: async ({ id }) => { + message: PAIRING_APPROVED_MESSAGE, + normalizeAllowEntry: createPairingPrefixStripper(/^(slack|user):/i), + notify: async ({ id, message }) => { const cfg = getSlackRuntime().config.loadConfig(); const account = resolveSlackAccount({ cfg, @@ -330,71 +347,39 @@ export const slackPlugin: ChannelPlugin = { const botToken = account.botToken?.trim(); const tokenOverride = token && token !== botToken ? token : undefined; if (tokenOverride) { - await getSlackRuntime().channel.slack.sendMessageSlack( - `user:${id}`, - PAIRING_APPROVED_MESSAGE, - { - token: tokenOverride, - }, - ); + await getSlackRuntime().channel.slack.sendMessageSlack(`user:${id}`, message, { + token: tokenOverride, + }); } else { - await getSlackRuntime().channel.slack.sendMessageSlack( - `user:${id}`, - PAIRING_APPROVED_MESSAGE, - ); + await getSlackRuntime().channel.slack.sendMessageSlack(`user:${id}`, message); } }, - }, + }), allowlist: { - supportsScope: ({ scope }) => scope === "dm", - readConfig: ({ cfg, accountId }) => - readSlackAllowlistConfig(resolveSlackAccount({ cfg, accountId })), - resolveNames: async ({ cfg, accountId, entries }) => - await resolveSlackAllowlistNames({ cfg, accountId, entries }), - applyConfigEdit: buildAccountScopedAllowlistConfigEditor({ + ...buildLegacyDmAccountAllowlistAdapter({ channelId: "slack", + resolveAccount: ({ cfg, accountId }) => resolveSlackAccount({ cfg, accountId }), normalize: ({ cfg, accountId, values }) => slackConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }), - resolvePaths: resolveLegacyDmAllowlistConfigPaths, + resolveDmAllowFrom: (account) => account.config.allowFrom ?? account.config.dm?.allowFrom, + resolveGroupPolicy: (account) => account.groupPolicy, + resolveGroupOverrides: resolveSlackAllowlistGroupOverrides, }), + resolveNames: resolveSlackAllowlistNames, }, security: { resolveDmPolicy: resolveSlackDmPolicy, - collectWarnings: ({ account, cfg }) => { - const channelAllowlistConfigured = - Boolean(account.config.channels) && Object.keys(account.config.channels ?? {}).length > 0; - - return collectOpenProviderGroupPolicyWarnings({ - cfg, - providerConfigPresent: cfg.channels?.slack !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - collect: (groupPolicy) => - collectOpenGroupPolicyConfiguredRouteWarnings({ - groupPolicy, - routeAllowlistConfigured: channelAllowlistConfigured, - configureRouteAllowlist: { - surface: "Slack channels", - openScope: "any channel not explicitly denied", - groupPolicyPath: "channels.slack.groupPolicy", - routeAllowlistPath: "channels.slack.channels", - }, - missingRouteAllowlist: { - surface: "Slack channels", - openBehavior: "with no channel allowlist; any channel can trigger (mention-gated)", - remediation: - 'Set channels.slack.groupPolicy="allowlist" and configure channels.slack.channels', - }, - }), - }); - }, + collectWarnings: collectSlackSecurityWarnings, }, groups: { resolveRequireMention: resolveSlackGroupRequireMention, resolveToolPolicy: resolveSlackGroupToolPolicy, }, threading: { - resolveReplyToMode: ({ cfg, accountId, chatType }) => - resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType), + resolveReplyToMode: createScopedAccountReplyToModeResolver({ + resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }), + resolveReplyToMode: (account, chatType) => resolveSlackReplyToMode(account, chatType), + }), allowExplicitReplyTagsWhenOff: false, buildToolContext: (params) => buildSlackThreadingToolContext(params), resolveAutoThreadId: ({ cfg, accountId, to, toolContext, replyToId }) => @@ -435,14 +420,15 @@ export const slackPlugin: ChannelPlugin = { hint: "", }, }, - directory: { - self: async () => null, + directory: createChannelDirectoryAdapter({ listPeers: async (params) => listSlackDirectoryPeersFromConfig(params), listGroups: async (params) => listSlackDirectoryGroupsFromConfig(params), - listPeersLive: async (params) => getSlackRuntime().channel.slack.listDirectoryPeersLive(params), - listGroupsLive: async (params) => - getSlackRuntime().channel.slack.listDirectoryGroupsLive(params), - }, + ...createRuntimeDirectoryLiveAdapter({ + getRuntime: () => getSlackRuntime().channel.slack, + listPeersLive: (runtime) => runtime.listDirectoryPeersLive, + listGroupsLive: (runtime) => runtime.listDirectoryGroupsLive, + }), + }), resolver: { resolveTargets: async ({ cfg, accountId, inputs, kind }) => { const toResolvedTarget = < @@ -458,28 +444,30 @@ export const slackPlugin: ChannelPlugin = { note, }); const account = resolveSlackAccount({ cfg, accountId }); - const token = account.config.userToken?.trim() || account.botToken?.trim(); - if (!token) { - return inputs.map((input) => ({ - input, - resolved: false, - note: "missing Slack token", - })); - } if (kind === "group") { - const resolved = await getSlackRuntime().channel.slack.resolveChannelAllowlist({ - token, - entries: inputs, + return resolveTargetsWithOptionalToken({ + token: account.config.userToken?.trim() || account.botToken?.trim(), + inputs, + missingTokenNote: "missing Slack token", + resolveWithToken: ({ token, inputs }) => + getSlackRuntime().channel.slack.resolveChannelAllowlist({ + token, + entries: inputs, + }), + mapResolved: (entry) => toResolvedTarget(entry, entry.archived ? "archived" : undefined), }); - return resolved.map((entry) => - toResolvedTarget(entry, entry.archived ? "archived" : undefined), - ); } - const resolved = await getSlackRuntime().channel.slack.resolveUserAllowlist({ - token, - entries: inputs, + return resolveTargetsWithOptionalToken({ + token: account.config.userToken?.trim() || account.botToken?.trim(), + inputs, + missingTokenNote: "missing Slack token", + resolveWithToken: ({ token, inputs }) => + getSlackRuntime().channel.slack.resolveUserAllowlist({ + token, + entries: inputs, + }), + mapResolved: (entry) => toResolvedTarget(entry, entry.note), }); - return resolved.map((entry) => toResolvedTarget(entry, entry.note)); }, }, actions: createSlackActions(SLACK_CHANNEL, { @@ -495,50 +483,51 @@ export const slackPlugin: ChannelPlugin = { deliveryMode: "direct", chunker: null, textChunkLimit: 4000, - sendText: async ({ to, text, accountId, deps, replyToId, threadId, cfg }) => { - const { send, threadTsValue, tokenOverride } = resolveSlackSendContext({ - cfg, - accountId: accountId ?? undefined, - deps, - replyToId, - threadId, - }); - const result = await send(to, text, { - cfg, - threadTs: threadTsValue != null ? String(threadTsValue) : undefined, - accountId: accountId ?? undefined, - ...(tokenOverride ? { token: tokenOverride } : {}), - }); - return { channel: "slack", ...result }; - }, - sendMedia: async ({ - to, - text, - mediaUrl, - mediaLocalRoots, - accountId, - deps, - replyToId, - threadId, - cfg, - }) => { - const { send, threadTsValue, tokenOverride } = resolveSlackSendContext({ - cfg, - accountId: accountId ?? undefined, - deps, - replyToId, - threadId, - }); - const result = await send(to, text, { - cfg, + ...createAttachedChannelResultAdapter({ + channel: "slack", + sendText: async ({ to, text, accountId, deps, replyToId, threadId, cfg }) => { + const { send, threadTsValue, tokenOverride } = resolveSlackSendContext({ + cfg, + accountId: accountId ?? undefined, + deps, + replyToId, + threadId, + }); + return await send(to, text, { + cfg, + threadTs: threadTsValue != null ? String(threadTsValue) : undefined, + accountId: accountId ?? undefined, + ...(tokenOverride ? { token: tokenOverride } : {}), + }); + }, + sendMedia: async ({ + to, + text, mediaUrl, mediaLocalRoots, - threadTs: threadTsValue != null ? String(threadTsValue) : undefined, - accountId: accountId ?? undefined, - ...(tokenOverride ? { token: tokenOverride } : {}), - }); - return { channel: "slack", ...result }; - }, + accountId, + deps, + replyToId, + threadId, + cfg, + }) => { + const { send, threadTsValue, tokenOverride } = resolveSlackSendContext({ + cfg, + accountId: accountId ?? undefined, + deps, + replyToId, + threadId, + }); + return await send(to, text, { + cfg, + mediaUrl, + mediaLocalRoots, + threadTs: threadTsValue != null ? String(threadTsValue) : undefined, + accountId: accountId ?? undefined, + ...(tokenOverride ? { token: tokenOverride } : {}), + }); + }, + }), }, status: { defaultRuntime: { diff --git a/extensions/slack/src/directory-config.ts b/extensions/slack/src/directory-config.ts index ec125727454..9cc8330820e 100644 --- a/extensions/slack/src/directory-config.ts +++ b/extensions/slack/src/directory-config.ts @@ -1,28 +1,23 @@ import { - applyDirectoryQueryAndLimit, - collectNormalizedDirectoryIds, - listDirectoryGroupEntriesFromMapKeys, - toDirectoryEntries, + listInspectedDirectoryEntriesFromSources, type DirectoryConfigParams, } from "openclaw/plugin-sdk/directory-runtime"; import { inspectSlackAccount, type InspectedSlackAccount } from "../api.js"; import { parseSlackTarget } from "./targets.js"; export async function listSlackDirectoryPeersFromConfig(params: DirectoryConfigParams) { - const account: InspectedSlackAccount = inspectSlackAccount({ - cfg: params.cfg, - accountId: params.accountId, - }); - if (!account.config) { - return []; - } - - const allowFrom = account.config.allowFrom ?? account.dm?.allowFrom ?? []; - const channelUsers = Object.values(account.config.channels ?? {}).flatMap( - (channel) => channel.users ?? [], - ); - const ids = collectNormalizedDirectoryIds({ - sources: [allowFrom, Object.keys(account.config.dms ?? {}), channelUsers], + return listInspectedDirectoryEntriesFromSources({ + ...params, + kind: "user", + inspectAccount: (cfg, accountId) => + inspectSlackAccount({ cfg, accountId }) as InspectedSlackAccount | null, + resolveSources: (account) => { + const allowFrom = account.config.allowFrom ?? account.dm?.allowFrom ?? []; + const channelUsers = Object.values(account.config.channels ?? {}).flatMap( + (channel) => channel.users ?? [], + ); + return [allowFrom, Object.keys(account.config.dms ?? {}), channelUsers]; + }, normalizeId: (raw) => { const mention = raw.match(/^<@([A-Z0-9]+)>$/i); const normalizedUserId = (mention?.[1] ?? raw).replace(/^(slack|user):/i, "").trim(); @@ -34,21 +29,15 @@ export async function listSlackDirectoryPeersFromConfig(params: DirectoryConfigP return normalized?.kind === "user" ? `user:${normalized.id.toLowerCase()}` : null; }, }); - return toDirectoryEntries("user", applyDirectoryQueryAndLimit(ids, params)); } export async function listSlackDirectoryGroupsFromConfig(params: DirectoryConfigParams) { - const account: InspectedSlackAccount = inspectSlackAccount({ - cfg: params.cfg, - accountId: params.accountId, - }); - if (!account.config) { - return []; - } - return listDirectoryGroupEntriesFromMapKeys({ - groups: account.config.channels, - query: params.query, - limit: params.limit, + return listInspectedDirectoryEntriesFromSources({ + ...params, + kind: "group", + inspectAccount: (cfg, accountId) => + inspectSlackAccount({ cfg, accountId }) as InspectedSlackAccount | null, + resolveSources: (account) => [Object.keys(account.config.channels ?? {})], normalizeId: (raw) => { const normalized = parseSlackTarget(raw, { defaultKind: "channel" }); return normalized?.kind === "channel" ? `channel:${normalized.id.toLowerCase()}` : null; diff --git a/extensions/slack/src/monitor/message-handler/dispatch.ts b/extensions/slack/src/monitor/message-handler/dispatch.ts index 569ca8f60a7..5fac27f002b 100644 --- a/extensions/slack/src/monitor/message-handler/dispatch.ts +++ b/extensions/slack/src/monitor/message-handler/dispatch.ts @@ -5,6 +5,7 @@ import { createReplyPrefixOptions } from "openclaw/plugin-sdk/channel-runtime"; import { createTypingCallbacks } from "openclaw/plugin-sdk/channel-runtime"; import { resolveStorePath, updateLastRoute } from "openclaw/plugin-sdk/config-runtime"; import { resolveAgentOutboundIdentity } from "openclaw/plugin-sdk/infra-runtime"; +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime"; import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-runtime"; import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime"; @@ -33,7 +34,7 @@ import { import type { PreparedSlackMessage } from "./types.js"; function hasMedia(payload: ReplyPayload): boolean { - return Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; + return resolveSendableOutboundReplyParts(payload).hasMedia; } export function isSlackStreamingEnabled(params: { @@ -250,17 +251,13 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag }; const deliverWithStreaming = async (payload: ReplyPayload): Promise => { - if ( - streamFailed || - hasMedia(payload) || - readSlackReplyBlocks(payload)?.length || - !payload.text?.trim() - ) { + const reply = resolveSendableOutboundReplyParts(payload); + if (streamFailed || reply.hasMedia || readSlackReplyBlocks(payload)?.length || !reply.hasText) { await deliverNormally(payload, streamSession?.threadTs); return; } - const text = payload.text.trim(); + const text = reply.trimmedText; let plannedThreadTs: string | undefined; try { if (!streamSession) { @@ -311,16 +308,16 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag return; } - const mediaCount = payload.mediaUrls?.length ?? (payload.mediaUrl ? 1 : 0); + const reply = resolveSendableOutboundReplyParts(payload); const slackBlocks = readSlackReplyBlocks(payload); const draftMessageId = draftStream?.messageId(); const draftChannelId = draftStream?.channelId(); - const finalText = payload.text ?? ""; - const trimmedFinalText = finalText.trim(); + const finalText = reply.text; + const trimmedFinalText = reply.trimmedText; const canFinalizeViaPreviewEdit = previewStreamingEnabled && streamMode !== "status_final" && - mediaCount === 0 && + !reply.hasMedia && !payload.isError && (trimmedFinalText.length > 0 || Boolean(slackBlocks?.length)) && typeof draftMessageId === "string" && @@ -361,7 +358,7 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag } catch (err) { logVerbose(`slack: status_final completion update failed (${String(err)})`); } - } else if (mediaCount > 0) { + } else if (reply.hasMedia) { await draftStream?.clear(); hasStreamedMessage = false; } diff --git a/extensions/slack/src/monitor/replies.ts b/extensions/slack/src/monitor/replies.ts index a8ef26510f0..f25e58673ca 100644 --- a/extensions/slack/src/monitor/replies.ts +++ b/extensions/slack/src/monitor/replies.ts @@ -1,4 +1,8 @@ import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +import { + deliverTextOrMediaReply, + resolveSendableOutboundReplyParts, +} from "openclaw/plugin-sdk/reply-payload"; import type { ChunkMode } from "openclaw/plugin-sdk/reply-runtime"; import { chunkMarkdownTextWithMode } from "openclaw/plugin-sdk/reply-runtime"; import { createReplyReferencePlanner } from "openclaw/plugin-sdk/reply-runtime"; @@ -37,15 +41,14 @@ export async function deliverReplies(params: { // must not force threading. const inlineReplyToId = params.replyToMode === "off" ? undefined : payload.replyToId; const threadTs = inlineReplyToId ?? params.replyThreadTs; - const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); - const text = payload.text ?? ""; + const reply = resolveSendableOutboundReplyParts(payload); const slackBlocks = readSlackReplyBlocks(payload); - if (!text && mediaList.length === 0 && !slackBlocks?.length) { + if (!reply.hasContent && !slackBlocks?.length) { continue; } - if (mediaList.length === 0) { - const trimmed = text.trim(); + if (!reply.hasMedia && slackBlocks?.length) { + const trimmed = reply.trimmedText; if (!trimmed && !slackBlocks?.length) { continue; } @@ -59,21 +62,43 @@ export async function deliverReplies(params: { ...(slackBlocks?.length ? { blocks: slackBlocks } : {}), ...(params.identity ? { identity: params.identity } : {}), }); - } else { - let first = true; - for (const mediaUrl of mediaList) { - const caption = first ? text : ""; - first = false; - await sendMessageSlack(params.target, caption, { + params.runtime.log?.(`delivered reply to ${params.target}`); + continue; + } + + const delivered = await deliverTextOrMediaReply({ + payload, + text: reply.text, + chunkText: !reply.hasMedia + ? (value) => { + const trimmed = value.trim(); + if (!trimmed || isSilentReplyText(trimmed, SILENT_REPLY_TOKEN)) { + return []; + } + return [trimmed]; + } + : undefined, + sendText: async (trimmed) => { + await sendMessageSlack(params.target, trimmed, { + token: params.token, + threadTs, + accountId: params.accountId, + ...(params.identity ? { identity: params.identity } : {}), + }); + }, + sendMedia: async ({ mediaUrl, caption }) => { + await sendMessageSlack(params.target, caption ?? "", { token: params.token, mediaUrl, threadTs, accountId: params.accountId, ...(params.identity ? { identity: params.identity } : {}), }); - } + }, + }); + if (delivered !== "empty") { + params.runtime.log?.(`delivered reply to ${params.target}`); } - params.runtime.log?.(`delivered reply to ${params.target}`); } } @@ -165,12 +190,12 @@ export async function deliverSlackSlashReplies(params: { const messages: string[] = []; const chunkLimit = Math.min(params.textLimit, 4000); for (const payload of params.replies) { - const textRaw = payload.text?.trim() ?? ""; - const text = textRaw && !isSilentReplyText(textRaw, SILENT_REPLY_TOKEN) ? textRaw : undefined; - const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); - const combined = [text ?? "", ...mediaList.map((url) => url.trim()).filter(Boolean)] - .filter(Boolean) - .join("\n"); + const reply = resolveSendableOutboundReplyParts(payload); + const text = + reply.hasText && !isSilentReplyText(reply.trimmedText, SILENT_REPLY_TOKEN) + ? reply.trimmedText + : undefined; + const combined = [text ?? "", ...reply.mediaUrls].filter(Boolean).join("\n"); if (!combined) { continue; } diff --git a/extensions/slack/src/outbound-adapter.ts b/extensions/slack/src/outbound-adapter.ts index 42888ea12b4..ed107d4c63f 100644 --- a/extensions/slack/src/outbound-adapter.ts +++ b/extensions/slack/src/outbound-adapter.ts @@ -1,10 +1,14 @@ import { resolvePayloadMediaUrls, - sendPayloadMediaSequence, + sendPayloadMediaSequenceAndFinalize, sendTextMediaPayload, } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime"; import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; +import { + attachChannelToResult, + createAttachedChannelResultAdapter, +} from "openclaw/plugin-sdk/channel-send-result"; import type { OutboundIdentity } from "openclaw/plugin-sdk/infra-runtime"; import { resolveInteractiveTextFallback, @@ -96,7 +100,6 @@ async function sendSlackOutboundMessage(params: { }); if (hookResult.cancelled) { return { - channel: "slack" as const, messageId: "cancelled-by-hook", channelId: params.to, meta: { cancelled: true }, @@ -114,7 +117,7 @@ async function sendSlackOutboundMessage(params: { ...(params.blocks ? { blocks: params.blocks } : {}), ...(slackIdentity ? { identity: slackIdentity } : {}), }); - return { channel: "slack" as const, ...result }; + return result; } function resolveSlackBlocks(payload: { @@ -166,75 +169,54 @@ export const slackOutbound: ChannelOutboundAdapter = { }); } const mediaUrls = resolvePayloadMediaUrls(payload); - if (mediaUrls.length === 0) { - return await sendSlackOutboundMessage({ - cfg: ctx.cfg, - to: ctx.to, - text: payload.text ?? "", - mediaLocalRoots: ctx.mediaLocalRoots, - blocks, - accountId: ctx.accountId, - deps: ctx.deps, - replyToId: ctx.replyToId, - threadId: ctx.threadId, - identity: ctx.identity, - }); - } - await sendPayloadMediaSequence({ - text: "", - mediaUrls, - send: async ({ text, mediaUrl }) => - await sendSlackOutboundMessage({ - cfg: ctx.cfg, - to: ctx.to, - text, - mediaUrl, - mediaLocalRoots: ctx.mediaLocalRoots, - accountId: ctx.accountId, - deps: ctx.deps, - replyToId: ctx.replyToId, - threadId: ctx.threadId, - identity: ctx.identity, - }), - }); - return await sendSlackOutboundMessage({ - cfg: ctx.cfg, - to: ctx.to, - text: payload.text ?? "", - mediaLocalRoots: ctx.mediaLocalRoots, - blocks, - accountId: ctx.accountId, - deps: ctx.deps, - replyToId: ctx.replyToId, - threadId: ctx.threadId, - identity: ctx.identity, - }); + return attachChannelToResult( + "slack", + await sendPayloadMediaSequenceAndFinalize({ + text: "", + mediaUrls, + send: async ({ text, mediaUrl }) => + await sendSlackOutboundMessage({ + cfg: ctx.cfg, + to: ctx.to, + text, + mediaUrl, + mediaLocalRoots: ctx.mediaLocalRoots, + accountId: ctx.accountId, + deps: ctx.deps, + replyToId: ctx.replyToId, + threadId: ctx.threadId, + identity: ctx.identity, + }), + finalize: async () => + await sendSlackOutboundMessage({ + cfg: ctx.cfg, + to: ctx.to, + text: payload.text ?? "", + mediaLocalRoots: ctx.mediaLocalRoots, + blocks, + accountId: ctx.accountId, + deps: ctx.deps, + replyToId: ctx.replyToId, + threadId: ctx.threadId, + identity: ctx.identity, + }), + }), + ); }, - sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity }) => { - return await sendSlackOutboundMessage({ - cfg, - to, - text, - accountId, - deps, - replyToId, - threadId, - identity, - }); - }, - sendMedia: async ({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId, - deps, - replyToId, - threadId, - identity, - }) => { - return await sendSlackOutboundMessage({ + ...createAttachedChannelResultAdapter({ + channel: "slack", + sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, identity }) => + await sendSlackOutboundMessage({ + cfg, + to, + text, + accountId, + deps, + replyToId, + threadId, + identity, + }), + sendMedia: async ({ cfg, to, text, @@ -245,6 +227,18 @@ export const slackOutbound: ChannelOutboundAdapter = { replyToId, threadId, identity, - }); - }, + }) => + await sendSlackOutboundMessage({ + cfg, + to, + text, + mediaUrl, + mediaLocalRoots, + accountId, + deps, + replyToId, + threadId, + identity, + }), + }), }; diff --git a/extensions/slack/src/send.ts b/extensions/slack/src/send.ts index 65f6203a57e..547013dc398 100644 --- a/extensions/slack/src/send.ts +++ b/extensions/slack/src/send.ts @@ -5,6 +5,7 @@ import { fetchWithSsrFGuard, withTrustedEnvProxyGuardedFetchMode, } from "openclaw/plugin-sdk/infra-runtime"; +import { resolveTextChunksWithFallback } from "openclaw/plugin-sdk/reply-payload"; import { chunkMarkdownTextWithMode, resolveChunkMode, @@ -310,9 +311,7 @@ export async function sendMessageSlack( const chunks = markdownChunks.flatMap((markdown) => markdownToSlackMrkdwnChunks(markdown, chunkLimit, { tableMode }), ); - if (!chunks.length && trimmedMessage) { - chunks.push(trimmedMessage); - } + const resolvedChunks = resolveTextChunksWithFallback(trimmedMessage, chunks); const mediaMaxBytes = typeof account.config.mediaMaxMb === "number" ? account.config.mediaMaxMb * 1024 * 1024 @@ -320,7 +319,7 @@ export async function sendMessageSlack( let lastMessageId = ""; if (opts.mediaUrl) { - const [firstChunk, ...rest] = chunks; + const [firstChunk, ...rest] = resolvedChunks; lastMessageId = await uploadSlackFile({ client, channelId, @@ -341,7 +340,7 @@ export async function sendMessageSlack( lastMessageId = response.ts ?? lastMessageId; } } else { - for (const chunk of chunks.length ? chunks : [""]) { + for (const chunk of resolvedChunks.length ? resolvedChunks : [""]) { const response = await postSlackMessageBestEffort({ client, channelId, diff --git a/extensions/synology-chat/src/channel.test.ts b/extensions/synology-chat/src/channel.test.ts index 3c453d0613a..4d9ed53a14e 100644 --- a/extensions/synology-chat/src/channel.test.ts +++ b/extensions/synology-chat/src/channel.test.ts @@ -97,8 +97,11 @@ describe("createSynologyChatPlugin", () => { it("has notifyApproval and normalizeAllowEntry", () => { const plugin = createSynologyChatPlugin(); expect(plugin.pairing.idLabel).toBe("synologyChatUserId"); - expect(typeof plugin.pairing.normalizeAllowEntry).toBe("function"); - expect(plugin.pairing.normalizeAllowEntry(" USER1 ")).toBe("user1"); + const normalize = plugin.pairing.normalizeAllowEntry; + expect(typeof normalize).toBe("function"); + if (normalize) { + expect(normalize(" USER1 ")).toBe("user1"); + } expect(typeof plugin.pairing.notifyApproval).toBe("function"); }); }); @@ -160,9 +163,10 @@ describe("createSynologyChatPlugin", () => { describe("directory", () => { it("returns empty stubs", async () => { const plugin = createSynologyChatPlugin(); - expect(await plugin.directory.self()).toBeNull(); - expect(await plugin.directory.listPeers()).toEqual([]); - expect(await plugin.directory.listGroups()).toEqual([]); + const params = { cfg: {}, runtime: {} as never }; + expect(await plugin.directory.self?.(params)).toBeNull(); + expect(await plugin.directory.listPeers?.(params)).toEqual([]); + expect(await plugin.directory.listGroups?.(params)).toEqual([]); }); }); diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index 496b5563857..9617dc129ae 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -8,6 +8,15 @@ import { createHybridChannelConfigAdapter, createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; +import { + createConditionalWarningCollector, + projectWarningCollector, +} from "openclaw/plugin-sdk/channel-policy"; +import { + attachChannelToResult, + createEmptyChannelDirectoryAdapter, + createTextPairingAdapter, +} from "openclaw/plugin-sdk/channel-runtime"; import { z } from "zod"; import { DEFAULT_ACCOUNT_ID, registerPluginHttpRoute, buildChannelConfigSchema } from "../api.js"; import { listAccountIds, resolveAccount } from "./accounts.js"; @@ -53,6 +62,26 @@ const synologyChatConfigAdapter = createHybridChannelConfigAdapter String(entry).trim().toLowerCase()).filter(Boolean), }); +const collectSynologyChatSecurityWarnings = + createConditionalWarningCollector( + (account) => + !account.token && + "- Synology Chat: token is not configured. The webhook will reject all requests.", + (account) => + !account.incomingUrl && + "- Synology Chat: incomingUrl is not configured. The bot cannot send replies.", + (account) => + account.allowInsecureSsl && + "- Synology Chat: SSL verification is disabled (allowInsecureSsl=true). Only use this for local NAS with self-signed certificates.", + (account) => + account.dmPolicy === "open" && + '- Synology Chat: dmPolicy="open" allows any user to message the bot. Consider "allowlist" for production use.', + (account) => + account.dmPolicy === "allowlist" && + account.allowedUserIds.length === 0 && + '- Synology Chat: dmPolicy="allowlist" with empty allowedUserIds blocks all senders. Add users or set dmPolicy="open".', + ); + function waitUntilAbort(signal?: AbortSignal, onAbort?: () => void): Promise { return new Promise((resolve) => { const complete = () => { @@ -106,52 +135,23 @@ export function createSynologyChatPlugin() { ...synologyChatConfigAdapter, }, - pairing: { + pairing: createTextPairingAdapter({ idLabel: "synologyChatUserId", + message: "OpenClaw: your access has been approved.", normalizeAllowEntry: (entry: string) => entry.toLowerCase().trim(), - notifyApproval: async ({ cfg, id }: { cfg: any; id: string }) => { + notify: async ({ cfg, id, message }) => { const account = resolveAccount(cfg); if (!account.incomingUrl) return; - await sendMessage( - account.incomingUrl, - "OpenClaw: your access has been approved.", - id, - account.allowInsecureSsl, - ); + await sendMessage(account.incomingUrl, message, id, account.allowInsecureSsl); }, - }, + }), security: { resolveDmPolicy: resolveSynologyChatDmPolicy, - collectWarnings: ({ account }: { account: ResolvedSynologyChatAccount }) => { - const warnings: string[] = []; - if (!account.token) { - warnings.push( - "- Synology Chat: token is not configured. The webhook will reject all requests.", - ); - } - if (!account.incomingUrl) { - warnings.push( - "- Synology Chat: incomingUrl is not configured. The bot cannot send replies.", - ); - } - if (account.allowInsecureSsl) { - warnings.push( - "- Synology Chat: SSL verification is disabled (allowInsecureSsl=true). Only use this for local NAS with self-signed certificates.", - ); - } - if (account.dmPolicy === "open") { - warnings.push( - '- Synology Chat: dmPolicy="open" allows any user to message the bot. Consider "allowlist" for production use.', - ); - } - if (account.dmPolicy === "allowlist" && account.allowedUserIds.length === 0) { - warnings.push( - '- Synology Chat: dmPolicy="allowlist" with empty allowedUserIds blocks all senders. Add users or set dmPolicy="open".', - ); - } - return warnings; - }, + collectWarnings: projectWarningCollector( + ({ account }: { account: ResolvedSynologyChatAccount }) => account, + collectSynologyChatSecurityWarnings, + ), }, messaging: { @@ -172,11 +172,7 @@ export function createSynologyChatPlugin() { }, }, - directory: { - self: async () => null, - listPeers: async () => [], - listGroups: async () => [], - }, + directory: createEmptyChannelDirectoryAdapter(), outbound: { deliveryMode: "gateway" as const, @@ -193,7 +189,7 @@ export function createSynologyChatPlugin() { if (!ok) { throw new Error("Failed to send message to Synology Chat"); } - return { channel: CHANNEL_ID, messageId: `sc-${Date.now()}`, chatId: to }; + return attachChannelToResult(CHANNEL_ID, { messageId: `sc-${Date.now()}`, chatId: to }); }, sendMedia: async ({ to, mediaUrl, accountId, cfg }: any) => { @@ -210,7 +206,7 @@ export function createSynologyChatPlugin() { if (!ok) { throw new Error("Failed to send media to Synology Chat"); } - return { channel: CHANNEL_ID, messageId: `sc-${Date.now()}`, chatId: to }; + return attachChannelToResult(CHANNEL_ID, { messageId: `sc-${Date.now()}`, chatId: to }); }, }, diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts index 75df3bd5f2c..b6c3c01763c 100644 --- a/extensions/telegram/src/bot-message-dispatch.ts +++ b/extensions/telegram/src/bot-message-dispatch.ts @@ -22,6 +22,7 @@ import type { TelegramAccountConfig, } from "openclaw/plugin-sdk/config-runtime"; import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime"; import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; @@ -567,7 +568,8 @@ export const dispatchTelegramMessage = async ({ )?.buttons; const split = splitTextIntoLaneSegments(payload.text); const segments = split.segments; - const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; + const reply = resolveSendableOutboundReplyParts(payload); + const hasMedia = reply.hasMedia; const flushBufferedFinalAnswer = async () => { const buffered = reasoningStepState.takeBufferedFinalAnswer(); @@ -631,7 +633,7 @@ export const dispatchTelegramMessage = async ({ return; } if (split.suppressedReasoningOnly) { - if (hasMedia) { + if (reply.hasMedia) { const payloadWithoutSuppressedReasoning = typeof payload.text === "string" ? { ...payload, text: "" } : payload; await sendPayload(payloadWithoutSuppressedReasoning); @@ -647,8 +649,7 @@ export const dispatchTelegramMessage = async ({ await reasoningLane.stream?.stop(); reasoningStepState.resetForNextStep(); } - const canSendAsIs = - hasMedia || (typeof payload.text === "string" && payload.text.length > 0); + const canSendAsIs = reply.hasMedia || reply.text.length > 0; if (!canSendAsIs) { if (info.kind === "final") { await flushBufferedFinalAnswer(); diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 073ca5bd03a..6cfed61829e 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -1,11 +1,20 @@ -import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit"; import { - collectAllowlistProviderGroupPolicyWarnings, - collectOpenGroupPolicyRouteAllowlistWarnings, - createScopedDmSecurityResolver, -} from "openclaw/plugin-sdk/channel-config-helpers"; -import { type OutboundSendDeps, resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; -import { normalizeMessageChannel } from "openclaw/plugin-sdk/channel-runtime"; + buildDmGroupAccountAllowlistAdapter, + createNestedAllowlistOverrideResolver, +} from "openclaw/plugin-sdk/allowlist-config-edit"; +import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; +import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy"; +import { + attachChannelToResult, + createAttachedChannelResultAdapter, + createChannelDirectoryAdapter, + createPairingPrefixStripper, + createTopLevelChannelReplyToModeResolver, + createTextPairingAdapter, + normalizeMessageChannel, + type OutboundSendDeps, + resolveOutboundSendDep, +} from "openclaw/plugin-sdk/channel-runtime"; import { buildOutboundBaseSessionKey, normalizeOutboundThreadId } from "openclaw/plugin-sdk/core"; import { resolveExecApprovalCommandDisplay } from "openclaw/plugin-sdk/infra-runtime"; import { buildExecApprovalPendingReplyPayload } from "openclaw/plugin-sdk/infra-runtime"; @@ -273,65 +282,66 @@ const resolveTelegramDmPolicy = createScopedDmSecurityResolver raw.replace(/^(telegram|tg):/i, ""), }); -function readTelegramAllowlistConfig(account: ResolvedTelegramAccount) { - const groupOverrides: Array<{ label: string; entries: string[] }> = []; - for (const [groupId, groupCfg] of Object.entries(account.config.groups ?? {})) { - const entries = (groupCfg?.allowFrom ?? []).map(String).filter(Boolean); - if (entries.length > 0) { - groupOverrides.push({ label: groupId, entries }); - } - for (const [topicId, topicCfg] of Object.entries(groupCfg?.topics ?? {})) { - const topicEntries = (topicCfg?.allowFrom ?? []).map(String).filter(Boolean); - if (topicEntries.length > 0) { - groupOverrides.push({ label: `${groupId} topic ${topicId}`, entries: topicEntries }); - } - } - } - return { - dmAllowFrom: (account.config.allowFrom ?? []).map(String), - groupAllowFrom: (account.config.groupAllowFrom ?? []).map(String), - dmPolicy: account.config.dmPolicy, - groupPolicy: account.config.groupPolicy, - groupOverrides, - }; -} +const resolveTelegramAllowlistGroupOverrides = createNestedAllowlistOverrideResolver({ + resolveRecord: (account: ResolvedTelegramAccount) => account.config.groups, + outerLabel: (groupId) => groupId, + resolveOuterEntries: (groupCfg) => groupCfg?.allowFrom, + resolveChildren: (groupCfg) => groupCfg?.topics, + innerLabel: (groupId, topicId) => `${groupId} topic ${topicId}`, + resolveInnerEntries: (topicCfg) => topicCfg?.allowFrom, +}); + +const collectTelegramSecurityWarnings = + createAllowlistProviderRouteAllowlistWarningCollector({ + providerConfigPresent: (cfg) => cfg.channels?.telegram !== undefined, + resolveGroupPolicy: (account) => account.config.groupPolicy, + resolveRouteAllowlistConfigured: (account) => + Boolean(account.config.groups) && Object.keys(account.config.groups ?? {}).length > 0, + restrictSenders: { + surface: "Telegram groups", + openScope: "any member in allowed groups", + groupPolicyPath: "channels.telegram.groupPolicy", + groupAllowFromPath: "channels.telegram.groupAllowFrom", + }, + noRouteAllowlist: { + surface: "Telegram groups", + routeAllowlistPath: "channels.telegram.groups", + routeScope: "group", + groupPolicyPath: "channels.telegram.groupPolicy", + groupAllowFromPath: "channels.telegram.groupAllowFrom", + }, + }); export const telegramPlugin: ChannelPlugin = { ...createTelegramPluginBase({ setupWizard: telegramSetupWizard, setup: telegramSetupAdapter, }), - pairing: { + pairing: createTextPairingAdapter({ idLabel: "telegramUserId", - normalizeAllowEntry: (entry) => entry.replace(/^(telegram|tg):/i, ""), - notifyApproval: async ({ cfg, id }) => { + message: PAIRING_APPROVED_MESSAGE, + normalizeAllowEntry: createPairingPrefixStripper(/^(telegram|tg):/i), + notify: async ({ cfg, id, message }) => { const { token } = getTelegramRuntime().channel.telegram.resolveTelegramToken(cfg); if (!token) { throw new Error("telegram token not configured"); } - await getTelegramRuntime().channel.telegram.sendMessageTelegram( - id, - PAIRING_APPROVED_MESSAGE, - { - token, - }, - ); + await getTelegramRuntime().channel.telegram.sendMessageTelegram(id, message, { + token, + }); }, - }, - allowlist: { - supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", - readConfig: ({ cfg, accountId }) => - readTelegramAllowlistConfig(resolveTelegramAccount({ cfg, accountId })), - applyConfigEdit: buildAccountScopedAllowlistConfigEditor({ - channelId: "telegram", - normalize: ({ cfg, accountId, values }) => - telegramConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }), - resolvePaths: (scope) => ({ - readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]], - writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"], - }), - }), - }, + }), + allowlist: buildDmGroupAccountAllowlistAdapter({ + channelId: "telegram", + resolveAccount: ({ cfg, accountId }) => resolveTelegramAccount({ cfg, accountId }), + normalize: ({ cfg, accountId, values }) => + telegramConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }), + resolveDmAllowFrom: (account) => account.config.allowFrom, + resolveGroupAllowFrom: (account) => account.config.groupAllowFrom, + resolveDmPolicy: (account) => account.config.dmPolicy, + resolveGroupPolicy: (account) => account.config.groupPolicy, + resolveGroupOverrides: resolveTelegramAllowlistGroupOverrides, + }), bindings: { compileConfiguredBinding: ({ conversationId }) => normalizeTelegramAcpConversationId(conversationId), @@ -344,40 +354,14 @@ export const telegramPlugin: ChannelPlugin { - const groupAllowlistConfigured = - account.config.groups && Object.keys(account.config.groups).length > 0; - return collectAllowlistProviderGroupPolicyWarnings({ - cfg, - providerConfigPresent: cfg.channels?.telegram !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - collect: (groupPolicy) => - collectOpenGroupPolicyRouteAllowlistWarnings({ - groupPolicy, - routeAllowlistConfigured: Boolean(groupAllowlistConfigured), - restrictSenders: { - surface: "Telegram groups", - openScope: "any member in allowed groups", - groupPolicyPath: "channels.telegram.groupPolicy", - groupAllowFromPath: "channels.telegram.groupAllowFrom", - }, - noRouteAllowlist: { - surface: "Telegram groups", - routeAllowlistPath: "channels.telegram.groups", - routeScope: "group", - groupPolicyPath: "channels.telegram.groupPolicy", - groupAllowFromPath: "channels.telegram.groupAllowFrom", - }, - }), - }); - }, + collectWarnings: collectTelegramSecurityWarnings, }, groups: { resolveRequireMention: resolveTelegramGroupRequireMention, resolveToolPolicy: resolveTelegramGroupToolPolicy, }, threading: { - resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "off", + resolveReplyToMode: createTopLevelChannelReplyToModeResolver("telegram"), resolveAutoThreadId: ({ to, toolContext, replyToId }) => replyToId ? undefined : resolveTelegramAutoThreadId({ to, toolContext }), }, @@ -471,11 +455,10 @@ export const telegramPlugin: ChannelPlugin {}); }, }, - directory: { - self: async () => null, + directory: createChannelDirectoryAdapter({ listPeers: async (params) => listTelegramDirectoryPeersFromConfig(params), listGroups: async (params) => listTelegramDirectoryGroupsFromConfig(params), - }, + }), actions: telegramMessageActions, setup: telegramSetupAdapter, outbound: { @@ -516,34 +499,22 @@ export const telegramPlugin: ChannelPlugin { - const result = await sendTelegramOutbound({ - cfg, - to, - text, - accountId, - deps, - replyToId, - threadId, - silent, - }); - return { channel: "telegram", ...result }; - }, - sendMedia: async ({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId, - deps, - replyToId, - threadId, - silent, - }) => { - const result = await sendTelegramOutbound({ + ...createAttachedChannelResultAdapter({ + channel: "telegram", + sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId, silent }) => + await sendTelegramOutbound({ + cfg, + to, + text, + accountId, + deps, + replyToId, + threadId, + silent, + }), + sendMedia: async ({ cfg, to, text, @@ -554,17 +525,28 @@ export const telegramPlugin: ChannelPlugin - await getTelegramRuntime().channel.telegram.sendPollTelegram(to, poll, { - cfg, - accountId: accountId ?? undefined, - messageThreadId: parseTelegramThreadId(threadId), - silent: silent ?? undefined, - isAnonymous: isAnonymous ?? undefined, - }), + }) => + await sendTelegramOutbound({ + cfg, + to, + text, + mediaUrl, + mediaLocalRoots, + accountId, + deps, + replyToId, + threadId, + silent, + }), + sendPoll: async ({ cfg, to, poll, accountId, threadId, silent, isAnonymous }) => + await getTelegramRuntime().channel.telegram.sendPollTelegram(to, poll, { + cfg, + accountId: accountId ?? undefined, + messageThreadId: parseTelegramThreadId(threadId), + silent: silent ?? undefined, + isAnonymous: isAnonymous ?? undefined, + }), + }), }, status: { defaultRuntime: { diff --git a/extensions/telegram/src/directory-config.ts b/extensions/telegram/src/directory-config.ts index af515a29379..6cb51ab686e 100644 --- a/extensions/telegram/src/directory-config.ts +++ b/extensions/telegram/src/directory-config.ts @@ -1,24 +1,20 @@ import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; import { - applyDirectoryQueryAndLimit, - collectNormalizedDirectoryIds, - listDirectoryGroupEntriesFromMapKeys, - toDirectoryEntries, + listInspectedDirectoryEntriesFromSources, type DirectoryConfigParams, } from "openclaw/plugin-sdk/directory-runtime"; import { inspectTelegramAccount, type InspectedTelegramAccount } from "../api.js"; export async function listTelegramDirectoryPeersFromConfig(params: DirectoryConfigParams) { - const account: InspectedTelegramAccount = inspectTelegramAccount({ - cfg: params.cfg, - accountId: params.accountId, - }); - if (!account.config) { - return []; - } - - const ids = collectNormalizedDirectoryIds({ - sources: [mapAllowFromEntries(account.config.allowFrom), Object.keys(account.config.dms ?? {})], + return listInspectedDirectoryEntriesFromSources({ + ...params, + kind: "user", + inspectAccount: (cfg, accountId) => + inspectTelegramAccount({ cfg, accountId }) as InspectedTelegramAccount | null, + resolveSources: (account) => [ + mapAllowFromEntries(account.config.allowFrom), + Object.keys(account.config.dms ?? {}), + ], normalizeId: (entry) => { const trimmed = entry.replace(/^(telegram|tg):/i, "").trim(); if (!trimmed) { @@ -30,20 +26,15 @@ export async function listTelegramDirectoryPeersFromConfig(params: DirectoryConf return trimmed.startsWith("@") ? trimmed : `@${trimmed}`; }, }); - return toDirectoryEntries("user", applyDirectoryQueryAndLimit(ids, params)); } export async function listTelegramDirectoryGroupsFromConfig(params: DirectoryConfigParams) { - const account: InspectedTelegramAccount = inspectTelegramAccount({ - cfg: params.cfg, - accountId: params.accountId, - }); - if (!account.config) { - return []; - } - return listDirectoryGroupEntriesFromMapKeys({ - groups: account.config.groups, - query: params.query, - limit: params.limit, + return listInspectedDirectoryEntriesFromSources({ + ...params, + kind: "group", + inspectAccount: (cfg, accountId) => + inspectTelegramAccount({ cfg, accountId }) as InspectedTelegramAccount | null, + resolveSources: (account) => [Object.keys(account.config.groups ?? {})], + normalizeId: (entry) => entry.trim() || null, }); } diff --git a/extensions/telegram/src/lane-delivery-text-deliverer.ts b/extensions/telegram/src/lane-delivery-text-deliverer.ts index c99dc52661a..c67a091995e 100644 --- a/extensions/telegram/src/lane-delivery-text-deliverer.ts +++ b/extensions/telegram/src/lane-delivery-text-deliverer.ts @@ -1,3 +1,4 @@ +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { TelegramInlineButtons } from "./button-types.js"; import type { TelegramDraftStream } from "./draft-stream.js"; @@ -459,7 +460,8 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) { allowPreviewUpdateForNonFinal = false, }: DeliverLaneTextParams): Promise => { const lane = params.lanes[laneName]; - const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; + const reply = resolveSendableOutboundReplyParts(payload, { text }); + const hasMedia = reply.hasMedia; const canEditViaPreview = !hasMedia && text.length > 0 && text.length <= params.draftMaxChars && !payload.isError; diff --git a/extensions/telegram/src/outbound-adapter.ts b/extensions/telegram/src/outbound-adapter.ts index 16ef036d93d..b5cb70a2c66 100644 --- a/extensions/telegram/src/outbound-adapter.ts +++ b/extensions/telegram/src/outbound-adapter.ts @@ -1,9 +1,13 @@ import { resolvePayloadMediaUrls, - sendPayloadMediaSequence, + sendPayloadMediaSequenceOrFallback, } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime"; import { resolveOutboundSendDep, type OutboundSendDeps } from "openclaw/plugin-sdk/channel-runtime"; +import { + attachChannelToResult, + createAttachedChannelResultAdapter, +} from "openclaw/plugin-sdk/channel-send-result"; import { resolveInteractiveTextFallback } from "openclaw/plugin-sdk/interactive-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import type { TelegramInlineButtons } from "./button-types.js"; @@ -75,17 +79,16 @@ export async function sendTelegramPayloadMessages(params: { quoteText, }; - if (mediaUrls.length === 0) { - return await params.send(params.to, text, { - ...payloadOpts, - buttons, - }); - } - // Telegram allows reply_markup on media; attach buttons only to the first send. - const finalResult = await sendPayloadMediaSequence({ + return await sendPayloadMediaSequenceOrFallback({ text, mediaUrls, + fallbackResult: { messageId: "unknown", chatId: params.to }, + sendNoMedia: async () => + await params.send(params.to, text, { + ...payloadOpts, + buttons, + }), send: async ({ text, mediaUrl, isFirst }) => await params.send(params.to, text, { ...payloadOpts, @@ -93,7 +96,6 @@ export async function sendTelegramPayloadMessages(params: { ...(isFirst ? { buttons } : {}), }), }); - return finalResult ?? { messageId: "unknown", chatId: params.to }; } export const telegramOutbound: ChannelOutboundAdapter = { @@ -104,46 +106,47 @@ export const telegramOutbound: ChannelOutboundAdapter = { shouldSkipPlainTextSanitization: ({ payload }) => Boolean(payload.channelData), resolveEffectiveTextChunkLimit: ({ fallbackLimit }) => typeof fallbackLimit === "number" ? Math.min(fallbackLimit, 4096) : 4096, - sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId }) => { - const { send, baseOpts } = resolveTelegramSendContext({ + ...createAttachedChannelResultAdapter({ + channel: "telegram", + sendText: async ({ cfg, to, text, accountId, deps, replyToId, threadId }) => { + const { send, baseOpts } = resolveTelegramSendContext({ + cfg, + deps, + accountId, + replyToId, + threadId, + }); + return await send(to, text, { + ...baseOpts, + }); + }, + sendMedia: async ({ cfg, - deps, - accountId, - replyToId, - threadId, - }); - const result = await send(to, text, { - ...baseOpts, - }); - return { channel: "telegram", ...result }; - }, - sendMedia: async ({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId, - deps, - replyToId, - threadId, - forceDocument, - }) => { - const { send, baseOpts } = resolveTelegramSendContext({ - cfg, - deps, - accountId, - replyToId, - threadId, - }); - const result = await send(to, text, { - ...baseOpts, + to, + text, mediaUrl, mediaLocalRoots, - forceDocument: forceDocument ?? false, - }); - return { channel: "telegram", ...result }; - }, + accountId, + deps, + replyToId, + threadId, + forceDocument, + }) => { + const { send, baseOpts } = resolveTelegramSendContext({ + cfg, + deps, + accountId, + replyToId, + threadId, + }); + return await send(to, text, { + ...baseOpts, + mediaUrl, + mediaLocalRoots, + forceDocument: forceDocument ?? false, + }); + }, + }), sendPayload: async ({ cfg, to, @@ -172,6 +175,6 @@ export const telegramOutbound: ChannelOutboundAdapter = { forceDocument: forceDocument ?? false, }, }); - return { channel: "telegram", ...result }; + return attachChannelToResult("telegram", result); }, }; diff --git a/extensions/tlon/api.ts b/extensions/tlon/api.ts index bccfa85fbac..5364c68f07d 100644 --- a/extensions/tlon/api.ts +++ b/extensions/tlon/api.ts @@ -1,2 +1 @@ export * from "openclaw/plugin-sdk/tlon"; -export * from "./setup-api.js"; diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index 865ead9ab46..89e4a235b60 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -1,5 +1,9 @@ import { createHybridChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers"; -import type { ChannelAccountSnapshot, ChannelPlugin } from "openclaw/plugin-sdk/channel-runtime"; +import { + createRuntimeOutboundDelegates, + type ChannelAccountSnapshot, + type ChannelPlugin, +} from "openclaw/plugin-sdk/channel-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { tlonChannelConfigSchema } from "./config-schema.js"; @@ -107,14 +111,11 @@ export const tlonPlugin: ChannelPlugin = { deliveryMode: "direct", textChunkLimit: 10000, resolveTarget: ({ to }) => resolveTlonOutboundTarget(to), - sendText: async (params) => - await ( - await loadTlonChannelRuntime() - ).tlonRuntimeOutbound.sendText!(params), - sendMedia: async (params) => - await ( - await loadTlonChannelRuntime() - ).tlonRuntimeOutbound.sendMedia!(params), + ...createRuntimeOutboundDelegates({ + getRuntime: loadTlonChannelRuntime, + sendText: { resolve: (runtime) => runtime.tlonRuntimeOutbound.sendText }, + sendMedia: { resolve: (runtime) => runtime.tlonRuntimeOutbound.sendMedia }, + }), }, status: { defaultRuntime: { diff --git a/extensions/whatsapp/src/auto-reply/deliver-reply.ts b/extensions/whatsapp/src/auto-reply/deliver-reply.ts index 6d9d8b541ae..92501c46fdd 100644 --- a/extensions/whatsapp/src/auto-reply/deliver-reply.ts +++ b/extensions/whatsapp/src/auto-reply/deliver-reply.ts @@ -1,4 +1,8 @@ import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +import { + resolveOutboundMediaUrls, + sendMediaWithLeadingCaption, +} from "openclaw/plugin-sdk/reply-payload"; import { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/reply-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; @@ -52,11 +56,7 @@ export async function deliverWebReply(params: { convertMarkdownTables(replyResult.text || "", tableMode), ); const textChunks = chunkMarkdownTextWithMode(convertedText, textLimit, chunkMode); - const mediaList = replyResult.mediaUrls?.length - ? replyResult.mediaUrls - : replyResult.mediaUrl - ? [replyResult.mediaUrl] - : []; + const mediaList = resolveOutboundMediaUrls(replyResult); const sendWithRetry = async (fn: () => Promise, label: string, maxAttempts = 3) => { let lastErr: unknown; @@ -114,9 +114,11 @@ export async function deliverWebReply(params: { const remainingText = [...textChunks]; // Media (with optional caption on first item) - for (const [index, mediaUrl] of mediaList.entries()) { - const caption = index === 0 ? remainingText.shift() || undefined : undefined; - try { + const leadingCaption = remainingText.shift() || ""; + await sendMediaWithLeadingCaption({ + mediaUrls: mediaList, + caption: leadingCaption, + send: async ({ mediaUrl, caption }) => { const media = await loadWebMedia(mediaUrl, { maxBytes: maxMediaBytes, localRoots: params.mediaLocalRoots, @@ -189,21 +191,24 @@ export async function deliverWebReply(params: { }, "auto-reply sent (media)", ); - } catch (err) { - whatsappOutboundLog.error(`Failed sending web media to ${msg.from}: ${formatError(err)}`); - replyLogger.warn({ err, mediaUrl }, "failed to send web media reply"); - if (index === 0) { - const warning = - err instanceof Error ? `⚠️ Media failed: ${err.message}` : "⚠️ Media failed."; - const fallbackTextParts = [remainingText.shift() ?? caption ?? "", warning].filter(Boolean); - const fallbackText = fallbackTextParts.join("\n"); - if (fallbackText) { - whatsappOutboundLog.warn(`Media skipped; sent text-only to ${msg.from}`); - await msg.reply(fallbackText); - } + }, + onError: async ({ error, mediaUrl, caption, isFirst }) => { + whatsappOutboundLog.error(`Failed sending web media to ${msg.from}: ${formatError(error)}`); + replyLogger.warn({ err: error, mediaUrl }, "failed to send web media reply"); + if (!isFirst) { + return; } - } - } + const warning = + error instanceof Error ? `⚠️ Media failed: ${error.message}` : "⚠️ Media failed."; + const fallbackTextParts = [remainingText.shift() ?? caption ?? "", warning].filter(Boolean); + const fallbackText = fallbackTextParts.join("\n"); + if (!fallbackText) { + return; + } + whatsappOutboundLog.warn(`Media skipped; sent text-only to ${msg.from}`); + await msg.reply(fallbackText); + }, + }); // Remaining text chunks after media for (const chunk of remainingText) { diff --git a/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts b/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts index 7aa35705f43..8fb27a39fe4 100644 --- a/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts +++ b/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts @@ -9,6 +9,10 @@ import { } from "openclaw/plugin-sdk/config-runtime"; import { emitHeartbeatEvent, resolveIndicatorType } from "openclaw/plugin-sdk/infra-runtime"; import { resolveHeartbeatVisibility } from "openclaw/plugin-sdk/infra-runtime"; +import { + hasOutboundReplyContent, + resolveSendableOutboundReplyParts, +} from "openclaw/plugin-sdk/reply-payload"; import { resolveHeartbeatReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, @@ -178,10 +182,7 @@ export async function runWebHeartbeatOnce(opts: { ); const replyPayload = resolveHeartbeatReplyPayload(replyResult); - if ( - !replyPayload || - (!replyPayload.text && !replyPayload.mediaUrl && !replyPayload.mediaUrls?.length) - ) { + if (!replyPayload || !hasOutboundReplyContent(replyPayload)) { heartbeatLogger.info( { to: redactedTo, @@ -201,7 +202,8 @@ export async function runWebHeartbeatOnce(opts: { return; } - const hasMedia = Boolean(replyPayload.mediaUrl || (replyPayload.mediaUrls?.length ?? 0) > 0); + const reply = resolveSendableOutboundReplyParts(replyPayload); + const hasMedia = reply.hasMedia; const ackMaxChars = Math.max( 0, cfg.agents?.defaults?.heartbeat?.ackMaxChars ?? DEFAULT_HEARTBEAT_ACK_MAX_CHARS, @@ -250,7 +252,7 @@ export async function runWebHeartbeatOnce(opts: { ); } - const finalText = stripped.text || replyPayload.text || ""; + const finalText = stripped.text || reply.text; // Check if alerts are disabled for WhatsApp if (!visibility.showAlerts) { diff --git a/extensions/whatsapp/src/auto-reply/monitor/process-message.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.ts index beaa564fe28..5db9cb31d0a 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/process-message.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.ts @@ -6,6 +6,7 @@ import type { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; import { recordSessionMetaFromInbound } from "openclaw/plugin-sdk/config-runtime"; import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime"; +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime"; import { shouldComputeCommandAuthorized } from "openclaw/plugin-sdk/reply-runtime"; import { formatInboundEnvelope } from "openclaw/plugin-sdk/reply-runtime"; @@ -429,10 +430,11 @@ export async function processMessage(params: { }); const fromDisplay = params.msg.chatType === "group" ? conversationId : (params.msg.from ?? "unknown"); - const hasMedia = Boolean(payload.mediaUrl || payload.mediaUrls?.length); + const reply = resolveSendableOutboundReplyParts(payload); + const hasMedia = reply.hasMedia; whatsappOutboundLog.info(`Auto-replied to ${fromDisplay}${hasMedia ? " (media)" : ""}`); if (shouldLogVerbose()) { - const preview = payload.text != null ? elide(payload.text, 400) : ""; + const preview = payload.text != null ? elide(reply.text, 400) : ""; whatsappOutboundLog.debug(`Reply body: ${preview}${hasMedia ? " (media)" : ""}`); } }, diff --git a/extensions/whatsapp/src/channel.directory.test.ts b/extensions/whatsapp/src/channel.directory.test.ts new file mode 100644 index 00000000000..3fd58b31d4d --- /dev/null +++ b/extensions/whatsapp/src/channel.directory.test.ts @@ -0,0 +1,62 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/whatsapp"; +import { describe, expect, it } from "vitest"; +import { + createDirectoryTestRuntime, + expectDirectorySurface, +} from "../../../test/helpers/extensions/directory.ts"; +import { whatsappPlugin } from "./channel.js"; + +describe("whatsapp directory", () => { + const runtimeEnv = createDirectoryTestRuntime() as never; + + it("lists peers and groups from config", async () => { + const cfg = { + channels: { + whatsapp: { + authDir: "/tmp/wa-auth", + allowFrom: [ + "whatsapp:+15551230001", + "15551230002@s.whatsapp.net", + "120363999999999999@g.us", + ], + groups: { + "120363111111111111@g.us": {}, + "120363222222222222@g.us": {}, + }, + }, + }, + } as unknown as OpenClawConfig; + + const directory = expectDirectorySurface(whatsappPlugin.directory); + + await expect( + directory.listPeers({ + cfg, + accountId: undefined, + query: undefined, + limit: undefined, + runtime: runtimeEnv, + }), + ).resolves.toEqual( + expect.arrayContaining([ + { kind: "user", id: "+15551230001" }, + { kind: "user", id: "+15551230002" }, + ]), + ); + + await expect( + directory.listGroups({ + cfg, + accountId: undefined, + query: undefined, + limit: undefined, + runtime: runtimeEnv, + }), + ).resolves.toEqual( + expect.arrayContaining([ + { kind: "group", id: "120363111111111111@g.us" }, + { kind: "group", id: "120363222222222222@g.us" }, + ]), + ); + }); +}); diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index 04780f81eda..151cfc60b40 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -1,4 +1,4 @@ -import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit"; +import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit"; // WhatsApp-specific imports from local extension code (moved from src/web/ and src/channels/plugins/) import { resolveWhatsAppAccount, type ResolvedWhatsAppAccount } from "./accounts.js"; import { @@ -67,26 +67,15 @@ export const whatsappPlugin: ChannelPlugin = { pairing: { idLabel: "whatsappSenderId", }, - allowlist: { - supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", - readConfig: ({ cfg, accountId }) => { - const account = resolveWhatsAppAccount({ cfg, accountId }); - return { - dmAllowFrom: (account.allowFrom ?? []).map(String), - groupAllowFrom: (account.groupAllowFrom ?? []).map(String), - dmPolicy: account.dmPolicy, - groupPolicy: account.groupPolicy, - }; - }, - applyConfigEdit: buildAccountScopedAllowlistConfigEditor({ - channelId: "whatsapp", - normalize: ({ values }) => formatWhatsAppConfigAllowFromEntries(values), - resolvePaths: (scope) => ({ - readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]], - writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"], - }), - }), - }, + allowlist: buildDmGroupAccountAllowlistAdapter({ + channelId: "whatsapp", + resolveAccount: ({ cfg, accountId }) => resolveWhatsAppAccount({ cfg, accountId }), + normalize: ({ values }) => formatWhatsAppConfigAllowFromEntries(values), + resolveDmAllowFrom: (account) => account.allowFrom, + resolveGroupAllowFrom: (account) => account.groupAllowFrom, + resolveDmPolicy: (account) => account.dmPolicy, + resolveGroupPolicy: (account) => account.groupPolicy, + }), mentions: { stripRegexes: ({ ctx }) => resolveWhatsAppMentionStripRegexes(ctx), }, diff --git a/extensions/whatsapp/src/directory-config.ts b/extensions/whatsapp/src/directory-config.ts index 1a5fbbff9b0..1915b6fd4da 100644 --- a/extensions/whatsapp/src/directory-config.ts +++ b/extensions/whatsapp/src/directory-config.ts @@ -1,17 +1,16 @@ import { - listDirectoryGroupEntriesFromMapKeys, - listDirectoryUserEntriesFromAllowFrom, + listResolvedDirectoryGroupEntriesFromMapKeys, + listResolvedDirectoryUserEntriesFromAllowFrom, type DirectoryConfigParams, } from "openclaw/plugin-sdk/directory-runtime"; import { resolveWhatsAppAccount } from "./accounts.js"; import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "./normalize.js"; export async function listWhatsAppDirectoryPeersFromConfig(params: DirectoryConfigParams) { - const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.accountId }); - return listDirectoryUserEntriesFromAllowFrom({ - allowFrom: account.allowFrom, - query: params.query, - limit: params.limit, + return listResolvedDirectoryUserEntriesFromAllowFrom({ + ...params, + resolveAccount: (cfg, accountId) => resolveWhatsAppAccount({ cfg, accountId }), + resolveAllowFrom: (account) => account.allowFrom, normalizeId: (entry) => { const normalized = normalizeWhatsAppTarget(entry); if (!normalized || isWhatsAppGroupJid(normalized)) { @@ -23,10 +22,9 @@ export async function listWhatsAppDirectoryPeersFromConfig(params: DirectoryConf } export async function listWhatsAppDirectoryGroupsFromConfig(params: DirectoryConfigParams) { - const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId: params.accountId }); - return listDirectoryGroupEntriesFromMapKeys({ - groups: account.groups, - query: params.query, - limit: params.limit, + return listResolvedDirectoryGroupEntriesFromMapKeys({ + ...params, + resolveAccount: (cfg, accountId) => resolveWhatsAppAccount({ cfg, accountId }), + resolveGroups: (account) => account.groups, }); } diff --git a/extensions/whatsapp/src/outbound-adapter.poll.test.ts b/extensions/whatsapp/src/outbound-adapter.poll.test.ts index 9b16610722f..c5c3e97926b 100644 --- a/extensions/whatsapp/src/outbound-adapter.poll.test.ts +++ b/extensions/whatsapp/src/outbound-adapter.poll.test.ts @@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../../../src/config/config.js"; const hoisted = vi.hoisted(() => ({ sendPollWhatsApp: vi.fn(async () => ({ messageId: "poll-1", toJid: "1555@s.whatsapp.net" })), + sendReactionWhatsApp: vi.fn(async () => undefined), })); vi.mock("../../../src/globals.js", () => ({ @@ -14,6 +15,7 @@ vi.mock("./send.js", async (importOriginal) => { return { ...actual, sendPollWhatsApp: hoisted.sendPollWhatsApp, + sendReactionWhatsApp: hoisted.sendReactionWhatsApp, }; }); @@ -40,6 +42,10 @@ describe("whatsappOutbound sendPoll", () => { accountId: "work", cfg, }); - expect(result).toEqual({ messageId: "poll-1", toJid: "1555@s.whatsapp.net" }); + expect(result).toEqual({ + channel: "whatsapp", + messageId: "poll-1", + toJid: "1555@s.whatsapp.net", + }); }); }); diff --git a/extensions/whatsapp/src/outbound-adapter.ts b/extensions/whatsapp/src/outbound-adapter.ts index ffc0306d80b..4800e2ded43 100644 --- a/extensions/whatsapp/src/outbound-adapter.ts +++ b/extensions/whatsapp/src/outbound-adapter.ts @@ -1,6 +1,11 @@ import { sendTextMediaPayload } from "openclaw/plugin-sdk/channel-runtime"; import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime"; import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime"; +import { + createAttachedChannelResultAdapter, + createEmptyChannelResult, +} from "openclaw/plugin-sdk/channel-send-result"; +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { chunkText } from "openclaw/plugin-sdk/reply-runtime"; import { shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; import { resolveWhatsAppOutboundTarget } from "./runtime-api.js"; @@ -20,9 +25,9 @@ export const whatsappOutbound: ChannelOutboundAdapter = { resolveWhatsAppOutboundTarget({ to, allowFrom, mode }), sendPayload: async (ctx) => { const text = trimLeadingWhitespace(ctx.payload.text); - const hasMedia = Boolean(ctx.payload.mediaUrl) || (ctx.payload.mediaUrls?.length ?? 0) > 0; + const hasMedia = resolveSendableOutboundReplyParts(ctx.payload).hasMedia; if (!text && !hasMedia) { - return { channel: "whatsapp", messageId: "" }; + return createEmptyChannelResult("whatsapp"); } return await sendTextMediaPayload({ channel: "whatsapp", @@ -36,41 +41,51 @@ export const whatsappOutbound: ChannelOutboundAdapter = { adapter: whatsappOutbound, }); }, - sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => { - const normalizedText = trimLeadingWhitespace(text); - if (!normalizedText) { - return { channel: "whatsapp", messageId: "" }; - } - const send = - resolveOutboundSendDep(deps, "whatsapp") ?? - (await import("./send.js")).sendMessageWhatsApp; - const result = await send(to, normalizedText, { - verbose: false, - cfg, - accountId: accountId ?? undefined, - gifPlayback, - }); - return { channel: "whatsapp", ...result }; - }, - sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps, gifPlayback }) => { - const normalizedText = trimLeadingWhitespace(text); - const send = - resolveOutboundSendDep(deps, "whatsapp") ?? - (await import("./send.js")).sendMessageWhatsApp; - const result = await send(to, normalizedText, { - verbose: false, + ...createAttachedChannelResultAdapter({ + channel: "whatsapp", + sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => { + const normalizedText = trimLeadingWhitespace(text); + if (!normalizedText) { + return createEmptyChannelResult("whatsapp"); + } + const send = + resolveOutboundSendDep(deps, "whatsapp") ?? + (await import("./send.js")).sendMessageWhatsApp; + return await send(to, normalizedText, { + verbose: false, + cfg, + accountId: accountId ?? undefined, + gifPlayback, + }); + }, + sendMedia: async ({ cfg, + to, + text, mediaUrl, mediaLocalRoots, - accountId: accountId ?? undefined, + accountId, + deps, gifPlayback, - }); - return { channel: "whatsapp", ...result }; - }, - sendPoll: async ({ cfg, to, poll, accountId }) => - await sendPollWhatsApp(to, poll, { - verbose: shouldLogVerbose(), - accountId: accountId ?? undefined, - cfg, - }), + }) => { + const normalizedText = trimLeadingWhitespace(text); + const send = + resolveOutboundSendDep(deps, "whatsapp") ?? + (await import("./send.js")).sendMessageWhatsApp; + return await send(to, normalizedText, { + verbose: false, + cfg, + mediaUrl, + mediaLocalRoots, + accountId: accountId ?? undefined, + gifPlayback, + }); + }, + sendPoll: async ({ cfg, to, poll, accountId }) => + await sendPollWhatsApp(to, poll, { + verbose: shouldLogVerbose(), + accountId: accountId ?? undefined, + cfg, + }), + }), }; diff --git a/extensions/whatsapp/src/shared.ts b/extensions/whatsapp/src/shared.ts index b9b86161b3d..3e241c9f94c 100644 --- a/extensions/whatsapp/src/shared.ts +++ b/extensions/whatsapp/src/shared.ts @@ -1,9 +1,8 @@ import { - collectAllowlistProviderGroupPolicyWarnings, - collectOpenGroupPolicyRouteAllowlistWarnings, createScopedChannelConfigAdapter, createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; +import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy"; import { createChannelPluginBase } from "openclaw/plugin-sdk/core"; import { createDelegatedSetupWizardProxy } from "openclaw/plugin-sdk/setup"; import { @@ -93,21 +92,28 @@ export function createWhatsAppPluginBase(params: { setupWizard: NonNullable["setupWizard"]>; setup: NonNullable["setup"]>; isConfigured: NonNullable["config"]>["isConfigured"]; -}): Pick< - ChannelPlugin, - | "id" - | "meta" - | "setupWizard" - | "capabilities" - | "reload" - | "gatewayMethods" - | "configSchema" - | "config" - | "security" - | "setup" - | "groups" -> { - return { +}) { + const collectWhatsAppSecurityWarnings = + createAllowlistProviderRouteAllowlistWarningCollector({ + providerConfigPresent: (cfg) => cfg.channels?.whatsapp !== undefined, + resolveGroupPolicy: (account) => account.groupPolicy, + resolveRouteAllowlistConfigured: (account) => + Boolean(account.groups) && Object.keys(account.groups ?? {}).length > 0, + restrictSenders: { + surface: "WhatsApp groups", + openScope: "any member in allowed groups", + groupPolicyPath: "channels.whatsapp.groupPolicy", + groupAllowFromPath: "channels.whatsapp.groupAllowFrom", + }, + noRouteAllowlist: { + surface: "WhatsApp groups", + routeAllowlistPath: "channels.whatsapp.groups", + routeScope: "group", + groupPolicyPath: "channels.whatsapp.groupPolicy", + groupAllowFromPath: "channels.whatsapp.groupAllowFrom", + }, + }); + const base = createChannelPluginBase({ id: WHATSAPP_CHANNEL, meta: { ...getChatChannelMeta(WHATSAPP_CHANNEL), @@ -144,35 +150,33 @@ export function createWhatsAppPluginBase(params: { }, security: { resolveDmPolicy: whatsappResolveDmPolicy, - collectWarnings: ({ account, cfg }) => { - const groupAllowlistConfigured = - Boolean(account.groups) && Object.keys(account.groups ?? {}).length > 0; - return collectAllowlistProviderGroupPolicyWarnings({ - cfg, - providerConfigPresent: cfg.channels?.whatsapp !== undefined, - configuredGroupPolicy: account.groupPolicy, - collect: (groupPolicy) => - collectOpenGroupPolicyRouteAllowlistWarnings({ - groupPolicy, - routeAllowlistConfigured: groupAllowlistConfigured, - restrictSenders: { - surface: "WhatsApp groups", - openScope: "any member in allowed groups", - groupPolicyPath: "channels.whatsapp.groupPolicy", - groupAllowFromPath: "channels.whatsapp.groupAllowFrom", - }, - noRouteAllowlist: { - surface: "WhatsApp groups", - routeAllowlistPath: "channels.whatsapp.groups", - routeScope: "group", - groupPolicyPath: "channels.whatsapp.groupPolicy", - groupAllowFromPath: "channels.whatsapp.groupAllowFrom", - }, - }), - }); - }, + collectWarnings: collectWhatsAppSecurityWarnings, }, setup: params.setup, groups: params.groups, - }; + }); + return { + ...base, + setupWizard: base.setupWizard!, + capabilities: base.capabilities!, + reload: base.reload!, + gatewayMethods: base.gatewayMethods!, + configSchema: base.configSchema!, + config: base.config!, + security: base.security!, + groups: base.groups!, + } satisfies Pick< + ChannelPlugin, + | "id" + | "meta" + | "setupWizard" + | "capabilities" + | "reload" + | "gatewayMethods" + | "configSchema" + | "config" + | "security" + | "setup" + | "groups" + >; } diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index 5434b3e144e..b8d11b50937 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -6,8 +6,15 @@ import { import { buildOpenGroupPolicyRestrictSendersWarning, buildOpenGroupPolicyWarning, - collectOpenProviderGroupPolicyWarnings, + createOpenProviderGroupPolicyWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; +import { + createChannelDirectoryAdapter, + createEmptyChannelResult, + createRawChannelSendResultAdapter, + createStaticReplyToModeResolver, +} from "openclaw/plugin-sdk/channel-runtime"; +import { listResolvedDirectoryUserEntriesFromAllowFrom } from "openclaw/plugin-sdk/directory-runtime"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { listZaloAccountIds, @@ -21,7 +28,6 @@ import { buildBaseAccountStatusSnapshot, buildChannelConfigSchema, buildTokenChannelStatusSummary, - buildChannelSendResult, DEFAULT_ACCOUNT_ID, chunkTextForOutbound, formatAllowFromLowercase, @@ -78,6 +84,41 @@ const resolveZaloDmPolicy = createScopedDmSecurityResolver( normalizeEntry: (raw) => raw.replace(/^(zalo|zl):/i, ""), }); +const collectZaloSecurityWarnings = createOpenProviderGroupPolicyWarningCollector<{ + cfg: OpenClawConfig; + account: ResolvedZaloAccount; +}>({ + providerConfigPresent: (cfg) => cfg.channels?.zalo !== undefined, + resolveGroupPolicy: ({ account }) => account.config.groupPolicy, + collect: ({ account, groupPolicy }) => { + if (groupPolicy !== "open") { + return []; + } + const explicitGroupAllowFrom = mapAllowFromEntries(account.config.groupAllowFrom); + const dmAllowFrom = mapAllowFromEntries(account.config.allowFrom); + const effectiveAllowFrom = + explicitGroupAllowFrom.length > 0 ? explicitGroupAllowFrom : dmAllowFrom; + if (effectiveAllowFrom.length > 0) { + return [ + buildOpenGroupPolicyRestrictSendersWarning({ + surface: "Zalo groups", + openScope: "any member", + groupPolicyPath: "channels.zalo.groupPolicy", + groupAllowFromPath: "channels.zalo.groupAllowFrom", + }), + ]; + } + return [ + buildOpenGroupPolicyWarning({ + surface: "Zalo groups", + openBehavior: + "with no groupAllowFrom/allowFrom allowlist; any member can trigger (mention-gated)", + remediation: 'Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom', + }), + ]; + }, +}); + export const zaloPlugin: ChannelPlugin = { id: "zalo", meta, @@ -107,47 +148,13 @@ export const zaloPlugin: ChannelPlugin = { }, security: { resolveDmPolicy: resolveZaloDmPolicy, - collectWarnings: ({ account, cfg }) => { - return collectOpenProviderGroupPolicyWarnings({ - cfg, - providerConfigPresent: cfg.channels?.zalo !== undefined, - configuredGroupPolicy: account.config.groupPolicy, - collect: (groupPolicy) => { - if (groupPolicy !== "open") { - return []; - } - const explicitGroupAllowFrom = mapAllowFromEntries(account.config.groupAllowFrom); - const dmAllowFrom = mapAllowFromEntries(account.config.allowFrom); - const effectiveAllowFrom = - explicitGroupAllowFrom.length > 0 ? explicitGroupAllowFrom : dmAllowFrom; - if (effectiveAllowFrom.length > 0) { - return [ - buildOpenGroupPolicyRestrictSendersWarning({ - surface: "Zalo groups", - openScope: "any member", - groupPolicyPath: "channels.zalo.groupPolicy", - groupAllowFromPath: "channels.zalo.groupAllowFrom", - }), - ]; - } - return [ - buildOpenGroupPolicyWarning({ - surface: "Zalo groups", - openBehavior: - "with no groupAllowFrom/allowFrom allowlist; any member can trigger (mention-gated)", - remediation: - 'Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom', - }), - ]; - }, - }); - }, + collectWarnings: collectZaloSecurityWarnings, }, groups: { resolveRequireMention: () => true, }, threading: { - resolveReplyToMode: () => "off", + resolveReplyToMode: createStaticReplyToModeResolver("off"), }, actions: zaloMessageActions, messaging: { @@ -158,19 +165,16 @@ export const zaloPlugin: ChannelPlugin = { hint: "", }, }, - directory: { - self: async () => null, - listPeers: async ({ cfg, accountId, query, limit }) => { - const account = resolveZaloAccount({ cfg: cfg, accountId }); - return listDirectoryUserEntriesFromAllowFrom({ - allowFrom: account.config.allowFrom, - query, - limit, + directory: createChannelDirectoryAdapter({ + listPeers: async (params) => + listResolvedDirectoryUserEntriesFromAllowFrom({ + ...params, + resolveAccount: (cfg, accountId) => resolveZaloAccount({ cfg, accountId }), + resolveAllowFrom: (account) => account.config.allowFrom, normalizeId: (entry) => entry.replace(/^(zalo|zl):/i, ""), - }); - }, + }), listGroups: async () => [], - }, + }), pairing: { idLabel: "zaloUserId", normalizeAllowEntry: (entry) => entry.replace(/^(zalo|zl):/i, ""), @@ -189,31 +193,30 @@ export const zaloPlugin: ChannelPlugin = { chunker: zaloPlugin.outbound!.chunker, sendText: (nextCtx) => zaloPlugin.outbound!.sendText!(nextCtx), sendMedia: (nextCtx) => zaloPlugin.outbound!.sendMedia!(nextCtx), - emptyResult: { channel: "zalo", messageId: "" }, + emptyResult: createEmptyChannelResult("zalo"), }), - sendText: async ({ to, text, accountId, cfg }) => { - const result = await ( - await loadZaloChannelRuntime() - ).sendZaloText({ - to, - text, - accountId: accountId ?? undefined, - cfg: cfg, - }); - return buildChannelSendResult("zalo", result); - }, - sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => { - const result = await ( - await loadZaloChannelRuntime() - ).sendZaloText({ - to, - text, - accountId: accountId ?? undefined, - mediaUrl, - cfg: cfg, - }); - return buildChannelSendResult("zalo", result); - }, + ...createRawChannelSendResultAdapter({ + channel: "zalo", + sendText: async ({ to, text, accountId, cfg }) => + await ( + await loadZaloChannelRuntime() + ).sendZaloText({ + to, + text, + accountId: accountId ?? undefined, + cfg: cfg, + }), + sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => + await ( + await loadZaloChannelRuntime() + ).sendZaloText({ + to, + text, + accountId: accountId ?? undefined, + mediaUrl, + cfg: cfg, + }), + }), }, status: { defaultRuntime: { diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index 8452fb661e2..b21476fbf8f 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -1,4 +1,5 @@ import type { IncomingMessage, ServerResponse } from "node:http"; +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import type { ResolvedZaloAccount } from "./accounts.js"; import { ZaloApiError, @@ -32,15 +33,14 @@ import { createTypingCallbacks, createScopedPairingAccess, createReplyPrefixOptions, + deliverTextOrMediaReply, issuePairingChallenge, - logTypingFailure, - resolveDirectDmAuthorizationOutcome, - resolveSenderCommandAuthorizationWithRuntime, - resolveOutboundMediaUrls, - resolveDefaultGroupPolicy, - resolveInboundRouteEnvelopeBuilderWithRuntime, - sendMediaWithLeadingCaption, resolveWebhookPath, + logTypingFailure, + resolveDefaultGroupPolicy, + resolveDirectDmAuthorizationOutcome, + resolveInboundRouteEnvelopeBuilderWithRuntime, + resolveSenderCommandAuthorizationWithRuntime, waitForAbortSignal, warnMissingProviderGroupPolicyFallbackOnce, } from "./runtime-api.js"; @@ -580,34 +580,31 @@ async function deliverZaloReply(params: { }): Promise { const { payload, token, chatId, runtime, core, config, accountId, statusSink, fetcher } = params; const tableMode = params.tableMode ?? "code"; - const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); - const sentMedia = await sendMediaWithLeadingCaption({ - mediaUrls: resolveOutboundMediaUrls(payload), - caption: text, - send: async ({ mediaUrl, caption }) => { - await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher); - statusSink?.({ lastOutboundAt: Date.now() }); - }, - onError: (error) => { - runtime.error?.(`Zalo photo send failed: ${String(error)}`); - }, + const reply = resolveSendableOutboundReplyParts(payload, { + text: core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode), }); - if (sentMedia) { - return; - } - - if (text) { - const chunkMode = core.channel.text.resolveChunkMode(config, "zalo", accountId); - const chunks = core.channel.text.chunkMarkdownTextWithMode(text, ZALO_TEXT_LIMIT, chunkMode); - for (const chunk of chunks) { + const chunkMode = core.channel.text.resolveChunkMode(config, "zalo", accountId); + await deliverTextOrMediaReply({ + payload, + text: reply.text, + chunkText: (value) => + core.channel.text.chunkMarkdownTextWithMode(value, ZALO_TEXT_LIMIT, chunkMode), + sendText: async (chunk) => { try { await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher); statusSink?.({ lastOutboundAt: Date.now() }); } catch (err) { runtime.error?.(`Zalo message send failed: ${String(err)}`); } - } - } + }, + sendMedia: async ({ mediaUrl, caption }) => { + await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher); + statusSink?.({ lastOutboundAt: Date.now() }); + }, + onMediaError: (error) => { + runtime.error?.(`Zalo photo send failed: ${String(error)}`); + }, + }); } export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise { diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index c1c90affe9c..b6cf6111580 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -1,5 +1,12 @@ import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers"; import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle"; +import { + createEmptyChannelResult, + createPairingPrefixStripper, + createRawChannelSendResultAdapter, + createStaticReplyToModeResolver, + createTextPairingAdapter, +} from "openclaw/plugin-sdk/channel-runtime"; import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js"; import type { ChannelAccountSnapshot, @@ -11,7 +18,6 @@ import type { GroupToolPolicyConfig, } from "../runtime-api.js"; import { - buildChannelSendResult, buildBaseAccountStatusSnapshot, DEFAULT_ACCOUNT_ID, isDangerousNameMatchingEnabled, @@ -308,7 +314,7 @@ export const zalouserPlugin: ChannelPlugin = { resolveToolPolicy: resolveZalouserGroupToolPolicy, }, threading: { - resolveReplyToMode: () => "off", + resolveReplyToMode: createStaticReplyToModeResolver("off"), }, actions: zalouserMessageActions, messaging: { @@ -431,20 +437,21 @@ export const zalouserPlugin: ChannelPlugin = { return results; }, }, - pairing: { + pairing: createTextPairingAdapter({ idLabel: "zalouserUserId", - normalizeAllowEntry: (entry) => entry.replace(/^(zalouser|zlu):/i, ""), - notifyApproval: async ({ cfg, id }) => { + message: "Your pairing request has been approved.", + normalizeAllowEntry: createPairingPrefixStripper(/^(zalouser|zlu):/i), + notify: async ({ cfg, id, message }) => { const account = resolveZalouserAccountSync({ cfg: cfg }); const authenticated = await checkZcaAuthenticated(account.profile); if (!authenticated) { throw new Error("Zalouser not authenticated"); } - await sendMessageZalouser(id, "Your pairing request has been approved.", { + await sendMessageZalouser(id, message, { profile: account.profile, }); }, - }, + }), auth: { login: async ({ cfg, accountId, runtime }) => { const account = resolveZalouserAccountSync({ @@ -488,34 +495,35 @@ export const zalouserPlugin: ChannelPlugin = { ctx, sendText: (nextCtx) => zalouserPlugin.outbound!.sendText!(nextCtx), sendMedia: (nextCtx) => zalouserPlugin.outbound!.sendMedia!(nextCtx), - emptyResult: { channel: "zalouser", messageId: "" }, + emptyResult: createEmptyChannelResult("zalouser"), }), - sendText: async ({ to, text, accountId, cfg }) => { - const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); - const target = parseZalouserOutboundTarget(to); - const result = await sendMessageZalouser(target.threadId, text, { - profile: account.profile, - isGroup: target.isGroup, - textMode: "markdown", - textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId), - textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId), - }); - return buildChannelSendResult("zalouser", result); - }, - sendMedia: async ({ to, text, mediaUrl, accountId, cfg, mediaLocalRoots }) => { - const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); - const target = parseZalouserOutboundTarget(to); - const result = await sendMessageZalouser(target.threadId, text, { - profile: account.profile, - isGroup: target.isGroup, - mediaUrl, - mediaLocalRoots, - textMode: "markdown", - textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId), - textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId), - }); - return buildChannelSendResult("zalouser", result); - }, + ...createRawChannelSendResultAdapter({ + channel: "zalouser", + sendText: async ({ to, text, accountId, cfg }) => { + const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); + const target = parseZalouserOutboundTarget(to); + return await sendMessageZalouser(target.threadId, text, { + profile: account.profile, + isGroup: target.isGroup, + textMode: "markdown", + textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId), + textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId), + }); + }, + sendMedia: async ({ to, text, mediaUrl, accountId, cfg, mediaLocalRoots }) => { + const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); + const target = parseZalouserOutboundTarget(to); + return await sendMessageZalouser(target.threadId, text, { + profile: account.profile, + isGroup: target.isGroup, + mediaUrl, + mediaLocalRoots, + textMode: "markdown", + textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId), + textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId), + }); + }, + }), }, status: { defaultRuntime: { diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index 5ae729c703e..7f455d93166 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -21,17 +21,17 @@ import { createTypingCallbacks, createScopedPairingAccess, createReplyPrefixOptions, + deliverTextOrMediaReply, evaluateGroupRouteAccessForPolicy, isDangerousNameMatchingEnabled, issuePairingChallenge, - resolveOutboundMediaUrls, mergeAllowlist, resolveMentionGatingWithBypass, resolveOpenProviderRuntimeGroupPolicy, + resolveSendableOutboundReplyParts, resolveDefaultGroupPolicy, resolveSenderCommandAuthorization, resolveSenderScopedGroupPolicy, - sendMediaWithLeadingCaption, summarizeMapping, warnMissingProviderGroupPolicyFallbackOnce, } from "../runtime-api.js"; @@ -707,16 +707,31 @@ async function deliverZalouserReply(params: { const { payload, profile, chatId, isGroup, runtime, core, config, accountId, statusSink } = params; const tableMode = params.tableMode ?? "code"; - const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); + const reply = resolveSendableOutboundReplyParts(payload, { + text: core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode), + }); const chunkMode = core.channel.text.resolveChunkMode(config, "zalouser", accountId); const textChunkLimit = core.channel.text.resolveTextChunkLimit(config, "zalouser", accountId, { fallbackLimit: ZALOUSER_TEXT_LIMIT, }); - - const sentMedia = await sendMediaWithLeadingCaption({ - mediaUrls: resolveOutboundMediaUrls(payload), - caption: text, - send: async ({ mediaUrl, caption }) => { + await deliverTextOrMediaReply({ + payload, + text: reply.text, + sendText: async (chunk) => { + try { + await sendMessageZalouser(chatId, chunk, { + profile, + isGroup, + textMode: "markdown", + textChunkMode: chunkMode, + textChunkLimit, + }); + statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + runtime.error(`Zalouser message send failed: ${String(err)}`); + } + }, + sendMedia: async ({ mediaUrl, caption }) => { logVerbose(core, runtime, `Sending media to ${chatId}`); await sendMessageZalouser(chatId, caption ?? "", { profile, @@ -728,28 +743,10 @@ async function deliverZalouserReply(params: { }); statusSink?.({ lastOutboundAt: Date.now() }); }, - onError: (error) => { + onMediaError: (error) => { runtime.error(`Zalouser media send failed: ${String(error)}`); }, }); - if (sentMedia) { - return; - } - - if (text) { - try { - await sendMessageZalouser(chatId, text, { - profile, - isGroup, - textMode: "markdown", - textChunkMode: chunkMode, - textChunkLimit, - }); - statusSink?.({ lastOutboundAt: Date.now() }); - } catch (err) { - runtime.error(`Zalouser message send failed: ${String(err)}`); - } - } } export async function monitorZalouserProvider( diff --git a/package.json b/package.json index 6a717b8b1bf..67bc2fbde55 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,10 @@ "types": "./dist/plugin-sdk/reply-runtime.d.ts", "default": "./dist/plugin-sdk/reply-runtime.js" }, + "./plugin-sdk/reply-payload": { + "types": "./dist/plugin-sdk/reply-payload.d.ts", + "default": "./dist/plugin-sdk/reply-payload.js" + }, "./plugin-sdk/channel-runtime": { "types": "./dist/plugin-sdk/channel-runtime.d.ts", "default": "./dist/plugin-sdk/channel-runtime.js" @@ -206,6 +210,14 @@ "types": "./dist/plugin-sdk/whatsapp.d.ts", "default": "./dist/plugin-sdk/whatsapp.js" }, + "./plugin-sdk/whatsapp-action-runtime": { + "types": "./dist/plugin-sdk/whatsapp-action-runtime.d.ts", + "default": "./dist/plugin-sdk/whatsapp-action-runtime.js" + }, + "./plugin-sdk/whatsapp-login-qr": { + "types": "./dist/plugin-sdk/whatsapp-login-qr.d.ts", + "default": "./dist/plugin-sdk/whatsapp-login-qr.js" + }, "./plugin-sdk/whatsapp-core": { "types": "./dist/plugin-sdk/whatsapp-core.d.ts", "default": "./dist/plugin-sdk/whatsapp-core.js" @@ -394,6 +406,10 @@ "types": "./dist/plugin-sdk/channel-policy.d.ts", "default": "./dist/plugin-sdk/channel-policy.js" }, + "./plugin-sdk/channel-send-result": { + "types": "./dist/plugin-sdk/channel-send-result.d.ts", + "default": "./dist/plugin-sdk/channel-send-result.js" + }, "./plugin-sdk/group-access": { "types": "./dist/plugin-sdk/group-access.d.ts", "default": "./dist/plugin-sdk/group-access.js" @@ -410,6 +426,10 @@ "types": "./dist/plugin-sdk/keyed-async-queue.d.ts", "default": "./dist/plugin-sdk/keyed-async-queue.js" }, + "./plugin-sdk/windows-spawn": { + "types": "./dist/plugin-sdk/windows-spawn.d.ts", + "default": "./dist/plugin-sdk/windows-spawn.js" + }, "./plugin-sdk/provider-auth": { "types": "./dist/plugin-sdk/provider-auth.d.ts", "default": "./dist/plugin-sdk/provider-auth.js" @@ -486,6 +506,10 @@ "types": "./dist/plugin-sdk/state-paths.d.ts", "default": "./dist/plugin-sdk/state-paths.js" }, + "./plugin-sdk/temp-path": { + "types": "./dist/plugin-sdk/temp-path.d.ts", + "default": "./dist/plugin-sdk/temp-path.js" + }, "./plugin-sdk/tool-send": { "types": "./dist/plugin-sdk/tool-send.d.ts", "default": "./dist/plugin-sdk/tool-send.js" @@ -511,7 +535,8 @@ "build:plugin-sdk:dts": "tsc -p tsconfig.plugin-sdk.dts.json", "build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && pnpm build:plugin-sdk:dts", "canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh", - "check": "pnpm check:host-env-policy:swift && pnpm format:check && pnpm tsgo && pnpm plugin-sdk:check-exports && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:plugins:no-extension-src-imports && pnpm lint:plugins:no-extension-test-core-imports && pnpm lint:plugins:no-extension-imports && pnpm lint:extensions:no-src-outside-plugin-sdk && pnpm lint:extensions:no-plugin-sdk-internal && pnpm lint:web-search-provider-boundaries && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope", + "check": "pnpm check:host-env-policy:swift && pnpm check:bundled-provider-auth-env-vars && pnpm format:check && pnpm tsgo && pnpm plugin-sdk:check-exports && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:plugins:no-extension-src-imports && pnpm lint:plugins:no-extension-test-core-imports && pnpm lint:plugins:no-extension-imports && pnpm lint:extensions:no-src-outside-plugin-sdk && pnpm lint:extensions:no-plugin-sdk-internal && pnpm lint:web-search-provider-boundaries && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope", + "check:bundled-provider-auth-env-vars": "node scripts/generate-bundled-provider-auth-env-vars.mjs --check", "check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-i18n-glossary && pnpm docs:check-links", "check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check", "check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500", @@ -634,6 +659,7 @@ "test:parallels:windows": "bash scripts/e2e/parallels-windows-smoke.sh", "test:perf:budget": "node scripts/test-perf-budget.mjs", "test:perf:hotspots": "node scripts/test-hotspots.mjs", + "test:perf:update-timings": "node scripts/test-update-timings.mjs", "test:sectriage": "pnpm exec vitest run --config vitest.gateway.config.ts && vitest run --config vitest.unit.config.ts --exclude src/daemon/launchd.integration.test.ts --exclude src/process/exec.test.ts", "test:startup:memory": "node scripts/check-cli-startup-memory.mjs", "test:ui": "pnpm lint:ui:no-raw-window-open && pnpm --dir ui test", @@ -701,6 +727,7 @@ "tar": "7.5.11", "tslog": "^4.10.2", "undici": "^7.24.4", + "uuid": "^11.1.0", "ws": "^8.19.0", "yaml": "^2.8.2", "zod": "^4.3.6" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 794fc8116ae..bdf1c4483e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -199,6 +199,9 @@ importers: undici: specifier: ^7.24.4 version: 7.24.4 + uuid: + specifier: ^11.1.0 + version: 11.1.0 ws: specifier: ^8.19.0 version: 8.19.0 @@ -480,6 +483,9 @@ importers: express: specifier: ^5.2.1 version: 5.2.1 + uuid: + specifier: ^11.1.0 + version: 11.1.0 extensions/nextcloud-talk: dependencies: diff --git a/scripts/audit-plugin-sdk-seams.mjs b/scripts/audit-plugin-sdk-seams.mjs index 90250cfaaa1..67e27c036f4 100644 --- a/scripts/audit-plugin-sdk-seams.mjs +++ b/scripts/audit-plugin-sdk-seams.mjs @@ -4,6 +4,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import ts from "typescript"; +import { optionalBundledClusterSet } from "./lib/optional-bundled-clusters.mjs"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const srcRoot = path.join(repoRoot, "src"); @@ -78,6 +79,18 @@ function normalizePluginSdkFamily(resolvedPath) { return relative.replace(/\.(m|c)?[jt]sx?$/, ""); } +function resolveOptionalClusterFromPath(resolvedPath) { + if (resolvedPath.startsWith("extensions/")) { + const cluster = resolvedPath.split("/")[1]; + return optionalBundledClusterSet.has(cluster) ? cluster : null; + } + if (resolvedPath.startsWith("src/plugin-sdk/")) { + const cluster = normalizePluginSdkFamily(resolvedPath).split("/")[0]; + return optionalBundledClusterSet.has(cluster) ? cluster : null; + } + return null; +} + function compareImports(left, right) { return ( left.family.localeCompare(right.family) || @@ -152,6 +165,79 @@ async function collectCorePluginSdkImports() { return inventory.toSorted(compareImports); } +function collectOptionalClusterStaticImports(filePath, sourceFile) { + const entries = []; + + function push(kind, specifierNode, specifier) { + if (!specifier.startsWith(".")) { + return; + } + const resolvedPath = resolveRelativeSpecifier(specifier, filePath); + if (!resolvedPath) { + return; + } + const cluster = resolveOptionalClusterFromPath(resolvedPath); + if (!cluster) { + return; + } + entries.push({ + cluster, + file: normalizePath(filePath), + kind, + line: toLine(sourceFile, specifierNode), + resolvedPath, + specifier, + }); + } + + function visit(node) { + if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { + push("import", node.moduleSpecifier, node.moduleSpecifier.text); + } else if ( + ts.isExportDeclaration(node) && + node.moduleSpecifier && + ts.isStringLiteral(node.moduleSpecifier) + ) { + push("export", node.moduleSpecifier, node.moduleSpecifier.text); + } + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return entries; +} + +async function collectOptionalClusterStaticLeaks() { + const files = await walkCodeFiles(srcRoot); + const inventory = []; + for (const filePath of files) { + const relativePath = normalizePath(filePath); + if (relativePath.startsWith("src/plugin-sdk/")) { + continue; + } + const source = await fs.readFile(filePath, "utf8"); + const scriptKind = + filePath.endsWith(".tsx") || filePath.endsWith(".jsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS; + const sourceFile = ts.createSourceFile( + filePath, + source, + ts.ScriptTarget.Latest, + true, + scriptKind, + ); + inventory.push(...collectOptionalClusterStaticImports(filePath, sourceFile)); + } + return inventory.toSorted((left, right) => { + return ( + left.cluster.localeCompare(right.cluster) || + left.file.localeCompare(right.file) || + left.line - right.line || + left.kind.localeCompare(right.kind) || + left.specifier.localeCompare(right.specifier) + ); + }); +} + function buildDuplicatedSeamFamilies(inventory) { const grouped = new Map(); for (const entry of inventory) { @@ -207,6 +293,30 @@ function buildOverlapFiles(inventory) { }); } +function buildOptionalClusterStaticLeaks(inventory) { + const grouped = new Map(); + for (const entry of inventory) { + const bucket = grouped.get(entry.cluster) ?? []; + bucket.push(entry); + grouped.set(entry.cluster, bucket); + } + + return Object.fromEntries( + [...grouped.entries()] + .map(([cluster, entries]) => [ + cluster, + { + count: entries.length, + files: [...new Set(entries.map((entry) => entry.file))].toSorted(compareStrings), + imports: entries, + }, + ]) + .toSorted((left, right) => { + return right[1].count - left[1].count || left[0].localeCompare(right[0]); + }), + ); +} + function packageClusterMeta(relativePackagePath) { if (relativePackagePath === "ui/package.json") { return { @@ -227,6 +337,35 @@ function packageClusterMeta(relativePackagePath) { }; } +function classifyMissingPackageCluster(params) { + if (optionalBundledClusterSet.has(params.cluster)) { + if (params.cluster === "ui") { + return { + decision: "optional", + reason: + "Private UI workspace. Repo-wide CLI/plugin CI should not require UI-only packages.", + }; + } + if (params.pluginSdkEntries.length > 0) { + return { + decision: "optional", + reason: + "Public plugin-sdk entry exists, but repo-wide default check/build should isolate this optional cluster from the static graph.", + }; + } + return { + decision: "optional", + reason: + "Workspace package is intentionally not mirrored into the root dependency set by default CI policy.", + }; + } + return { + decision: "required", + reason: + "Cluster is statically visible to repo-wide check/build and has not been classified optional.", + }; +} + async function buildMissingPackages() { const rootPackage = JSON.parse(await fs.readFile(path.join(repoRoot, "package.json"), "utf8")); const rootDeps = new Set([ @@ -264,15 +403,27 @@ async function buildMissingPackages() { continue; } const meta = packageClusterMeta(relativePackagePath); + const rootDependencyMirrorAllowlist = ( + pkg.openclaw?.releaseChecks?.rootDependencyMirrorAllowlist ?? [] + ).toSorted(compareStrings); const pluginSdkEntries = [...(pluginSdkReachability.get(meta.cluster) ?? new Set())].toSorted( compareStrings, ); + const classification = classifyMissingPackageCluster({ + cluster: meta.cluster, + pluginSdkEntries, + }); output.push({ cluster: meta.cluster, + decision: classification.decision, + decisionReason: classification.reason, packageName: pkg.name ?? meta.packageName, packagePath: relativePackagePath, npmSpec: pkg.openclaw?.install?.npmSpec ?? null, private: pkg.private === true, + rootDependencyMirrorAllowlist, + mirrorAllowlistMatchesMissing: + missing.join("\n") === rootDependencyMirrorAllowlist.join("\n"), pluginSdkReachability: pluginSdkEntries.length > 0 ? { staticEntryPoints: pluginSdkEntries } : undefined, missing, @@ -286,9 +437,11 @@ async function buildMissingPackages() { await collectWorkspacePackagePaths(); const inventory = await collectCorePluginSdkImports(); +const optionalClusterStaticLeaks = await collectOptionalClusterStaticLeaks(); const result = { duplicatedSeamFamilies: buildDuplicatedSeamFamilies(inventory), overlapFiles: buildOverlapFiles(inventory), + optionalClusterStaticLeaks: buildOptionalClusterStaticLeaks(optionalClusterStaticLeaks), missingPackages: await buildMissingPackages(), }; diff --git a/scripts/check-architecture-smells.mjs b/scripts/check-architecture-smells.mjs new file mode 100644 index 00000000000..c10973355bc --- /dev/null +++ b/scripts/check-architecture-smells.mjs @@ -0,0 +1,272 @@ +#!/usr/bin/env node + +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; +import { + collectTypeScriptFilesFromRoots, + resolveSourceRoots, + runAsScript, + toLine, +} from "./lib/ts-guard-utils.mjs"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const scanRoots = resolveSourceRoots(repoRoot, ["src/plugin-sdk", "src/plugins/runtime"]); + +function normalizePath(filePath) { + return path.relative(repoRoot, filePath).split(path.sep).join("/"); +} + +function compareEntries(left, right) { + return ( + left.category.localeCompare(right.category) || + left.file.localeCompare(right.file) || + left.line - right.line || + left.kind.localeCompare(right.kind) || + left.specifier.localeCompare(right.specifier) || + left.reason.localeCompare(right.reason) + ); +} + +function resolveSpecifier(specifier, importerFile) { + if (specifier.startsWith(".")) { + return normalizePath(path.resolve(path.dirname(importerFile), specifier)); + } + if (specifier.startsWith("/")) { + return normalizePath(specifier); + } + return null; +} + +function pushEntry(entries, entry) { + entries.push(entry); +} + +function scanPluginSdkExtensionFacadeSmells(sourceFile, filePath) { + const relativeFile = normalizePath(filePath); + if (!relativeFile.startsWith("src/plugin-sdk/")) { + return []; + } + + const entries = []; + + function visit(node) { + if ( + ts.isExportDeclaration(node) && + node.moduleSpecifier && + ts.isStringLiteral(node.moduleSpecifier) + ) { + const specifier = node.moduleSpecifier.text; + const resolvedPath = resolveSpecifier(specifier, filePath); + if (resolvedPath?.startsWith("extensions/")) { + pushEntry(entries, { + category: "plugin-sdk-extension-facade", + file: relativeFile, + line: toLine(sourceFile, node.moduleSpecifier), + kind: "export", + specifier, + resolvedPath, + reason: "plugin-sdk public surface re-exports extension-owned implementation", + }); + } + } + + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return entries; +} + +function scanRuntimeTypeImplementationSmells(sourceFile, filePath) { + const relativeFile = normalizePath(filePath); + if (!/^src\/plugins\/runtime\/types(?:-[^/]+)?\.ts$/.test(relativeFile)) { + return []; + } + + const entries = []; + + function visit(node) { + if ( + ts.isImportTypeNode(node) && + ts.isLiteralTypeNode(node.argument) && + ts.isStringLiteral(node.argument.literal) + ) { + const specifier = node.argument.literal.text; + const resolvedPath = resolveSpecifier(specifier, filePath); + if ( + resolvedPath && + (/^src\/plugins\/runtime\/runtime-[^/]+\.ts$/.test(resolvedPath) || + /^extensions\/[^/]+\/runtime-api\.[^/]+$/.test(resolvedPath)) + ) { + pushEntry(entries, { + category: "runtime-type-implementation-edge", + file: relativeFile, + line: toLine(sourceFile, node.argument.literal), + kind: "import-type", + specifier, + resolvedPath, + reason: "runtime type file references implementation shim directly", + }); + } + } + + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return entries; +} + +function scanRuntimeServiceLocatorSmells(sourceFile, filePath) { + const relativeFile = normalizePath(filePath); + if ( + !relativeFile.startsWith("src/plugin-sdk/") && + !relativeFile.startsWith("src/plugins/runtime/") + ) { + return []; + } + + const entries = []; + const exportedNames = new Set(); + const runtimeStoreCalls = []; + const mutableStateNodes = []; + + for (const statement of sourceFile.statements) { + if (ts.isFunctionDeclaration(statement) && statement.name) { + const isExported = statement.modifiers?.some( + (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword, + ); + if (isExported) { + exportedNames.add(statement.name.text); + } + } else if (ts.isVariableStatement(statement)) { + const isExported = statement.modifiers?.some( + (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword, + ); + for (const declaration of statement.declarationList.declarations) { + if (ts.isIdentifier(declaration.name) && isExported) { + exportedNames.add(declaration.name.text); + } + if ( + !isExported && + (statement.declarationList.flags & ts.NodeFlags.Let) !== 0 && + ts.isIdentifier(declaration.name) + ) { + mutableStateNodes.push(declaration.name); + } + } + } + } + + function visit(node) { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === "createPluginRuntimeStore" + ) { + runtimeStoreCalls.push(node.expression); + } + + ts.forEachChild(node, visit); + } + + visit(sourceFile); + + const getterNames = [...exportedNames].filter((name) => /^get[A-Z]/.test(name)); + const setterNames = [...exportedNames].filter((name) => /^set[A-Z]/.test(name)); + + if (runtimeStoreCalls.length > 0 && getterNames.length > 0 && setterNames.length > 0) { + for (const callNode of runtimeStoreCalls) { + pushEntry(entries, { + category: "runtime-service-locator", + file: relativeFile, + line: toLine(sourceFile, callNode), + kind: "runtime-store", + specifier: "createPluginRuntimeStore", + resolvedPath: relativeFile, + reason: `exports paired runtime accessors (${getterNames.join(", ")} / ${setterNames.join(", ")}) over module-global store state`, + }); + } + } + + if (mutableStateNodes.length > 0 && getterNames.length > 0 && setterNames.length > 0) { + for (const identifier of mutableStateNodes) { + pushEntry(entries, { + category: "runtime-service-locator", + file: relativeFile, + line: toLine(sourceFile, identifier), + kind: "mutable-state", + specifier: identifier.text, + resolvedPath: relativeFile, + reason: `module-global mutable state backs exported runtime accessors (${getterNames.join(", ")} / ${setterNames.join(", ")})`, + }); + } + } + + return entries; +} + +export async function collectArchitectureSmells() { + const files = (await collectTypeScriptFilesFromRoots(scanRoots)).toSorted((left, right) => + normalizePath(left).localeCompare(normalizePath(right)), + ); + + const inventory = []; + for (const filePath of files) { + const source = await fs.readFile(filePath, "utf8"); + const sourceFile = ts.createSourceFile( + filePath, + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + inventory.push(...scanPluginSdkExtensionFacadeSmells(sourceFile, filePath)); + inventory.push(...scanRuntimeTypeImplementationSmells(sourceFile, filePath)); + inventory.push(...scanRuntimeServiceLocatorSmells(sourceFile, filePath)); + } + + return inventory.toSorted(compareEntries); +} + +function formatInventoryHuman(inventory) { + if (inventory.length === 0) { + return "No architecture smells found for the configured checks."; + } + + const lines = ["Architecture smell inventory:"]; + let activeCategory = ""; + let activeFile = ""; + for (const entry of inventory) { + if (entry.category !== activeCategory) { + activeCategory = entry.category; + activeFile = ""; + lines.push(entry.category); + } + if (entry.file !== activeFile) { + activeFile = entry.file; + lines.push(` ${activeFile}`); + } + lines.push(` - line ${entry.line} [${entry.kind}] ${entry.reason}`); + lines.push(` specifier: ${entry.specifier}`); + lines.push(` resolved: ${entry.resolvedPath}`); + } + return lines.join("\n"); +} + +export async function main(argv = process.argv.slice(2)) { + const json = argv.includes("--json"); + const inventory = await collectArchitectureSmells(); + + if (json) { + process.stdout.write(`${JSON.stringify(inventory, null, 2)}\n`); + return; + } + + console.log(formatInventoryHuman(inventory)); + console.log(`${inventory.length} smell${inventory.length === 1 ? "" : "s"} found.`); +} + +runAsScript(import.meta.url, main); diff --git a/scripts/generate-bundled-provider-auth-env-vars.d.mts b/scripts/generate-bundled-provider-auth-env-vars.d.mts new file mode 100644 index 00000000000..d5e189e743a --- /dev/null +++ b/scripts/generate-bundled-provider-auth-env-vars.d.mts @@ -0,0 +1,17 @@ +export function collectBundledProviderAuthEnvVars(params?: { + repoRoot?: string; +}): Record; + +export function renderBundledProviderAuthEnvVarModule( + entries: Record, +): string; + +export function writeBundledProviderAuthEnvVarModule(params?: { + repoRoot?: string; + outputPath?: string; + check?: boolean; +}): { + changed: boolean; + wrote: boolean; + outputPath: string; +}; diff --git a/scripts/generate-bundled-provider-auth-env-vars.mjs b/scripts/generate-bundled-provider-auth-env-vars.mjs new file mode 100644 index 00000000000..ebcd29360e8 --- /dev/null +++ b/scripts/generate-bundled-provider-auth-env-vars.mjs @@ -0,0 +1,131 @@ +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { writeTextFileIfChanged } from "./runtime-postbuild-shared.mjs"; + +const GENERATED_BY = "scripts/generate-bundled-provider-auth-env-vars.mjs"; +const DEFAULT_OUTPUT_PATH = "src/plugins/bundled-provider-auth-env-vars.generated.ts"; + +function readIfExists(filePath) { + try { + return fs.readFileSync(filePath, "utf8"); + } catch { + return null; + } +} + +function normalizeProviderAuthEnvVars(providerAuthEnvVars) { + if ( + !providerAuthEnvVars || + typeof providerAuthEnvVars !== "object" || + Array.isArray(providerAuthEnvVars) + ) { + return []; + } + + return Object.entries(providerAuthEnvVars) + .map(([providerId, envVars]) => { + const normalizedProviderId = providerId.trim(); + const normalizedEnvVars = Array.isArray(envVars) + ? envVars.map((value) => String(value).trim()).filter(Boolean) + : []; + if (!normalizedProviderId || normalizedEnvVars.length === 0) { + return null; + } + return [normalizedProviderId, normalizedEnvVars]; + }) + .filter(Boolean) + .toSorted(([left], [right]) => left.localeCompare(right)); +} + +export function collectBundledProviderAuthEnvVars(params = {}) { + const repoRoot = path.resolve(params.repoRoot ?? process.cwd()); + const extensionsRoot = path.join(repoRoot, "extensions"); + if (!fs.existsSync(extensionsRoot)) { + return {}; + } + + const entries = new Map(); + for (const dirent of fs.readdirSync(extensionsRoot, { withFileTypes: true })) { + if (!dirent.isDirectory()) { + continue; + } + + const manifestPath = path.join(extensionsRoot, dirent.name, "openclaw.plugin.json"); + if (!fs.existsSync(manifestPath)) { + continue; + } + + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + for (const [providerId, envVars] of normalizeProviderAuthEnvVars( + manifest.providerAuthEnvVars, + )) { + entries.set(providerId, envVars); + } + } + + return Object.fromEntries( + [...entries.entries()].toSorted(([left], [right]) => left.localeCompare(right)), + ); +} + +export function renderBundledProviderAuthEnvVarModule(entries) { + const renderedEntries = Object.entries(entries) + .map(([providerId, envVars]) => { + const renderedKey = /^[$A-Z_a-z][\w$]*$/u.test(providerId) + ? providerId + : JSON.stringify(providerId); + const renderedEnvVars = envVars.map((value) => JSON.stringify(value)).join(", "); + return ` ${renderedKey}: [${renderedEnvVars}],`; + }) + .join("\n"); + return `// Auto-generated by ${GENERATED_BY}. Do not edit directly. + +export const BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES = { +${renderedEntries} +} as const satisfies Record; +`; +} + +export function writeBundledProviderAuthEnvVarModule(params = {}) { + const repoRoot = path.resolve(params.repoRoot ?? process.cwd()); + const outputPath = path.resolve(repoRoot, params.outputPath ?? DEFAULT_OUTPUT_PATH); + const next = renderBundledProviderAuthEnvVarModule( + collectBundledProviderAuthEnvVars({ repoRoot }), + ); + const current = readIfExists(outputPath); + const changed = current !== next; + + if (params.check) { + return { + changed, + wrote: false, + outputPath, + }; + } + + return { + changed, + wrote: writeTextFileIfChanged(outputPath, next), + outputPath, + }; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + const result = writeBundledProviderAuthEnvVarModule({ + check: process.argv.includes("--check"), + }); + + if (result.changed) { + if (process.argv.includes("--check")) { + console.error( + `[bundled-provider-auth-env-vars] stale generated output at ${path.relative(process.cwd(), result.outputPath)}`, + ); + process.exitCode = 1; + } else { + console.log( + `[bundled-provider-auth-env-vars] wrote ${path.relative(process.cwd(), result.outputPath)}`, + ); + } + } +} diff --git a/scripts/lib/optional-bundled-clusters.d.mts b/scripts/lib/optional-bundled-clusters.d.mts new file mode 100644 index 00000000000..425e241ced7 --- /dev/null +++ b/scripts/lib/optional-bundled-clusters.d.mts @@ -0,0 +1,6 @@ +export const optionalBundledClusters: string[]; +export const optionalBundledClusterSet: Set; +export const OPTIONAL_BUNDLED_BUILD_ENV: "OPENCLAW_INCLUDE_OPTIONAL_BUNDLED"; +export function isOptionalBundledCluster(cluster: string): boolean; +export function shouldIncludeOptionalBundledClusters(env?: NodeJS.ProcessEnv): boolean; +export function shouldBuildBundledCluster(cluster: string, env?: NodeJS.ProcessEnv): boolean; diff --git a/scripts/lib/optional-bundled-clusters.d.ts b/scripts/lib/optional-bundled-clusters.d.ts new file mode 100644 index 00000000000..425e241ced7 --- /dev/null +++ b/scripts/lib/optional-bundled-clusters.d.ts @@ -0,0 +1,6 @@ +export const optionalBundledClusters: string[]; +export const optionalBundledClusterSet: Set; +export const OPTIONAL_BUNDLED_BUILD_ENV: "OPENCLAW_INCLUDE_OPTIONAL_BUNDLED"; +export function isOptionalBundledCluster(cluster: string): boolean; +export function shouldIncludeOptionalBundledClusters(env?: NodeJS.ProcessEnv): boolean; +export function shouldBuildBundledCluster(cluster: string, env?: NodeJS.ProcessEnv): boolean; diff --git a/scripts/lib/optional-bundled-clusters.mjs b/scripts/lib/optional-bundled-clusters.mjs new file mode 100644 index 00000000000..153dfee4ad6 --- /dev/null +++ b/scripts/lib/optional-bundled-clusters.mjs @@ -0,0 +1,30 @@ +export const optionalBundledClusters = [ + "acpx", + "diagnostics-otel", + "diffs", + "googlechat", + "matrix", + "memory-lancedb", + "msteams", + "nostr", + "tlon", + "twitch", + "ui", + "zalouser", +]; + +export const optionalBundledClusterSet = new Set(optionalBundledClusters); + +export const OPTIONAL_BUNDLED_BUILD_ENV = "OPENCLAW_INCLUDE_OPTIONAL_BUNDLED"; + +export function isOptionalBundledCluster(cluster) { + return optionalBundledClusterSet.has(cluster); +} + +export function shouldIncludeOptionalBundledClusters(env = process.env) { + return env[OPTIONAL_BUNDLED_BUILD_ENV] === "1"; +} + +export function shouldBuildBundledCluster(cluster, env = process.env) { + return shouldIncludeOptionalBundledClusters(env) || !isOptionalBundledCluster(cluster); +} diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index ac54dabe731..e0d707523a8 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -13,6 +13,7 @@ "setup-tools", "config-runtime", "reply-runtime", + "reply-payload", "channel-runtime", "interactive-runtime", "infra-runtime", @@ -41,6 +42,8 @@ "imessage", "imessage-core", "whatsapp", + "whatsapp-action-runtime", + "whatsapp-login-qr", "whatsapp-core", "line", "line-core", @@ -88,10 +91,12 @@ "channel-config-schema", "channel-lifecycle", "channel-policy", + "channel-send-result", "group-access", "directory-runtime", "json-store", "keyed-async-queue", + "windows-spawn", "provider-auth", "provider-auth-api-key", "provider-auth-login", @@ -111,6 +116,7 @@ "web-media", "speech", "state-paths", + "temp-path", "tool-send", "secret-input-schema" ] diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index dc7158a4cb7..94d2a173a0e 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -3,127 +3,30 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { channelTestPrefixes } from "../vitest.channel-paths.mjs"; +import { + loadTestRunnerBehavior, + loadUnitTimingManifest, + packFilesByDuration, + selectTimedHeavyFiles, +} from "./test-runner-manifest.mjs"; // On Windows, `.cmd` launchers can fail with `spawn EINVAL` when invoked without a shell // (especially under GitHub Actions + Git Bash). Use `shell: true` and let the shell resolve pnpm. const pnpm = "pnpm"; - -const unitIsolatedFilesRaw = [ - "src/plugins/loader.test.ts", - "src/plugins/tools.optional.test.ts", - "src/agents/session-tool-result-guard.tool-result-persist-hook.test.ts", - "src/security/fix.test.ts", - // Runtime source guard scans are sensitive to filesystem contention. - "src/security/temp-path-guard.test.ts", - "src/security/audit.test.ts", - "src/utils.test.ts", - "src/auto-reply/tool-meta.test.ts", - "src/auto-reply/envelope.test.ts", - "src/commands/auth-choice.test.ts", - // Provider runtime contract imports plugin runtimes plus async ESM mocks; - // keep it off the shared fast lane to avoid teardown stalls on this host. - "src/plugins/contracts/runtime.contract.test.ts", - // Process supervision + docker setup suites are stable but setup-heavy. - "src/process/supervisor/supervisor.test.ts", - "src/docker-setup.test.ts", - // Filesystem-heavy skills sync suite. - "src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts", - // Real git hook integration test; keep signal, move off unit-fast critical path. - "test/git-hooks-pre-commit.test.ts", - // Setup-heavy doctor command suites; keep them off the unit-fast critical path. - "src/commands/doctor.warns-state-directory-is-missing.test.ts", - "src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.test.ts", - "src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.test.ts", - // Setup-heavy CLI update flow suite; move off unit-fast critical path. - "src/cli/update-cli.test.ts", - // Uses temp repos + module cache resets; keep it off vmForks to avoid ref-resolution flakes. - "src/infra/git-commit.test.ts", - // Expensive schema build/bootstrap checks; keep coverage but run in isolated lane. - "src/config/schema.test.ts", - "src/config/schema.tags.test.ts", - // CLI smoke/agent flows are stable but setup-heavy. - "src/cli/program.smoke.test.ts", - "src/commands/agent.test.ts", - "src/media/store.test.ts", - "src/media/store.header-ext.test.ts", - "extensions/whatsapp/src/media.test.ts", - "extensions/whatsapp/src/auto-reply.web-auto-reply.falls-back-text-media-send-fails.test.ts", - "src/browser/server.covers-additional-endpoint-branches.test.ts", - "src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts", - "src/browser/server.agent-contract-snapshot-endpoints.test.ts", - "src/browser/server.agent-contract-form-layout-act-commands.test.ts", - "src/browser/server.skips-default-maxchars-explicitly-set-zero.test.ts", - "src/browser/server.auth-token-gates-http.test.ts", - // Keep this high-variance heavy file off the unit-fast critical path. - "src/auto-reply/reply.block-streaming.test.ts", - // Archive extraction/fixture-heavy suite; keep off unit-fast critical path. - "src/hooks/install.test.ts", - // Download/extraction safety cases can spike under unit-fast contention. - "src/agents/skills-install.download.test.ts", - // Skills discovery/snapshot suites are filesystem-heavy and high-variance in vmForks lanes. - "src/agents/skills.test.ts", - "src/agents/skills.buildworkspaceskillsnapshot.test.ts", - "extensions/acpx/src/runtime.test.ts", - // Shell-heavy script harness can contend under vmForks startup bursts. - "test/scripts/ios-team-id.test.ts", - // Heavy runner/exec/archive suites are stable but contend on shared resources under vmForks. - "src/agents/pi-embedded-runner.test.ts", - "src/agents/bash-tools.test.ts", - "src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts", - "src/agents/bash-tools.exec.background-abort.test.ts", - "src/agents/subagent-announce.format.test.ts", - "src/infra/archive.test.ts", - "src/cli/daemon-cli.coverage.test.ts", - // Model normalization test imports config/model discovery stack; keep off unit-fast critical path. - "src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts", - // Auth profile rotation suite is retry-heavy and high-variance under vmForks contention. - "src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts", - // Heavy trigger command scenarios; keep off unit-fast critical path to reduce contention noise. - "src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.test.ts", - "src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts", - "src/auto-reply/reply.triggers.group-intro-prompts.test.ts", - "src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts", - "extensions/whatsapp/src/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts", - // Setup-heavy bot bootstrap suite. - "extensions/telegram/src/bot.create-telegram-bot.test.ts", - // Medium-heavy bot behavior suite; move off unit-fast critical path. - "extensions/telegram/src/bot.test.ts", - // Slack slash registration tests are setup-heavy and can bottleneck unit-fast. - "extensions/slack/src/monitor/slash.test.ts", - // Uses process-level unhandledRejection listeners; keep it off vmForks to avoid cross-file leakage. - "extensions/imessage/src/monitor.shutdown.unhandled-rejection.test.ts", - // Mutates process.cwd() and mocks core module loaders; isolate from the shared fast lane. - "src/infra/git-commit.test.ts", -]; -const unitIsolatedFiles = unitIsolatedFilesRaw.filter((file) => fs.existsSync(file)); -const unitSingletonIsolatedFilesRaw = [ - // These pass clean in isolation but can hang on fork shutdown after sharing - // the broad unit-fast lane on this host; keep them in dedicated processes. - "src/cli/command-secret-gateway.test.ts", -]; -const unitSingletonIsolatedFiles = unitSingletonIsolatedFilesRaw.filter((file) => - fs.existsSync(file), -); -const unitThreadSingletonFilesRaw = [ - // These suites terminate cleanly under the threads pool but can hang during - // forks worker shutdown on this host. - "src/channels/plugins/actions/actions.test.ts", - "src/infra/outbound/deliver.test.ts", - "src/infra/outbound/deliver.lifecycle.test.ts", - "src/infra/outbound/message.channels.test.ts", - "src/infra/outbound/message-action-runner.poll.test.ts", - "src/tts/tts.test.ts", -]; -const unitThreadSingletonFiles = unitThreadSingletonFilesRaw.filter((file) => fs.existsSync(file)); -const unitVmForkSingletonFilesRaw = [ - "src/channels/plugins/contracts/inbound.telegram.contract.test.ts", -]; -const unitVmForkSingletonFiles = unitVmForkSingletonFilesRaw.filter((file) => fs.existsSync(file)); -const groupedUnitIsolatedFiles = unitIsolatedFiles.filter( - (file) => !unitSingletonIsolatedFiles.includes(file) && !unitThreadSingletonFiles.includes(file), -); -const channelSingletonFilesRaw = []; -const channelSingletonFiles = channelSingletonFilesRaw.filter((file) => fs.existsSync(file)); +const behaviorManifest = loadTestRunnerBehavior(); +const existingFiles = (entries) => + entries.map((entry) => entry.file).filter((file) => fs.existsSync(file)); +const unitBehaviorIsolatedFiles = existingFiles(behaviorManifest.unit.isolated); +const unitSingletonIsolatedFiles = existingFiles(behaviorManifest.unit.singletonIsolated); +const unitThreadSingletonFiles = existingFiles(behaviorManifest.unit.threadSingleton); +const unitVmForkSingletonFiles = existingFiles(behaviorManifest.unit.vmForkSingleton); +const unitBehaviorOverrideSet = new Set([ + ...unitBehaviorIsolatedFiles, + ...unitSingletonIsolatedFiles, + ...unitThreadSingletonFiles, + ...unitVmForkSingletonFiles, +]); +const channelSingletonFiles = []; const children = new Set(); const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true"; @@ -158,117 +61,7 @@ const testProfile = // Even on low-memory hosts, keep the isolated lane split so files like // git-commit.test.ts still get the worker/process isolation they require. const shouldSplitUnitRuns = testProfile !== "serial"; -const runs = [ - ...(shouldSplitUnitRuns - ? [ - { - name: "unit-fast", - args: [ - "vitest", - "run", - "--config", - "vitest.unit.config.ts", - `--pool=${useVmForks ? "vmForks" : "forks"}`, - ...(disableIsolation ? ["--isolate=false"] : []), - ...[ - ...unitIsolatedFiles, - ...unitSingletonIsolatedFiles, - ...unitThreadSingletonFiles, - ...unitVmForkSingletonFiles, - ].flatMap((file) => ["--exclude", file]), - ], - }, - ...(groupedUnitIsolatedFiles.length > 0 - ? [ - { - name: "unit-isolated", - args: [ - "vitest", - "run", - "--config", - "vitest.unit.config.ts", - "--pool=forks", - ...groupedUnitIsolatedFiles, - ], - }, - ] - : []), - ...unitSingletonIsolatedFiles.map((file) => ({ - name: `${path.basename(file, ".test.ts")}-isolated`, - args: [ - "vitest", - "run", - "--config", - "vitest.unit.config.ts", - `--pool=${useVmForks ? "vmForks" : "forks"}`, - file, - ], - })), - ...unitThreadSingletonFiles.map((file) => ({ - name: `${path.basename(file, ".test.ts")}-threads`, - args: ["vitest", "run", "--config", "vitest.unit.config.ts", "--pool=threads", file], - })), - ...unitVmForkSingletonFiles.map((file) => ({ - name: `${path.basename(file, ".test.ts")}-vmforks`, - args: [ - "vitest", - "run", - "--config", - "vitest.unit.config.ts", - `--pool=${useVmForks ? "vmForks" : "forks"}`, - ...(disableIsolation ? ["--isolate=false"] : []), - file, - ], - })), - ...channelSingletonFiles.map((file) => ({ - name: `${path.basename(file, ".test.ts")}-channels-isolated`, - args: ["vitest", "run", "--config", "vitest.channels.config.ts", "--pool=forks", file], - })), - ] - : [ - { - name: "unit", - args: [ - "vitest", - "run", - "--config", - "vitest.unit.config.ts", - `--pool=${useVmForks ? "vmForks" : "forks"}`, - ...(disableIsolation ? ["--isolate=false"] : []), - ], - }, - ]), - ...(includeExtensionsSuite - ? [ - { - name: "extensions", - args: [ - "vitest", - "run", - "--config", - "vitest.extensions.config.ts", - ...(useVmForks ? ["--pool=vmForks"] : []), - ], - }, - ] - : []), - ...(includeGatewaySuite - ? [ - { - name: "gateway", - args: [ - "vitest", - "run", - "--config", - "vitest.gateway.config.ts", - // Gateway tests are sensitive to vmForks behavior (global state + env stubs). - // Keep them on process forks for determinism even when other suites use vmForks. - "--pool=forks", - ], - }, - ] - : []), -]; +let runs = []; const shardOverride = Number.parseInt(process.env.OPENCLAW_TEST_SHARDS ?? "", 10); const configuredShardCount = Number.isFinite(shardOverride) && shardOverride > 1 ? shardOverride : null; @@ -414,7 +207,7 @@ const allKnownTestFiles = [ ]), ]; const inferTarget = (fileFilter) => { - const isolated = unitIsolatedFiles.includes(fileFilter); + const isolated = unitBehaviorIsolatedFiles.includes(fileFilter); if (fileFilter.endsWith(".live.test.ts")) { return { owner: "live", isolated }; } @@ -438,6 +231,160 @@ const inferTarget = (fileFilter) => { } return { owner: "base", isolated }; }; +const unitTimingManifest = loadUnitTimingManifest(); +const parseEnvNumber = (name, fallback) => { + const parsed = Number.parseInt(process.env[name] ?? "", 10); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; +}; +const allKnownUnitFiles = allKnownTestFiles.filter((file) => { + if (file.endsWith(".live.test.ts") || file.endsWith(".e2e.test.ts")) { + return false; + } + return inferTarget(file).owner !== "gateway"; +}); +const defaultHeavyUnitFileLimit = + testProfile === "serial" ? 0 : testProfile === "low" ? 20 : highMemLocalHost ? 80 : 60; +const defaultHeavyUnitLaneCount = + testProfile === "serial" ? 0 : testProfile === "low" ? 2 : highMemLocalHost ? 5 : 4; +const heavyUnitFileLimit = parseEnvNumber( + "OPENCLAW_TEST_HEAVY_UNIT_FILE_LIMIT", + defaultHeavyUnitFileLimit, +); +const heavyUnitLaneCount = parseEnvNumber( + "OPENCLAW_TEST_HEAVY_UNIT_LANES", + defaultHeavyUnitLaneCount, +); +const heavyUnitMinDurationMs = parseEnvNumber("OPENCLAW_TEST_HEAVY_UNIT_MIN_MS", 1200); +const timedHeavyUnitFiles = + shouldSplitUnitRuns && heavyUnitFileLimit > 0 + ? selectTimedHeavyFiles({ + candidates: allKnownUnitFiles, + limit: heavyUnitFileLimit, + minDurationMs: heavyUnitMinDurationMs, + exclude: unitBehaviorOverrideSet, + timings: unitTimingManifest, + }) + : []; +const unitFastExcludedFiles = [ + ...new Set([...unitBehaviorOverrideSet, ...timedHeavyUnitFiles, ...channelSingletonFiles]), +]; +const estimateUnitDurationMs = (file) => + unitTimingManifest.files[file]?.durationMs ?? unitTimingManifest.defaultDurationMs; +const heavyUnitBuckets = packFilesByDuration( + timedHeavyUnitFiles, + heavyUnitLaneCount, + estimateUnitDurationMs, +); +const unitHeavyEntries = heavyUnitBuckets.map((files, index) => ({ + name: `unit-heavy-${String(index + 1)}`, + args: ["vitest", "run", "--config", "vitest.unit.config.ts", "--pool=forks", ...files], +})); +const baseRuns = [ + ...(shouldSplitUnitRuns + ? [ + { + name: "unit-fast", + args: [ + "vitest", + "run", + "--config", + "vitest.unit.config.ts", + `--pool=${useVmForks ? "vmForks" : "forks"}`, + ...(disableIsolation ? ["--isolate=false"] : []), + ...unitFastExcludedFiles.flatMap((file) => ["--exclude", file]), + ], + }, + ...(unitBehaviorIsolatedFiles.length > 0 + ? [ + { + name: "unit-isolated", + args: [ + "vitest", + "run", + "--config", + "vitest.unit.config.ts", + "--pool=forks", + ...unitBehaviorIsolatedFiles, + ], + }, + ] + : []), + ...unitHeavyEntries, + ...unitSingletonIsolatedFiles.map((file) => ({ + name: `${path.basename(file, ".test.ts")}-isolated`, + args: [ + "vitest", + "run", + "--config", + "vitest.unit.config.ts", + `--pool=${useVmForks ? "vmForks" : "forks"}`, + file, + ], + })), + ...unitThreadSingletonFiles.map((file) => ({ + name: `${path.basename(file, ".test.ts")}-threads`, + args: ["vitest", "run", "--config", "vitest.unit.config.ts", "--pool=threads", file], + })), + ...unitVmForkSingletonFiles.map((file) => ({ + name: `${path.basename(file, ".test.ts")}-vmforks`, + args: [ + "vitest", + "run", + "--config", + "vitest.unit.config.ts", + `--pool=${useVmForks ? "vmForks" : "forks"}`, + ...(disableIsolation ? ["--isolate=false"] : []), + file, + ], + })), + ...channelSingletonFiles.map((file) => ({ + name: `${path.basename(file, ".test.ts")}-channels-isolated`, + args: ["vitest", "run", "--config", "vitest.channels.config.ts", "--pool=forks", file], + })), + ] + : [ + { + name: "unit", + args: [ + "vitest", + "run", + "--config", + "vitest.unit.config.ts", + `--pool=${useVmForks ? "vmForks" : "forks"}`, + ...(disableIsolation ? ["--isolate=false"] : []), + ], + }, + ]), + ...(includeExtensionsSuite + ? [ + { + name: "extensions", + args: [ + "vitest", + "run", + "--config", + "vitest.extensions.config.ts", + ...(useVmForks ? ["--pool=vmForks"] : []), + ], + }, + ] + : []), + ...(includeGatewaySuite + ? [ + { + name: "gateway", + args: ["vitest", "run", "--config", "vitest.gateway.config.ts", "--pool=forks"], + }, + ] + : []), +]; +runs = baseRuns; +const formatEntrySummary = (entry) => { + const explicitFilters = countExplicitEntryFilters(entry.args) ?? 0; + return `${entry.name} filters=${String(explicitFilters || "all")} maxWorkers=${String( + maxWorkersForRun(entry.name) ?? "default", + )}`; +}; const resolveFilterMatches = (fileFilter) => { const normalizedFilter = normalizeRepoPath(fileFilter); if (fs.existsSync(fileFilter)) { @@ -640,8 +587,10 @@ const defaultWorkerBudget = } : highMemLocalHost ? { - // High-memory local hosts can prioritize wall-clock speed. - unit: Math.max(4, Math.min(14, Math.floor((localWorkers * 7) / 8))), + // After peeling measured hotspots into dedicated lanes, the shared + // unit-fast lane shuts down more reliably with a slightly smaller + // worker fan-out than the old "max it out" local default. + unit: Math.max(4, Math.min(10, Math.floor((localWorkers * 5) / 8))), unitIsolated: Math.max(1, Math.min(2, Math.floor(localWorkers / 6) || 1)), extensions: Math.max(1, Math.min(4, Math.floor(localWorkers / 4))), gateway: Math.max(2, Math.min(6, Math.floor(localWorkers / 2))), @@ -674,7 +623,13 @@ const maxWorkersForRun = (name) => { if (isCI && isMacOS) { return 1; } - if (name === "unit-isolated" || name.endsWith("-isolated")) { + if (name.endsWith("-threads") || name.endsWith("-vmforks")) { + return 1; + } + if (name.endsWith("-isolated") && name !== "unit-isolated") { + return 1; + } + if (name === "unit-isolated" || name.startsWith("unit-heavy-")) { return defaultWorkerBudget.unitIsolated; } if (name === "extensions") { @@ -706,9 +661,12 @@ const maxOldSpaceSizeMb = (() => { } return null; })(); +const formatElapsedMs = (elapsedMs) => + elapsedMs >= 1000 ? `${(elapsedMs / 1000).toFixed(1)}s` : `${Math.round(elapsedMs)}ms`; const runOnce = (entry, extraArgs = []) => new Promise((resolve) => { + const startedAt = Date.now(); const maxWorkers = maxWorkersForRun(entry.name); // vmForks with a single worker has shown cross-file leakage in extension suites. // Fall back to process forks when we intentionally clamp that lane to one worker. @@ -726,6 +684,11 @@ const runOnce = (entry, extraArgs = []) => ...extraArgs, ] : [...entryArgs, ...silentArgs, ...windowsCiArgs, ...extraArgs]; + console.log( + `[test-parallel] start ${entry.name} workers=${maxWorkers ?? "default"} filters=${String( + countExplicitEntryFilters(entryArgs) ?? "all", + )}`, + ); const nodeOptions = process.env.NODE_OPTIONS ?? ""; const nextNodeOptions = WARNING_SUPPRESSION_FLAGS.reduce( (acc, flag) => (acc.includes(flag) ? acc : `${acc} ${flag}`.trim()), @@ -756,6 +719,11 @@ const runOnce = (entry, extraArgs = []) => }); child.on("exit", (code, signal) => { children.delete(child); + console.log( + `[test-parallel] done ${entry.name} code=${String(code ?? (signal ? 1 : 0))} elapsed=${formatElapsedMs( + Date.now() - startedAt, + )}`, + ); resolve(code ?? (signal ? 1 : 0)); }); }); @@ -823,6 +791,14 @@ const shutdown = (signal) => { process.on("SIGINT", () => shutdown("SIGINT")); process.on("SIGTERM", () => shutdown("SIGTERM")); +if (process.env.OPENCLAW_TEST_LIST_LANES === "1") { + const entriesToPrint = targetedEntries.length > 0 ? targetedEntries : runs; + for (const entry of entriesToPrint) { + console.log(formatEntrySummary(entry)); + } + process.exit(0); +} + if (targetedEntries.length > 0) { if (passthroughRequiresSingleRun && targetedEntries.length > 1) { console.error( diff --git a/scripts/test-runner-manifest.mjs b/scripts/test-runner-manifest.mjs new file mode 100644 index 00000000000..30b4414acc7 --- /dev/null +++ b/scripts/test-runner-manifest.mjs @@ -0,0 +1,129 @@ +import fs from "node:fs"; +import path from "node:path"; + +export const behaviorManifestPath = "test/fixtures/test-parallel.behavior.json"; +export const unitTimingManifestPath = "test/fixtures/test-timings.unit.json"; + +const defaultTimingManifest = { + config: "vitest.unit.config.ts", + defaultDurationMs: 250, + files: {}, +}; + +const readJson = (filePath, fallback) => { + try { + return JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch { + return fallback; + } +}; + +const normalizeRepoPath = (value) => value.split(path.sep).join("/"); + +const normalizeManifestEntries = (entries) => + entries + .map((entry) => + typeof entry === "string" + ? { file: normalizeRepoPath(entry), reason: "" } + : { + file: normalizeRepoPath(String(entry?.file ?? "")), + reason: typeof entry?.reason === "string" ? entry.reason : "", + }, + ) + .filter((entry) => entry.file.length > 0); + +export function loadTestRunnerBehavior() { + const raw = readJson(behaviorManifestPath, {}); + const unit = raw.unit ?? {}; + return { + unit: { + isolated: normalizeManifestEntries(unit.isolated ?? []), + singletonIsolated: normalizeManifestEntries(unit.singletonIsolated ?? []), + threadSingleton: normalizeManifestEntries(unit.threadSingleton ?? []), + vmForkSingleton: normalizeManifestEntries(unit.vmForkSingleton ?? []), + }, + }; +} + +export function loadUnitTimingManifest() { + const raw = readJson(unitTimingManifestPath, defaultTimingManifest); + const defaultDurationMs = + Number.isFinite(raw.defaultDurationMs) && raw.defaultDurationMs > 0 + ? raw.defaultDurationMs + : defaultTimingManifest.defaultDurationMs; + const files = Object.fromEntries( + Object.entries(raw.files ?? {}) + .map(([file, value]) => { + const normalizedFile = normalizeRepoPath(file); + const durationMs = + Number.isFinite(value?.durationMs) && value.durationMs >= 0 ? value.durationMs : null; + const testCount = + Number.isFinite(value?.testCount) && value.testCount >= 0 ? value.testCount : null; + if (!durationMs) { + return [normalizedFile, null]; + } + return [ + normalizedFile, + { + durationMs, + ...(testCount !== null ? { testCount } : {}), + }, + ]; + }) + .filter(([, value]) => value !== null), + ); + + return { + config: + typeof raw.config === "string" && raw.config ? raw.config : defaultTimingManifest.config, + generatedAt: typeof raw.generatedAt === "string" ? raw.generatedAt : "", + defaultDurationMs, + files, + }; +} + +export function selectTimedHeavyFiles({ + candidates, + limit, + minDurationMs, + exclude = new Set(), + timings, +}) { + return candidates + .filter((file) => !exclude.has(file)) + .map((file) => ({ + file, + durationMs: timings.files[file]?.durationMs ?? timings.defaultDurationMs, + known: Boolean(timings.files[file]), + })) + .filter((entry) => entry.known && entry.durationMs >= minDurationMs) + .toSorted((a, b) => b.durationMs - a.durationMs) + .slice(0, limit) + .map((entry) => entry.file); +} + +export function packFilesByDuration(files, bucketCount, estimateDurationMs) { + const normalizedBucketCount = Math.max(0, Math.floor(bucketCount)); + if (normalizedBucketCount <= 0 || files.length === 0) { + return []; + } + + const buckets = Array.from({ length: Math.min(normalizedBucketCount, files.length) }, () => ({ + totalMs: 0, + files: [], + })); + + const sortedFiles = [...files].toSorted((left, right) => { + return estimateDurationMs(right) - estimateDurationMs(left); + }); + + for (const file of sortedFiles) { + const bucket = buckets.reduce((lightest, current) => + current.totalMs < lightest.totalMs ? current : lightest, + ); + bucket.files.push(file); + bucket.totalMs += estimateDurationMs(file); + } + + return buckets.map((bucket) => bucket.files).filter((bucket) => bucket.length > 0); +} diff --git a/scripts/test-update-timings.mjs b/scripts/test-update-timings.mjs new file mode 100644 index 00000000000..722d3539f7a --- /dev/null +++ b/scripts/test-update-timings.mjs @@ -0,0 +1,109 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { unitTimingManifestPath } from "./test-runner-manifest.mjs"; + +function parseArgs(argv) { + const args = { + config: "vitest.unit.config.ts", + out: unitTimingManifestPath, + reportPath: "", + limit: 128, + defaultDurationMs: 250, + }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--config") { + args.config = argv[i + 1] ?? args.config; + i += 1; + continue; + } + if (arg === "--out") { + args.out = argv[i + 1] ?? args.out; + i += 1; + continue; + } + if (arg === "--report") { + args.reportPath = argv[i + 1] ?? ""; + i += 1; + continue; + } + if (arg === "--limit") { + const parsed = Number.parseInt(argv[i + 1] ?? "", 10); + if (Number.isFinite(parsed) && parsed > 0) { + args.limit = parsed; + } + i += 1; + continue; + } + if (arg === "--default-duration-ms") { + const parsed = Number.parseInt(argv[i + 1] ?? "", 10); + if (Number.isFinite(parsed) && parsed > 0) { + args.defaultDurationMs = parsed; + } + i += 1; + continue; + } + } + return args; +} + +const normalizeRepoPath = (value) => value.split(path.sep).join("/"); + +const opts = parseArgs(process.argv.slice(2)); +const reportPath = + opts.reportPath || path.join(os.tmpdir(), `openclaw-vitest-timings-${Date.now()}.json`); + +if (!(opts.reportPath && fs.existsSync(reportPath))) { + const run = spawnSync( + "pnpm", + ["vitest", "run", "--config", opts.config, "--reporter=json", "--outputFile", reportPath], + { + stdio: "inherit", + env: process.env, + }, + ); + + if (run.status !== 0) { + process.exit(run.status ?? 1); + } +} + +const report = JSON.parse(fs.readFileSync(reportPath, "utf8")); +const files = Object.fromEntries( + (report.testResults ?? []) + .map((result) => { + const file = typeof result.name === "string" ? normalizeRepoPath(result.name) : ""; + const start = typeof result.startTime === "number" ? result.startTime : 0; + const end = typeof result.endTime === "number" ? result.endTime : 0; + const testCount = Array.isArray(result.assertionResults) ? result.assertionResults.length : 0; + return { + file, + durationMs: Math.max(0, end - start), + testCount, + }; + }) + .filter((entry) => entry.file.length > 0 && entry.durationMs > 0) + .toSorted((a, b) => b.durationMs - a.durationMs) + .slice(0, opts.limit) + .map((entry) => [ + entry.file, + { + durationMs: entry.durationMs, + testCount: entry.testCount, + }, + ]), +); + +const output = { + config: opts.config, + generatedAt: new Date().toISOString(), + defaultDurationMs: opts.defaultDurationMs, + files, +}; + +fs.writeFileSync(opts.out, `${JSON.stringify(output, null, 2)}\n`); +console.log( + `[test-update-timings] wrote ${String(Object.keys(files).length)} timings to ${opts.out}`, +); diff --git a/scripts/tsdown-build.mjs b/scripts/tsdown-build.mjs index 871e89ddbf0..79f24ea65b8 100644 --- a/scripts/tsdown-build.mjs +++ b/scripts/tsdown-build.mjs @@ -1,6 +1,8 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; const logLevel = process.env.OPENCLAW_BUILD_VERBOSE ? "info" : "warn"; const extraArgs = process.argv.slice(2); @@ -8,6 +10,38 @@ const INEFFECTIVE_DYNAMIC_IMPORT_RE = /\[INEFFECTIVE_DYNAMIC_IMPORT\]/; const UNRESOLVED_IMPORT_RE = /\[UNRESOLVED_IMPORT\]/; const ANSI_ESCAPE_RE = new RegExp(String.raw`\u001B\[[0-9;]*m`, "g"); +function removeDistPluginNodeModulesSymlinks(rootDir) { + const extensionsDir = path.join(rootDir, "extensions"); + if (!fs.existsSync(extensionsDir)) { + return; + } + + for (const dirent of fs.readdirSync(extensionsDir, { withFileTypes: true })) { + if (!dirent.isDirectory()) { + continue; + } + const nodeModulesPath = path.join(extensionsDir, dirent.name, "node_modules"); + try { + if (fs.lstatSync(nodeModulesPath).isSymbolicLink()) { + fs.rmSync(nodeModulesPath, { force: true, recursive: true }); + } + } catch { + // Skip missing or unreadable paths so the build can proceed. + } + } +} + +function pruneStaleRuntimeSymlinks() { + const cwd = process.cwd(); + // runtime-postbuild links dist/dist-runtime plugin node_modules back into the + // source extensions. Remove only those symlinks up front so tsdown's clean + // step cannot traverse into the active pnpm install tree on rebuilds. + removeDistPluginNodeModulesSymlinks(path.join(cwd, "dist")); + removeDistPluginNodeModulesSymlinks(path.join(cwd, "dist-runtime")); +} + +pruneStaleRuntimeSymlinks(); + function findFatalUnresolvedImport(lines) { for (const line of lines) { if (!UNRESOLVED_IMPORT_RE.test(line)) { diff --git a/src/acp/client.ts b/src/acp/client.ts index 1d25281cce5..f3a04371c55 100644 --- a/src/acp/client.ts +++ b/src/acp/client.ts @@ -13,12 +13,12 @@ import { type RequestPermissionResponse, type SessionNotification, } from "@agentclientprotocol/sdk"; -import { isKnownCoreToolId } from "../agents/tool-catalog.js"; -import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; import { materializeWindowsSpawnProgram, resolveWindowsSpawnProgram, -} from "../plugin-sdk/windows-spawn.js"; +} from "openclaw/plugin-sdk/windows-spawn"; +import { isKnownCoreToolId } from "../agents/tool-catalog.js"; +import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; import { listKnownProviderAuthEnvVarNames, omitEnvKeysCaseInsensitive, diff --git a/src/acp/control-plane/session-actor-queue.ts b/src/acp/control-plane/session-actor-queue.ts index 7112d7421e3..54a8d33e54b 100644 --- a/src/acp/control-plane/session-actor-queue.ts +++ b/src/acp/control-plane/session-actor-queue.ts @@ -1,4 +1,4 @@ -import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js"; +import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue"; export class SessionActorQueue { private readonly queue = new KeyedAsyncQueue(); diff --git a/src/acp/translator.session-rate-limit.test.ts b/src/acp/translator.session-rate-limit.test.ts index d5897fa8172..566b61a5027 100644 --- a/src/acp/translator.session-rate-limit.test.ts +++ b/src/acp/translator.session-rate-limit.test.ts @@ -5,11 +5,10 @@ import type { SetSessionConfigOptionRequest, SetSessionModeRequest, } from "@agentclientprotocol/sdk"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { listThinkingLevels } from "../auto-reply/thinking.js"; import type { GatewayClient } from "../gateway/client.js"; import type { EventFrame } from "../gateway/protocol/index.js"; -import { resetProviderRuntimeHookCacheForTest } from "../plugins/provider-runtime.js"; import { createInMemorySessionStore } from "./session.js"; import { AcpGatewayAgent } from "./translator.js"; import { createAcpConnection, createAcpGateway } from "./translator.test-helpers.js"; @@ -121,10 +120,6 @@ async function expectOversizedPromptRejected(params: { sessionId: string; text: sessionStore.clearAllSessionsForTest(); } -beforeEach(() => { - resetProviderRuntimeHookCacheForTest(); -}); - describe("acp session creation rate limit", () => { it("rate limits excessive newSession bursts", async () => { const sessionStore = createInMemorySessionStore(); diff --git a/src/agents/cli-runner/helpers.ts b/src/agents/cli-runner/helpers.ts index 96ec35540be..98289396112 100644 --- a/src/agents/cli-runner/helpers.ts +++ b/src/agents/cli-runner/helpers.ts @@ -4,10 +4,10 @@ import os from "node:os"; import path from "node:path"; import type { AgentTool } from "@mariozechner/pi-agent-core"; import type { ImageContent } from "@mariozechner/pi-ai"; +import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue"; import type { ThinkLevel } from "../../auto-reply/thinking.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { CliBackendConfig } from "../../config/types.js"; -import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js"; import { buildTtsSystemPromptHint } from "../../tts/tts.js"; import { isRecord } from "../../utils.js"; import { buildModelAliasLines } from "../model-alias-lines.js"; diff --git a/src/agents/live-model-errors.test.ts b/src/agents/live-model-errors.test.ts index a0db57799ed..ec9440fbe57 100644 --- a/src/agents/live-model-errors.test.ts +++ b/src/agents/live-model-errors.test.ts @@ -7,7 +7,7 @@ import { describe("live model error helpers", () => { it("detects generic model-not-found messages", () => { expect(isModelNotFoundErrorMessage('{"code":404,"message":"model not found"}')).toBe(true); - expect(isModelNotFoundErrorMessage("model: MiniMax-M2.5-highspeed not found")).toBe(true); + expect(isModelNotFoundErrorMessage("model: MiniMax-M2.7-highspeed not found")).toBe(true); expect(isModelNotFoundErrorMessage("request ended without sending any chunks")).toBe(false); }); diff --git a/src/agents/minimax.live.test.ts b/src/agents/minimax.live.test.ts index 0d618725a8c..9ad1d18cf4e 100644 --- a/src/agents/minimax.live.test.ts +++ b/src/agents/minimax.live.test.ts @@ -4,7 +4,7 @@ import { isTruthyEnvValue } from "../infra/env.js"; const MINIMAX_KEY = process.env.MINIMAX_API_KEY ?? ""; const MINIMAX_BASE_URL = process.env.MINIMAX_BASE_URL?.trim() || "https://api.minimax.io/anthropic"; -const MINIMAX_MODEL = process.env.MINIMAX_MODEL?.trim() || "MiniMax-M2.5"; +const MINIMAX_MODEL = process.env.MINIMAX_MODEL?.trim() || "MiniMax-M2.7"; const LIVE = isTruthyEnvValue(process.env.MINIMAX_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE); const describeLive = LIVE && MINIMAX_KEY ? describe : describe.skip; diff --git a/src/agents/model-compat.test.ts b/src/agents/model-compat.test.ts index e576bc621b3..c1e79f4757a 100644 --- a/src/agents/model-compat.test.ts +++ b/src/agents/model-compat.test.ts @@ -368,14 +368,14 @@ describe("isModernModelRef", () => { expect(isModernModelRef({ provider: "opencode", id: "gemini-3-pro" })).toBe(true); expect(isModernModelRef({ provider: "opencode-go", id: "kimi-k2.5" })).toBe(true); expect(isModernModelRef({ provider: "opencode-go", id: "glm-5" })).toBe(true); - expect(isModernModelRef({ provider: "opencode-go", id: "minimax-m2.5" })).toBe(true); + expect(isModernModelRef({ provider: "opencode-go", id: "minimax-m2.7" })).toBe(true); }); it("excludes provider-declined modern models", () => { providerRuntimeMocks.resolveProviderModernModelRef.mockImplementation(({ provider, context }) => - provider === "opencode" && context.modelId === "minimax-m2.5" ? false : undefined, + provider === "opencode" && context.modelId === "minimax-m2.7" ? false : undefined, ); - expect(isModernModelRef({ provider: "opencode", id: "minimax-m2.5" })).toBe(false); + expect(isModernModelRef({ provider: "opencode", id: "minimax-m2.7" })).toBe(false); }); }); diff --git a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts index 036f4d00824..5e0f870e476 100644 --- a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts +++ b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts @@ -308,8 +308,8 @@ describe("models-config", () => { api: "anthropic-messages", models: [ { - id: "MiniMax-M2.5", - name: "MiniMax M2.5", + id: "MiniMax-M2.7", + name: "MiniMax M2.7", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, @@ -454,7 +454,7 @@ describe("models-config", () => { baseUrl: "https://api.minimax.io/anthropic", apiKey: "STALE_AGENT_KEY", // pragma: allowlist secret api: "anthropic-messages", - models: [{ id: "MiniMax-M2.5", name: "MiniMax M2.5", input: ["text"] }], + models: [{ id: "MiniMax-M2.7", name: "MiniMax M2.7", input: ["text"] }], }, }, }); diff --git a/src/agents/models-config.preserves-explicit-reasoning-override.test.ts b/src/agents/models-config.preserves-explicit-reasoning-override.test.ts index b1dd8ca49f0..ed35a9a14b0 100644 --- a/src/agents/models-config.preserves-explicit-reasoning-override.test.ts +++ b/src/agents/models-config.preserves-explicit-reasoning-override.test.ts @@ -21,7 +21,7 @@ type ModelsJson = { }; const MINIMAX_ENV_KEY = "MINIMAX_API_KEY"; -const MINIMAX_MODEL_ID = "MiniMax-M2.5"; +const MINIMAX_MODEL_ID = "MiniMax-M2.7"; const MINIMAX_TEST_KEY = "sk-minimax-test"; const baseMinimaxProvider = { @@ -50,8 +50,8 @@ async function generateAndReadMinimaxModel(cfg: OpenClawConfig): Promise { - it("preserves user reasoning:false when built-in catalog has reasoning:true (MiniMax-M2.5)", async () => { - // MiniMax-M2.5 has reasoning:true in the built-in catalog. + it("preserves user reasoning:false when built-in catalog has reasoning:true (MiniMax-M2.7)", async () => { + // MiniMax-M2.7 has reasoning:true in the built-in catalog. // User explicitly sets reasoning:false to avoid message-ordering conflicts. await withTempHome(async () => { await withMinimaxApiKey(async () => { @@ -63,7 +63,7 @@ describe("models-config: explicit reasoning override", () => { models: [ { id: MINIMAX_MODEL_ID, - name: "MiniMax M2.5", + name: "MiniMax M2.7", reasoning: false, // explicit override: user wants to disable reasoning input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, @@ -84,15 +84,15 @@ describe("models-config: explicit reasoning override", () => { }); }); - it("falls back to built-in reasoning:true when user omits the field (MiniMax-M2.5)", async () => { + it("falls back to built-in reasoning:true when user omits the field (MiniMax-M2.7)", async () => { // When the user does not set reasoning at all, the built-in catalog value - // (true for MiniMax-M2.5) should be used so the model works out of the box. + // (true for MiniMax-M2.7) should be used so the model works out of the box. await withTempHome(async () => { await withMinimaxApiKey(async () => { // Omit 'reasoning' to simulate a user config that doesn't set it. const modelWithoutReasoning = { id: MINIMAX_MODEL_ID, - name: "MiniMax M2.5", + name: "MiniMax M2.7", input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1_000_000, diff --git a/src/agents/models-config.providers.minimax.test.ts b/src/agents/models-config.providers.minimax.test.ts index 80718d28fbe..b3e3ea1e5c2 100644 --- a/src/agents/models-config.providers.minimax.test.ts +++ b/src/agents/models-config.providers.minimax.test.ts @@ -37,11 +37,15 @@ describe("minimax provider catalog", () => { const providers = await resolveImplicitProvidersForTest({ agentDir }); expect(providers?.minimax?.models?.map((model) => model.id)).toEqual([ "MiniMax-VL-01", + "MiniMax-M2.7", + "MiniMax-M2.7-highspeed", "MiniMax-M2.5", "MiniMax-M2.5-highspeed", ]); expect(providers?.["minimax-portal"]?.models?.map((model) => model.id)).toEqual([ "MiniMax-VL-01", + "MiniMax-M2.7", + "MiniMax-M2.7-highspeed", "MiniMax-M2.5", "MiniMax-M2.5-highspeed", ]); diff --git a/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts b/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts index ff38fe5e64a..4895a43c8d6 100644 --- a/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts +++ b/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts @@ -98,7 +98,7 @@ describe("models-config", () => { providerKey: "minimax", expectedBaseUrl: "https://api.minimax.io/anthropic", expectedApiKeyRef: "MINIMAX_API_KEY", // pragma: allowlist secret - expectedModelIds: ["MiniMax-M2.5", "MiniMax-VL-01"], + expectedModelIds: ["MiniMax-M2.7", "MiniMax-VL-01"], }); }); }); diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.model.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.model.test.ts index 042f479d5e4..69cf44409ff 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.model.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.model.test.ts @@ -199,11 +199,11 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { await expectSpawnUsesConfiguredModel({ config: { session: { mainKey: "main", scope: "per-sender" }, - agents: { defaults: { subagents: { model: "minimax/MiniMax-M2.5" } } }, + agents: { defaults: { subagents: { model: "minimax/MiniMax-M2.7" } } }, }, runId: "run-default-model", callId: "call-default-model", - expectedModel: "minimax/MiniMax-M2.5", + expectedModel: "minimax/MiniMax-M2.7", }); }); @@ -220,7 +220,7 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { config: { session: { mainKey: "main", scope: "per-sender" }, agents: { - defaults: { subagents: { model: "minimax/MiniMax-M2.5" } }, + defaults: { subagents: { model: "minimax/MiniMax-M2.7" } }, list: [{ id: "research", subagents: { model: "opencode/claude" } }], }, }, @@ -235,7 +235,7 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => { config: { session: { mainKey: "main", scope: "per-sender" }, agents: { - defaults: { model: { primary: "minimax/MiniMax-M2.5" } }, + defaults: { model: { primary: "minimax/MiniMax-M2.7" } }, list: [{ id: "research", model: { primary: "opencode/claude" } }], }, }, diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index dbd95e64d34..685976bf63d 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -685,7 +685,7 @@ describe("applyExtraParamsToAgent", () => { agent, undefined, "siliconflow", - "Pro/MiniMaxAI/MiniMax-M2.5", + "Pro/MiniMaxAI/MiniMax-M2.7", undefined, "off", ); @@ -693,7 +693,7 @@ describe("applyExtraParamsToAgent", () => { const model = { api: "openai-completions", provider: "siliconflow", - id: "Pro/MiniMaxAI/MiniMax-M2.5", + id: "Pro/MiniMaxAI/MiniMax-M2.7", } as Model<"openai-completions">; const context: Context = { messages: [] }; void agent.streamFn?.(model, context, {}); diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 17133673ef2..9499df9be08 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -7,6 +7,11 @@ import { estimateTokens, SessionManager, } from "@mariozechner/pi-coding-agent"; +import { resolveSignalReactionLevel } from "openclaw/plugin-sdk/signal"; +import { + resolveTelegramInlineButtonsScope, + resolveTelegramReactionLevel, +} from "openclaw/plugin-sdk/telegram"; import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js"; import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js"; import { resolveChannelCapabilities } from "../../config/channel-capabilities.js"; @@ -19,11 +24,6 @@ import { createInternalHookEvent, triggerInternalHook } from "../../hooks/intern import { getMachineDisplayName } from "../../infra/machine-name.js"; import { generateSecureToken } from "../../infra/secure-random.js"; import { getMemorySearchManager } from "../../memory/index.js"; -import { resolveSignalReactionLevel } from "../../plugin-sdk/signal.js"; -import { - resolveTelegramInlineButtonsScope, - resolveTelegramReactionLevel, -} from "../../plugin-sdk/telegram.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import { prepareProviderRuntimeAuth } from "../../plugins/provider-runtime.js"; import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js"; diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 8d13e8a8453..5b1767983ea 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -7,6 +7,11 @@ import { DefaultResourceLoader, SessionManager, } from "@mariozechner/pi-coding-agent"; +import { resolveSignalReactionLevel } from "openclaw/plugin-sdk/signal"; +import { + resolveTelegramInlineButtonsScope, + resolveTelegramReactionLevel, +} from "openclaw/plugin-sdk/telegram"; import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js"; import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js"; import type { OpenClawConfig } from "../../../config/config.js"; @@ -16,11 +21,6 @@ import { ensureGlobalUndiciStreamTimeouts, } from "../../../infra/net/undici-global-dispatcher.js"; import { MAX_IMAGE_BYTES } from "../../../media/constants.js"; -import { resolveSignalReactionLevel } from "../../../plugin-sdk/signal.js"; -import { - resolveTelegramInlineButtonsScope, - resolveTelegramReactionLevel, -} from "../../../plugin-sdk/telegram.js"; import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js"; import type { PluginHookAgentContext, diff --git a/src/agents/pi-embedded-runner/run/payloads.ts b/src/agents/pi-embedded-runner/run/payloads.ts index c0e0ded136e..a79fc592bf9 100644 --- a/src/agents/pi-embedded-runner/run/payloads.ts +++ b/src/agents/pi-embedded-runner/run/payloads.ts @@ -1,4 +1,5 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; +import { hasOutboundReplyContent } from "openclaw/plugin-sdk/reply-payload"; import { parseReplyDirectives } from "../../../auto-reply/reply/reply-directives.js"; import type { ReasoningLevel, VerboseLevel } from "../../../auto-reply/thinking.js"; import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../../auto-reply/tokens.js"; @@ -336,7 +337,7 @@ export function buildEmbeddedRunPayloads(params: { audioAsVoice: item.audioAsVoice || Boolean(hasAudioAsVoiceTag && item.media?.length), })) .filter((p) => { - if (!p.text && !p.mediaUrl && (!p.mediaUrls || p.mediaUrls.length === 0)) { + if (!hasOutboundReplyContent(p)) { return false; } if (p.text && isSilentReplyText(p.text, SILENT_REPLY_TOKEN)) { diff --git a/src/agents/pi-embedded-subscribe.handlers.messages.test.ts b/src/agents/pi-embedded-subscribe.handlers.messages.test.ts index 6c508bdbdb6..1ecdd45f9af 100644 --- a/src/agents/pi-embedded-subscribe.handlers.messages.test.ts +++ b/src/agents/pi-embedded-subscribe.handlers.messages.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { resolveSilentReplyFallbackText } from "./pi-embedded-subscribe.handlers.messages.js"; +import { + buildAssistantStreamData, + hasAssistantVisibleReply, + resolveSilentReplyFallbackText, +} from "./pi-embedded-subscribe.handlers.messages.js"; describe("resolveSilentReplyFallbackText", () => { it("replaces NO_REPLY with latest messaging tool text when available", () => { @@ -29,3 +33,31 @@ describe("resolveSilentReplyFallbackText", () => { ).toBe("NO_REPLY"); }); }); + +describe("hasAssistantVisibleReply", () => { + it("treats audio-only payloads as visible", () => { + expect(hasAssistantVisibleReply({ audioAsVoice: true })).toBe(true); + }); + + it("detects text or media visibility", () => { + expect(hasAssistantVisibleReply({ text: "hello" })).toBe(true); + expect(hasAssistantVisibleReply({ mediaUrls: ["https://example.com/a.png"] })).toBe(true); + expect(hasAssistantVisibleReply({})).toBe(false); + }); +}); + +describe("buildAssistantStreamData", () => { + it("normalizes media payloads for assistant stream events", () => { + expect( + buildAssistantStreamData({ + text: "hello", + delta: "he", + mediaUrl: "https://example.com/a.png", + }), + ).toEqual({ + text: "hello", + delta: "he", + mediaUrls: ["https://example.com/a.png"], + }); + }); +}); diff --git a/src/agents/pi-embedded-subscribe.handlers.messages.ts b/src/agents/pi-embedded-subscribe.handlers.messages.ts index 04f47e67cde..c3b4e92ba61 100644 --- a/src/agents/pi-embedded-subscribe.handlers.messages.ts +++ b/src/agents/pi-embedded-subscribe.handlers.messages.ts @@ -1,4 +1,5 @@ import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core"; +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { parseReplyDirectives } from "../auto-reply/reply/reply-directives.js"; import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { emitAgentEvent } from "../infra/agent-events.js"; @@ -56,6 +57,29 @@ export function resolveSilentReplyFallbackText(params: { return fallback; } +export function hasAssistantVisibleReply(params: { + text?: string; + mediaUrls?: string[]; + mediaUrl?: string; + audioAsVoice?: boolean; +}): boolean { + return resolveSendableOutboundReplyParts(params).hasContent || Boolean(params.audioAsVoice); +} + +export function buildAssistantStreamData(params: { + text?: string; + delta?: string; + mediaUrls?: string[]; + mediaUrl?: string; +}): { text: string; delta: string; mediaUrls?: string[] } { + const mediaUrls = resolveSendableOutboundReplyParts(params).mediaUrls; + return { + text: params.text ?? "", + delta: params.delta ?? "", + mediaUrls: mediaUrls.length ? mediaUrls : undefined, + }; +} + export function handleMessageStart( ctx: EmbeddedPiSubscribeContext, evt: AgentEvent & { message: AgentMessage }, @@ -196,14 +220,13 @@ export function handleMessageUpdate( const parsedDelta = visibleDelta ? ctx.consumePartialReplyDirectives(visibleDelta) : null; const parsedFull = parseReplyDirectives(stripTrailingDirective(next)); const cleanedText = parsedFull.text; - const mediaUrls = parsedDelta?.mediaUrls; - const hasMedia = Boolean(mediaUrls && mediaUrls.length > 0); + const { mediaUrls, hasMedia } = resolveSendableOutboundReplyParts(parsedDelta ?? {}); const hasAudio = Boolean(parsedDelta?.audioAsVoice); const previousCleaned = ctx.state.lastStreamedAssistantCleaned ?? ""; let shouldEmit = false; let deltaText = ""; - if (!cleanedText && !hasMedia && !hasAudio) { + if (!hasAssistantVisibleReply({ text: cleanedText, mediaUrls, audioAsVoice: hasAudio })) { shouldEmit = false; } else if (previousCleaned && !cleanedText.startsWith(previousCleaned)) { shouldEmit = false; @@ -216,29 +239,23 @@ export function handleMessageUpdate( ctx.state.lastStreamedAssistantCleaned = cleanedText; if (shouldEmit) { + const data = buildAssistantStreamData({ + text: cleanedText, + delta: deltaText, + mediaUrls, + }); emitAgentEvent({ runId: ctx.params.runId, stream: "assistant", - data: { - text: cleanedText, - delta: deltaText, - mediaUrls: hasMedia ? mediaUrls : undefined, - }, + data, }); void ctx.params.onAgentEvent?.({ stream: "assistant", - data: { - text: cleanedText, - delta: deltaText, - mediaUrls: hasMedia ? mediaUrls : undefined, - }, + data, }); ctx.state.emittedAssistantUpdate = true; if (ctx.params.onPartialReply && ctx.state.shouldEmitPartialReplies) { - void ctx.params.onPartialReply({ - text: cleanedText, - mediaUrls: hasMedia ? mediaUrls : undefined, - }); + void ctx.params.onPartialReply(data); } } } @@ -291,8 +308,7 @@ export function handleMessageEnd( const trimmedText = text.trim(); const parsedText = trimmedText ? parseReplyDirectives(stripTrailingDirective(trimmedText)) : null; let cleanedText = parsedText?.text ?? ""; - let mediaUrls = parsedText?.mediaUrls; - let hasMedia = Boolean(mediaUrls && mediaUrls.length > 0); + let { mediaUrls, hasMedia } = resolveSendableOutboundReplyParts(parsedText ?? {}); if (!cleanedText && !hasMedia && !ctx.params.enforceFinalTag) { const rawTrimmed = rawText.trim(); @@ -301,28 +317,24 @@ export function handleMessageEnd( if (rawCandidate) { const parsedFallback = parseReplyDirectives(stripTrailingDirective(rawCandidate)); cleanedText = parsedFallback.text ?? rawCandidate; - mediaUrls = parsedFallback.mediaUrls; - hasMedia = Boolean(mediaUrls && mediaUrls.length > 0); + ({ mediaUrls, hasMedia } = resolveSendableOutboundReplyParts(parsedFallback)); } } if (!ctx.state.emittedAssistantUpdate && (cleanedText || hasMedia)) { + const data = buildAssistantStreamData({ + text: cleanedText, + delta: cleanedText, + mediaUrls, + }); emitAgentEvent({ runId: ctx.params.runId, stream: "assistant", - data: { - text: cleanedText, - delta: cleanedText, - mediaUrls: hasMedia ? mediaUrls : undefined, - }, + data, }); void ctx.params.onAgentEvent?.({ stream: "assistant", - data: { - text: cleanedText, - delta: cleanedText, - mediaUrls: hasMedia ? mediaUrls : undefined, - }, + data, }); ctx.state.emittedAssistantUpdate = true; } @@ -377,7 +389,7 @@ export function handleMessageEnd( replyToCurrent, } = splitResult; // Emit if there's content OR audioAsVoice flag (to propagate the flag). - if (cleanedText || (mediaUrls && mediaUrls.length > 0) || audioAsVoice) { + if (hasAssistantVisibleReply({ text: cleanedText, mediaUrls, audioAsVoice })) { emitBlockReplySafely({ text: cleanedText, mediaUrls: mediaUrls?.length ? mediaUrls : undefined, diff --git a/src/agents/sandbox/docker.ts b/src/agents/sandbox/docker.ts index 80a2921cb6b..dff86ea6756 100644 --- a/src/agents/sandbox/docker.ts +++ b/src/agents/sandbox/docker.ts @@ -1,9 +1,9 @@ import { spawn } from "node:child_process"; -import { createSubsystemLogger } from "../../logging/subsystem.js"; import { materializeWindowsSpawnProgram, resolveWindowsSpawnProgram, -} from "../../plugin-sdk/windows-spawn.js"; +} from "openclaw/plugin-sdk/windows-spawn"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; import { sanitizeEnvVars } from "./sanitize-env-vars.js"; import type { EnvSanitizationOptions } from "./sanitize-env-vars.js"; diff --git a/src/agents/tools/image-tool.test.ts b/src/agents/tools/image-tool.test.ts index c58a7f9aa1a..c48a705dc01 100644 --- a/src/agents/tools/image-tool.test.ts +++ b/src/agents/tools/image-tool.test.ts @@ -142,7 +142,7 @@ function createMinimaxImageConfig(): OpenClawConfig { return { agents: { defaults: { - model: { primary: "minimax/MiniMax-M2.5" }, + model: { primary: "minimax/MiniMax-M2.7" }, imageModel: { primary: "minimax/MiniMax-VL-01" }, }, }, @@ -272,7 +272,7 @@ describe("image tool implicit imageModel config", () => { vi.stubEnv("OPENAI_API_KEY", "openai-test"); vi.stubEnv("ANTHROPIC_API_KEY", "anthropic-test"); const cfg: OpenClawConfig = { - agents: { defaults: { model: { primary: "minimax/MiniMax-M2.5" } } }, + agents: { defaults: { model: { primary: "minimax/MiniMax-M2.7" } } }, }; expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual( createDefaultImageFallbackExpectation("minimax/MiniMax-VL-01"), @@ -298,7 +298,7 @@ describe("image tool implicit imageModel config", () => { vi.stubEnv("OPENAI_API_KEY", "openai-test"); vi.stubEnv("ANTHROPIC_API_KEY", "anthropic-test"); const cfg: OpenClawConfig = { - agents: { defaults: { model: { primary: "minimax-portal/MiniMax-M2.5" } } }, + agents: { defaults: { model: { primary: "minimax-portal/MiniMax-M2.7" } } }, }; expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual( createDefaultImageFallbackExpectation("minimax-portal/MiniMax-VL-01"), @@ -356,7 +356,7 @@ describe("image tool implicit imageModel config", () => { const cfg: OpenClawConfig = { agents: { defaults: { - model: { primary: "minimax/MiniMax-M2.5" }, + model: { primary: "minimax/MiniMax-M2.7" }, imageModel: { primary: "openai/gpt-5-mini" }, }, }, @@ -584,7 +584,7 @@ describe("image tool implicit imageModel config", () => { vi.stubEnv("OPENAI_API_KEY", "openai-test"); const cfg: OpenClawConfig = { - agents: { defaults: { model: { primary: "minimax/MiniMax-M2.5" } } }, + agents: { defaults: { model: { primary: "minimax/MiniMax-M2.7" } } }, }; const tool = createRequiredImageTool({ config: cfg, agentDir, sandbox }); @@ -651,7 +651,7 @@ describe("image tool implicit imageModel config", () => { const cfg: OpenClawConfig = { agents: { defaults: { - model: { primary: "minimax/MiniMax-M2.5" }, + model: { primary: "minimax/MiniMax-M2.7" }, imageModel: { primary: "minimax/MiniMax-VL-01" }, }, }, @@ -704,7 +704,7 @@ describe("image tool MiniMax VLM routing", () => { const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-minimax-vlm-")); vi.stubEnv("MINIMAX_API_KEY", "minimax-test"); const cfg: OpenClawConfig = { - agents: { defaults: { model: { primary: "minimax/MiniMax-M2.5" } } }, + agents: { defaults: { model: { primary: "minimax/MiniMax-M2.7" } } }, }; const tool = createRequiredImageTool({ config: cfg, agentDir }); return { fetch, tool }; diff --git a/src/auto-reply/heartbeat-reply-payload.ts b/src/auto-reply/heartbeat-reply-payload.ts index 4bdf9e3a57b..87f92c6b7c1 100644 --- a/src/auto-reply/heartbeat-reply-payload.ts +++ b/src/auto-reply/heartbeat-reply-payload.ts @@ -1,3 +1,4 @@ +import { hasOutboundReplyContent } from "openclaw/plugin-sdk/reply-payload"; import type { ReplyPayload } from "./types.js"; export function resolveHeartbeatReplyPayload( @@ -14,7 +15,7 @@ export function resolveHeartbeatReplyPayload( if (!payload) { continue; } - if (payload.text || payload.mediaUrl || (payload.mediaUrls && payload.mediaUrls.length > 0)) { + if (hasOutboundReplyContent(payload)) { return payload; } } diff --git a/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts b/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts index 0a93f5f69a6..6ad08b1d6c5 100644 --- a/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts @@ -183,7 +183,7 @@ describe("directive behavior", () => { primary: "anthropic/claude-opus-4-5", fallbacks: ["openai/gpt-4.1-mini"], }, - imageModel: { primary: "minimax/MiniMax-M2.5" }, + imageModel: { primary: "minimax/MiniMax-M2.7" }, models: undefined, }, }); @@ -206,7 +206,7 @@ describe("directive behavior", () => { models: { "anthropic/claude-opus-4-5": {}, "openai/gpt-4.1-mini": {}, - "minimax/MiniMax-M2.5": { alias: "minimax" }, + "minimax/MiniMax-M2.7": { alias: "minimax" }, }, }, extra: { @@ -216,14 +216,17 @@ describe("directive behavior", () => { minimax: { baseUrl: "https://api.minimax.io/anthropic", api: "anthropic-messages", - models: [{ id: "MiniMax-M2.5", name: "MiniMax M2.5" }], + models: [ + { id: "MiniMax-M2.7", name: "MiniMax M2.7" }, + { id: "MiniMax-M2.5", name: "MiniMax M2.5" }, + ], }, }, }, }, }); expect(configOnlyProviderText).toContain("Models (minimax"); - expect(configOnlyProviderText).toContain("minimax/MiniMax-M2.5"); + expect(configOnlyProviderText).toContain("minimax/MiniMax-M2.7"); const missingAuthText = await runModelDirectiveText(home, "/model list", { defaults: { diff --git a/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts b/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts index 9cca0fad783..dd98000d165 100644 --- a/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts @@ -119,9 +119,10 @@ describe("directive behavior", () => { config: { agents: { defaults: { - model: { primary: "minimax/MiniMax-M2.5" }, + model: { primary: "minimax/MiniMax-M2.7" }, workspace: path.join(home, "openclaw"), models: { + "minimax/MiniMax-M2.7": {}, "minimax/MiniMax-M2.5": {}, "minimax/MiniMax-M2.5-highspeed": {}, "lmstudio/minimax-m2.5-gs32": {}, @@ -135,7 +136,10 @@ describe("directive behavior", () => { baseUrl: "https://api.minimax.io/anthropic", apiKey: "sk-test", // pragma: allowlist secret api: "anthropic-messages", - models: [makeModelDefinition("MiniMax-M2.5", "MiniMax M2.5")], + models: [ + makeModelDefinition("MiniMax-M2.7", "MiniMax M2.7"), + makeModelDefinition("MiniMax-M2.5", "MiniMax M2.5"), + ], }, lmstudio: { baseUrl: "http://127.0.0.1:1234/v1", @@ -153,9 +157,10 @@ describe("directive behavior", () => { config: { agents: { defaults: { - model: { primary: "minimax/MiniMax-M2.5" }, + model: { primary: "minimax/MiniMax-M2.7" }, workspace: path.join(home, "openclaw"), models: { + "minimax/MiniMax-M2.7": {}, "minimax/MiniMax-M2.5": {}, "minimax/MiniMax-M2.5-highspeed": {}, }, @@ -169,6 +174,7 @@ describe("directive behavior", () => { apiKey: "sk-test", // pragma: allowlist secret api: "anthropic-messages", models: [ + makeModelDefinition("MiniMax-M2.7", "MiniMax M2.7"), makeModelDefinition("MiniMax-M2.5", "MiniMax M2.5"), makeModelDefinition("MiniMax-M2.5-highspeed", "MiniMax M2.5 Highspeed"), ], diff --git a/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts b/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts index 9a831dde795..626683601d7 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.test-harness.ts @@ -80,7 +80,7 @@ const modelCatalogMocks = vi.hoisted(() => ({ { provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 mini" }, { provider: "openai", id: "gpt-5.2", name: "GPT-5.2" }, { provider: "openai-codex", id: "gpt-5.2", name: "GPT-5.2 (Codex)" }, - { provider: "minimax", id: "MiniMax-M2.5", name: "MiniMax M2.5" }, + { provider: "minimax", id: "MiniMax-M2.7", name: "MiniMax M2.7" }, ]), resetModelCatalogCacheForTest: vi.fn(), })); diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 5c9b78c208f..c25342e4a28 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -1,5 +1,6 @@ import crypto from "node:crypto"; import fs from "node:fs"; +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-budget.js"; import { runCliAgent } from "../../agents/cli-runner.js"; import { getCliSessionId } from "../../agents/cli-session.js"; @@ -148,6 +149,7 @@ export async function runAgentTurnWithFallback(params: { try { const normalizeStreamingText = (payload: ReplyPayload): { text?: string; skip: boolean } => { let text = payload.text; + const reply = resolveSendableOutboundReplyParts(payload); if (!params.isHeartbeat && text?.includes("HEARTBEAT_OK")) { const stripped = stripHeartbeatToken(text, { mode: "message", @@ -156,7 +158,7 @@ export async function runAgentTurnWithFallback(params: { didLogHeartbeatStrip = true; logVerbose("Stripped stray HEARTBEAT_OK token from reply"); } - if (stripped.shouldSkip && (payload.mediaUrls?.length ?? 0) === 0) { + if (stripped.shouldSkip && !reply.hasMedia) { return { skip: true }; } text = stripped.text; @@ -172,7 +174,7 @@ export async function runAgentTurnWithFallback(params: { } if (!text) { // Allow media-only payloads (e.g. tool result screenshots) through. - if ((payload.mediaUrls?.length ?? 0) > 0) { + if (reply.hasMedia) { return { text: undefined, skip: false }; } return { skip: true }; diff --git a/src/auto-reply/reply/agent-runner-helpers.ts b/src/auto-reply/reply/agent-runner-helpers.ts index 11ea0fe9f53..168984c35b9 100644 --- a/src/auto-reply/reply/agent-runner-helpers.ts +++ b/src/auto-reply/reply/agent-runner-helpers.ts @@ -1,3 +1,7 @@ +import { + hasOutboundReplyContent, + resolveSendableOutboundReplyParts, +} from "openclaw/plugin-sdk/reply-payload"; import { loadSessionStore } from "../../config/sessions.js"; import { isAudioFileName } from "../../media/mime.js"; import { normalizeVerboseLevel, type VerboseLevel } from "../thinking.js"; @@ -9,7 +13,7 @@ const hasAudioMedia = (urls?: string[]): boolean => Boolean(urls?.some((url) => isAudioFileName(url))); export const isAudioPayload = (payload: ReplyPayload): boolean => - hasAudioMedia(payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : undefined)); + hasAudioMedia(resolveSendableOutboundReplyParts(payload).mediaUrls); type VerboseGateParams = { sessionKey?: string; @@ -63,19 +67,9 @@ export const signalTypingIfNeeded = async ( payloads: ReplyPayload[], typingSignals: TypingSignaler, ): Promise => { - const shouldSignalTyping = payloads.some((payload) => { - const trimmed = payload.text?.trim(); - if (trimmed) { - return true; - } - if (payload.mediaUrl) { - return true; - } - if (payload.mediaUrls && payload.mediaUrls.length > 0) { - return true; - } - return false; - }); + const shouldSignalTyping = payloads.some((payload) => + hasOutboundReplyContent(payload, { trimText: true }), + ); if (shouldSignalTyping) { await typingSignals.signalRunStart(); } diff --git a/src/auto-reply/reply/agent-runner-payloads.ts b/src/auto-reply/reply/agent-runner-payloads.ts index 9e89c921407..5f4eeab2694 100644 --- a/src/auto-reply/reply/agent-runner-payloads.ts +++ b/src/auto-reply/reply/agent-runner-payloads.ts @@ -1,3 +1,4 @@ +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import type { ReplyToMode } from "../../config/types.js"; import { logVerbose } from "../../globals.js"; import { stripHeartbeatToken } from "../heartbeat.js"; @@ -20,15 +21,11 @@ import { shouldSuppressMessagingToolReplies, } from "./reply-payloads.js"; -function hasPayloadMedia(payload: ReplyPayload): boolean { - return Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; -} - async function normalizeReplyPayloadMedia(params: { payload: ReplyPayload; normalizeMediaPaths?: (payload: ReplyPayload) => Promise; }): Promise { - if (!params.normalizeMediaPaths || !hasPayloadMedia(params.payload)) { + if (!params.normalizeMediaPaths || !resolveSendableOutboundReplyParts(params.payload).hasMedia) { return params.payload; } @@ -69,11 +66,7 @@ async function normalizeSentMediaUrlsForDedupe(params: { mediaUrl: trimmed, mediaUrls: [trimmed], }); - const normalizedMediaUrls = normalized.mediaUrls?.length - ? normalized.mediaUrls - : normalized.mediaUrl - ? [normalized.mediaUrl] - : []; + const normalizedMediaUrls = resolveSendableOutboundReplyParts(normalized).mediaUrls; for (const mediaUrl of normalizedMediaUrls) { const candidate = mediaUrl.trim(); if (!candidate || seen.has(candidate)) { @@ -130,7 +123,7 @@ export async function buildReplyPayloads(params: { didLogHeartbeatStrip = true; logVerbose("Stripped stray HEARTBEAT_OK token from reply"); } - const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; + const hasMedia = resolveSendableOutboundReplyParts(payload).hasMedia; if (stripped.shouldSkip && !hasMedia) { return []; } diff --git a/src/auto-reply/reply/block-reply-coalescer.ts b/src/auto-reply/reply/block-reply-coalescer.ts index 130f57b3d07..c7a6f85c26b 100644 --- a/src/auto-reply/reply/block-reply-coalescer.ts +++ b/src/auto-reply/reply/block-reply-coalescer.ts @@ -1,3 +1,4 @@ +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import type { ReplyPayload } from "../types.js"; import type { BlockStreamingCoalescing } from "./block-streaming.js"; @@ -75,9 +76,10 @@ export function createBlockReplyCoalescer(params: { if (shouldAbort()) { return; } - const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; - const text = payload.text ?? ""; - const hasText = text.trim().length > 0; + const reply = resolveSendableOutboundReplyParts(payload); + const hasMedia = reply.hasMedia; + const text = reply.text; + const hasText = reply.hasText; if (hasMedia) { void flush({ force: true }); void onFlush(payload); diff --git a/src/auto-reply/reply/block-reply-pipeline.ts b/src/auto-reply/reply/block-reply-pipeline.ts index 9ce85334238..aee14715136 100644 --- a/src/auto-reply/reply/block-reply-pipeline.ts +++ b/src/auto-reply/reply/block-reply-pipeline.ts @@ -1,3 +1,4 @@ +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { logVerbose } from "../../globals.js"; import type { ReplyPayload } from "../types.js"; import { createBlockReplyCoalescer } from "./block-reply-coalescer.js"; @@ -35,30 +36,20 @@ export function createAudioAsVoiceBuffer(params: { } export function createBlockReplyPayloadKey(payload: ReplyPayload): string { - const text = payload.text?.trim() ?? ""; - const mediaList = payload.mediaUrls?.length - ? payload.mediaUrls - : payload.mediaUrl - ? [payload.mediaUrl] - : []; + const reply = resolveSendableOutboundReplyParts(payload); return JSON.stringify({ - text, - mediaList, + text: reply.trimmedText, + mediaList: reply.mediaUrls, replyToId: payload.replyToId ?? null, }); } export function createBlockReplyContentKey(payload: ReplyPayload): string { - const text = payload.text?.trim() ?? ""; - const mediaList = payload.mediaUrls?.length - ? payload.mediaUrls - : payload.mediaUrl - ? [payload.mediaUrl] - : []; + const reply = resolveSendableOutboundReplyParts(payload); // Content-only key used for final-payload suppression after block streaming. // This intentionally ignores replyToId so a streamed threaded payload and the // later final payload still collapse when they carry the same content. - return JSON.stringify({ text, mediaList }); + return JSON.stringify({ text: reply.trimmedText, mediaList: reply.mediaUrls }); } const withTimeout = async ( @@ -217,7 +208,7 @@ export function createBlockReplyPipeline(params: { if (bufferPayload(payload)) { return; } - const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; + const hasMedia = resolveSendableOutboundReplyParts(payload).hasMedia; if (hasMedia) { void coalescer?.flush({ force: true }); sendPayload(payload, /* bypassSeenCheck */ false); diff --git a/src/auto-reply/reply/commands-approve.ts b/src/auto-reply/reply/commands-approve.ts index 630ea988c05..05d7fe0139a 100644 --- a/src/auto-reply/reply/commands-approve.ts +++ b/src/auto-reply/reply/commands-approve.ts @@ -1,9 +1,9 @@ -import { callGateway } from "../../gateway/call.js"; -import { logVerbose } from "../../globals.js"; import { isTelegramExecApprovalApprover, isTelegramExecApprovalClientEnabled, -} from "../../plugin-sdk/telegram.js"; +} from "openclaw/plugin-sdk/telegram"; +import { callGateway } from "../../gateway/call.js"; +import { logVerbose } from "../../globals.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js"; import { requireGatewayClientScopeForInternalChannel } from "./command-gates.js"; import type { CommandHandler } from "./commands-types.js"; diff --git a/src/auto-reply/reply/commands-models.ts b/src/auto-reply/reply/commands-models.ts index 25f309361d2..b1a1fcba8da 100644 --- a/src/auto-reply/reply/commands-models.ts +++ b/src/auto-reply/reply/commands-models.ts @@ -1,3 +1,10 @@ +import { + buildModelsKeyboard, + buildProviderKeyboard, + calculateTotalPages, + getModelsPageSize, + type ProviderInfo, +} from "openclaw/plugin-sdk/telegram"; import { resolveAgentDir, resolveSessionAgentId } from "../../agents/agent-scope.js"; import { resolveModelAuthLabel } from "../../agents/model-auth-label.js"; import { loadModelCatalog } from "../../agents/model-catalog.js"; @@ -10,13 +17,6 @@ import { } from "../../agents/model-selection.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; -import { - buildModelsKeyboard, - buildProviderKeyboard, - calculateTotalPages, - getModelsPageSize, - type ProviderInfo, -} from "../../plugin-sdk/telegram.js"; import type { ReplyPayload } from "../types.js"; import { rejectUnauthorizedCommand } from "./command-gates.js"; import type { CommandHandler } from "./commands-types.js"; diff --git a/src/auto-reply/reply/directive-handling.model.ts b/src/auto-reply/reply/directive-handling.model.ts index 986f632bcb5..5d8d871f9ec 100644 --- a/src/auto-reply/reply/directive-handling.model.ts +++ b/src/auto-reply/reply/directive-handling.model.ts @@ -1,3 +1,4 @@ +import { buildBrowseProvidersButton } from "openclaw/plugin-sdk/telegram"; import { ensureAuthProfileStore, resolveAuthStorePathForDisplay, @@ -12,7 +13,6 @@ import { } from "../../agents/model-selection.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; -import { buildBrowseProvidersButton } from "../../plugin-sdk/telegram.js"; import { shortenHomePath } from "../../utils.js"; import { resolveSelectedAndActiveModel } from "../model-runtime.js"; import type { ReplyPayload } from "../types.js"; diff --git a/src/auto-reply/reply/dispatch-acp-delivery.ts b/src/auto-reply/reply/dispatch-acp-delivery.ts index 6624f9868a2..57be876132b 100644 --- a/src/auto-reply/reply/dispatch-acp-delivery.ts +++ b/src/auto-reply/reply/dispatch-acp-delivery.ts @@ -1,3 +1,4 @@ +import { hasOutboundReplyContent } from "openclaw/plugin-sdk/reply-payload"; import type { OpenClawConfig } from "../../config/config.js"; import type { TtsAutoMode } from "../../config/types.tts.js"; import { logVerbose } from "../../globals.js"; @@ -127,7 +128,7 @@ export function createAcpDispatchDeliveryCoordinator(params: { state.blockCount += 1; } - if ((payload.text?.trim() ?? "").length > 0 || payload.mediaUrl || payload.mediaUrls?.length) { + if (hasOutboundReplyContent(payload, { trimText: true })) { await startReplyLifecycleOnce(); } diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index 34950c20950..9df6ef2bc63 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -1,3 +1,4 @@ +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { resolveConversationBindingRecord, @@ -532,7 +533,7 @@ export async function dispatchReplyFromConfig(params: { } // Group/native flows intentionally suppress tool summary text, but media-only // tool results (for example TTS audio) must still be delivered. - const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; + const hasMedia = resolveSendableOutboundReplyParts(payload).hasMedia; if (!hasMedia) { return null; } diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 71cb32ee5bb..2ee2571de5b 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -1,4 +1,8 @@ import crypto from "node:crypto"; +import { + hasOutboundReplyContent, + resolveSendableOutboundReplyParts, +} from "openclaw/plugin-sdk/reply-payload"; import { resolveRunModelFallbacksOverride } from "../../agents/agent-scope.js"; import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-budget.js"; import { lookupContextTokens } from "../../agents/context.js"; @@ -81,13 +85,12 @@ export function createFollowupRunner(params: { } for (const payload of payloads) { - if (!payload?.text && !payload?.mediaUrl && !payload?.mediaUrls?.length) { + if (!payload || !hasOutboundReplyContent(payload)) { continue; } if ( isSilentReplyText(payload.text, SILENT_REPLY_TOKEN) && - !payload.mediaUrl && - !payload.mediaUrls?.length + !resolveSendableOutboundReplyParts(payload).hasMedia ) { continue; } @@ -290,7 +293,7 @@ export function createFollowupRunner(params: { return [payload]; } const stripped = stripHeartbeatToken(text, { mode: "message" }); - const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; + const hasMedia = resolveSendableOutboundReplyParts(payload).hasMedia; if (stripped.shouldSkip && !hasMedia) { return []; } diff --git a/src/auto-reply/reply/normalize-reply.ts b/src/auto-reply/reply/normalize-reply.ts index 52faa463bdb..a3ae3417d7d 100644 --- a/src/auto-reply/reply/normalize-reply.ts +++ b/src/auto-reply/reply/normalize-reply.ts @@ -1,5 +1,5 @@ import { sanitizeUserFacingText } from "../../agents/pi-embedded-helpers.js"; -import { hasReplyChannelData, hasReplyContent } from "../../interactive/payload.js"; +import { hasReplyPayloadContent } from "../../interactive/payload.js"; import { stripHeartbeatToken } from "../heartbeat.js"; import { HEARTBEAT_TOKEN, @@ -32,17 +32,18 @@ export function normalizeReplyPayload( payload: ReplyPayload, opts: NormalizeReplyOptions = {}, ): ReplyPayload | null { - const hasChannelData = hasReplyChannelData(payload.channelData); + const hasContent = (text: string | undefined) => + hasReplyPayloadContent( + { + ...payload, + text, + }, + { + trimText: true, + }, + ); const trimmed = payload.text?.trim() ?? ""; - if ( - !hasReplyContent({ - text: trimmed, - mediaUrl: payload.mediaUrl, - mediaUrls: payload.mediaUrls, - interactive: payload.interactive, - hasChannelData, - }) - ) { + if (!hasContent(trimmed)) { opts.onSkip?.("empty"); return null; } @@ -50,14 +51,7 @@ export function normalizeReplyPayload( const silentToken = opts.silentToken ?? SILENT_REPLY_TOKEN; let text = payload.text ?? undefined; if (text && isSilentReplyText(text, silentToken)) { - if ( - !hasReplyContent({ - mediaUrl: payload.mediaUrl, - mediaUrls: payload.mediaUrls, - interactive: payload.interactive, - hasChannelData, - }) - ) { + if (!hasContent("")) { opts.onSkip?.("silent"); return null; } @@ -68,15 +62,7 @@ export function normalizeReplyPayload( // silent just like the exact-match path above. (#30916, #30955) if (text && text.includes(silentToken) && !isSilentReplyText(text, silentToken)) { text = stripSilentToken(text, silentToken); - if ( - !hasReplyContent({ - text, - mediaUrl: payload.mediaUrl, - mediaUrls: payload.mediaUrls, - interactive: payload.interactive, - hasChannelData, - }) - ) { + if (!hasContent(text)) { opts.onSkip?.("silent"); return null; } @@ -92,16 +78,7 @@ export function normalizeReplyPayload( if (stripped.didStrip) { opts.onHeartbeatStrip?.(); } - if ( - stripped.shouldSkip && - !hasReplyContent({ - text: stripped.text, - mediaUrl: payload.mediaUrl, - mediaUrls: payload.mediaUrls, - interactive: payload.interactive, - hasChannelData, - }) - ) { + if (stripped.shouldSkip && !hasContent(stripped.text)) { opts.onSkip?.("heartbeat"); return null; } @@ -111,15 +88,7 @@ export function normalizeReplyPayload( if (text) { text = sanitizeUserFacingText(text, { errorContext: Boolean(payload.isError) }); } - if ( - !hasReplyContent({ - text, - mediaUrl: payload.mediaUrl, - mediaUrls: payload.mediaUrls, - interactive: payload.interactive, - hasChannelData, - }) - ) { + if (!hasContent(text)) { opts.onSkip?.("empty"); return null; } diff --git a/src/auto-reply/reply/reply-delivery.ts b/src/auto-reply/reply/reply-delivery.ts index cacd6b083cb..ee19d2d0934 100644 --- a/src/auto-reply/reply/reply-delivery.ts +++ b/src/auto-reply/reply/reply-delivery.ts @@ -1,3 +1,4 @@ +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { logVerbose } from "../../globals.js"; import { SILENT_REPLY_TOKEN } from "../tokens.js"; import type { BlockReplyContext, ReplyPayload } from "../types.js"; @@ -57,9 +58,6 @@ export function normalizeReplyPayloadDirectives(params: { }; } -const hasRenderableMedia = (payload: ReplyPayload): boolean => - Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0; - export function createBlockReplyDeliveryHandler(params: { onBlockReply: (payload: ReplyPayload, context?: BlockReplyContext) => Promise | void; currentMessageId?: string; @@ -73,7 +71,7 @@ export function createBlockReplyDeliveryHandler(params: { }): (payload: ReplyPayload) => Promise { return async (payload) => { const { text, skip } = params.normalizeStreamingText(payload); - if (skip && !hasRenderableMedia(payload)) { + if (skip && !resolveSendableOutboundReplyParts(payload).hasMedia) { return; } @@ -106,7 +104,7 @@ export function createBlockReplyDeliveryHandler(params: { ? await params.normalizeMediaPaths(normalized.payload) : normalized.payload; const blockPayload = params.applyReplyToMode(mediaNormalizedPayload); - const blockHasMedia = hasRenderableMedia(blockPayload); + const blockHasMedia = resolveSendableOutboundReplyParts(blockPayload).hasMedia; // Skip empty payloads unless they have audioAsVoice flag (need to track it). if (!blockPayload.text && !blockHasMedia && !blockPayload.audioAsVoice) { diff --git a/src/auto-reply/reply/reply-media-paths.ts b/src/auto-reply/reply/reply-media-paths.ts index 1c09316afad..915b7607092 100644 --- a/src/auto-reply/reply/reply-media-paths.ts +++ b/src/auto-reply/reply/reply-media-paths.ts @@ -1,3 +1,4 @@ +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { resolvePathFromInput } from "../../agents/path-policy.js"; import { assertMediaNotDataUrl, resolveSandboxedMediaSource } from "../../agents/sandbox-paths.js"; import { ensureSandboxWorkspaceForSession } from "../../agents/sandbox.js"; @@ -25,7 +26,7 @@ function isLikelyLocalMediaSource(media: string): boolean { } function getPayloadMediaList(payload: ReplyPayload): string[] { - return payload.mediaUrls?.length ? payload.mediaUrls : payload.mediaUrl ? [payload.mediaUrl] : []; + return resolveSendableOutboundReplyParts(payload).mediaUrls; } export function createReplyMediaPathNormalizer(params: { diff --git a/src/auto-reply/reply/reply-payloads.ts b/src/auto-reply/reply/reply-payloads.ts index 7d7ae82975c..1826d1872af 100644 --- a/src/auto-reply/reply/reply-payloads.ts +++ b/src/auto-reply/reply/reply-payloads.ts @@ -4,7 +4,7 @@ import { normalizeChannelId } from "../../channels/plugins/index.js"; import { parseExplicitTargetForChannel } from "../../channels/plugins/target-parsing.js"; import type { ReplyToMode } from "../../config/types.js"; import { normalizeTargetForProvider } from "../../infra/outbound/target-normalization.js"; -import { hasReplyChannelData, hasReplyContent } from "../../interactive/payload.js"; +import { hasReplyPayloadContent } from "../../interactive/payload.js"; import { normalizeOptionalAccountId } from "../../routing/account-id.js"; import type { OriginatingChannelType } from "../templating.js"; import type { ReplyPayload } from "../types.js"; @@ -75,14 +75,7 @@ export function applyReplyTagsToPayload( } export function isRenderablePayload(payload: ReplyPayload): boolean { - return hasReplyContent({ - text: payload.text, - mediaUrl: payload.mediaUrl, - mediaUrls: payload.mediaUrls, - interactive: payload.interactive, - hasChannelData: hasReplyChannelData(payload.channelData), - extraContent: payload.audioAsVoice, - }); + return hasReplyPayloadContent(payload, { extraContent: payload.audioAsVoice }); } export function shouldSuppressReasoningPayload(payload: ReplyPayload): boolean { diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index 3836ceb5ab6..3fed4655d99 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -12,7 +12,7 @@ import { resolveEffectiveMessagesConfig } from "../../agents/identity.js"; import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import type { OpenClawConfig } from "../../config/config.js"; import { buildOutboundSessionContext } from "../../infra/outbound/session-context.js"; -import { hasReplyContent } from "../../interactive/payload.js"; +import { hasReplyPayloadContent } from "../../interactive/payload.js"; import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js"; import type { OriginatingChannelType } from "../templating.js"; import type { ReplyPayload } from "../types.js"; @@ -126,12 +126,16 @@ export async function routeReply(params: RouteReplyParams): Promise ({ vi.mock("../../agents/model-catalog.js", () => ({ loadModelCatalog: vi.fn(async () => [ - { provider: "minimax", id: "m2.5", name: "M2.5" }, + { provider: "minimax", id: "m2.7", name: "M2.7" }, { provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" }, ]), })); @@ -1288,7 +1288,7 @@ describe("applyResetModelOverride", () => { }); expect(sessionEntry.providerOverride).toBe("minimax"); - expect(sessionEntry.modelOverride).toBe("m2.5"); + expect(sessionEntry.modelOverride).toBe("m2.7"); expect(sessionCtx.BodyStripped).toBe("summarize"); }); diff --git a/src/auto-reply/reply/streaming-directives.ts b/src/auto-reply/reply/streaming-directives.ts index e61499200e0..ab4e6bedae1 100644 --- a/src/auto-reply/reply/streaming-directives.ts +++ b/src/auto-reply/reply/streaming-directives.ts @@ -1,3 +1,4 @@ +import { hasOutboundReplyContent } from "openclaw/plugin-sdk/reply-payload"; import { splitMediaFromOutput } from "../../media/parse.js"; import { parseInlineDirectives } from "../../utils/directive-tags.js"; import { isSilentReplyPrefixText, isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js"; @@ -67,10 +68,7 @@ const parseChunk = (raw: string, options?: { silentToken?: string }): ParsedChun }; const hasRenderableContent = (parsed: ReplyDirectiveParseResult): boolean => - Boolean(parsed.text) || - Boolean(parsed.mediaUrl) || - (parsed.mediaUrls?.length ?? 0) > 0 || - Boolean(parsed.audioAsVoice); + hasOutboundReplyContent(parsed) || Boolean(parsed.audioAsVoice); export function createStreamingDirectiveAccumulator() { let pendingTail = ""; diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index a32fdc3ba87..4485e2c22ee 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -1,9 +1,9 @@ +import type { StickerMetadata } from "openclaw/plugin-sdk/telegram"; import type { ChannelId } from "../channels/plugins/types.js"; import type { MediaUnderstandingDecision, MediaUnderstandingOutput, } from "../media-understanding/types.js"; -import type { StickerMetadata } from "../plugin-sdk/telegram.js"; import type { InputProvenance } from "../sessions/input-provenance.js"; import type { InternalMessageChannel } from "../utils/message-channel.js"; import type { CommandArgs } from "./commands-registry.types.js"; diff --git a/src/auto-reply/thinking.shared.ts b/src/auto-reply/thinking.shared.ts index 7487928eac3..e5a80c8bdb3 100644 --- a/src/auto-reply/thinking.shared.ts +++ b/src/auto-reply/thinking.shared.ts @@ -14,6 +14,25 @@ export type ThinkingCatalogEntry = { const BASE_THINKING_LEVELS: ThinkLevel[] = ["off", "minimal", "low", "medium", "high", "adaptive"]; const ANTHROPIC_CLAUDE_46_MODEL_RE = /^claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i; const AMAZON_BEDROCK_CLAUDE_46_MODEL_RE = /claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i; +const OPENAI_XHIGH_MODEL_IDS = [ + "gpt-5.4", + "gpt-5.4-pro", + "gpt-5.4-mini", + "gpt-5.4-nano", + "gpt-5.2", +] as const; +const OPENAI_CODEX_XHIGH_MODEL_IDS = [ + "gpt-5.4", + "gpt-5.3-codex", + "gpt-5.3-codex-spark", + "gpt-5.2-codex", + "gpt-5.1-codex", +] as const; +const GITHUB_COPILOT_XHIGH_MODEL_IDS = ["gpt-5.2", "gpt-5.2-codex"] as const; + +function matchesExactOrPrefix(modelId: string, ids: readonly string[]): boolean { + return ids.some((candidate) => modelId === candidate || modelId.startsWith(`${candidate}-`)); +} export function normalizeProviderId(provider?: string | null): string { if (!provider) { @@ -33,6 +52,27 @@ export function isBinaryThinkingProvider(provider?: string | null): boolean { return normalizeProviderId(provider) === "zai"; } +export function supportsBuiltInXHighThinking( + provider?: string | null, + model?: string | null, +): boolean { + const providerId = normalizeProviderId(provider); + const modelId = model?.trim().toLowerCase(); + if (!providerId || !modelId) { + return false; + } + if (providerId === "openai") { + return matchesExactOrPrefix(modelId, OPENAI_XHIGH_MODEL_IDS); + } + if (providerId === "openai-codex") { + return matchesExactOrPrefix(modelId, OPENAI_CODEX_XHIGH_MODEL_IDS); + } + if (providerId === "github-copilot") { + return GITHUB_COPILOT_XHIGH_MODEL_IDS.includes(modelId as never); + } + return false; +} + // Normalize user-provided thinking level strings to the canonical enum. export function normalizeThinkLevel(raw?: string | null): ThinkLevel | undefined { if (!raw) { diff --git a/src/auto-reply/thinking.ts b/src/auto-reply/thinking.ts index 1f2f1738b1f..7c0f2df02c7 100644 --- a/src/auto-reply/thinking.ts +++ b/src/auto-reply/thinking.ts @@ -5,6 +5,7 @@ import { listThinkingLevels as listThinkingLevelsFallback, normalizeProviderId, resolveThinkingDefaultForModel as resolveThinkingDefaultForModelFallback, + supportsBuiltInXHighThinking, } from "./thinking.shared.js"; import type { ThinkLevel, ThinkingCatalogEntry } from "./thinking.shared.js"; export { @@ -36,6 +37,9 @@ import { } from "../plugins/provider-runtime.js"; export function isBinaryThinkingProvider(provider?: string | null, model?: string | null): boolean { + if (isBinaryThinkingProviderFallback(provider)) { + return true; + } const normalizedProvider = normalizeProviderId(provider); if (!normalizedProvider) { return false; @@ -59,6 +63,9 @@ export function supportsXHighThinking(provider?: string | null, model?: string | if (!modelKey) { return false; } + if (supportsBuiltInXHighThinking(provider, modelKey)) { + return true; + } const providerKey = normalizeProviderId(provider); if (providerKey) { const pluginDecision = resolveProviderXHighThinking({ diff --git a/src/channel-web.ts b/src/channel-web.ts index 38d5a3c02cb..3566cee4790 100644 --- a/src/channel-web.ts +++ b/src/channel-web.ts @@ -7,11 +7,15 @@ export { monitorWebChannel, resolveHeartbeatRecipients, runWebHeartbeatOnce, -} from "./plugin-sdk/whatsapp.js"; -export { extractMediaPlaceholder, extractText, monitorWebInbox } from "./plugin-sdk/whatsapp.js"; -export { loginWeb } from "./plugin-sdk/whatsapp.js"; +} from "openclaw/plugin-sdk/whatsapp"; +export { + extractMediaPlaceholder, + extractText, + monitorWebInbox, +} from "openclaw/plugin-sdk/whatsapp"; +export { loginWeb } from "openclaw/plugin-sdk/whatsapp"; export { loadWebMedia, optimizeImageToJpeg } from "./media/web-media.js"; -export { sendMessageWhatsApp } from "./plugin-sdk/whatsapp.js"; +export { sendMessageWhatsApp } from "openclaw/plugin-sdk/whatsapp"; export { createWaSocket, formatError, @@ -22,4 +26,4 @@ export { WA_WEB_AUTH_DIR, waitForWaConnection, webAuthExists, -} from "./plugin-sdk/whatsapp.js"; +} from "openclaw/plugin-sdk/whatsapp"; diff --git a/src/channels/allowlists/resolve-utils.ts b/src/channels/allowlists/resolve-utils.ts index 2199eaf4ecf..84a3da97b5e 100644 --- a/src/channels/allowlists/resolve-utils.ts +++ b/src/channels/allowlists/resolve-utils.ts @@ -1,4 +1,4 @@ -import { mapAllowFromEntries } from "../../plugin-sdk/channel-config-helpers.js"; +import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; import type { RuntimeEnv } from "../../runtime.js"; import { summarizeStringEntries } from "../../shared/string-sample.js"; diff --git a/src/channels/plugins/bundled.ts b/src/channels/plugins/bundled.ts index 5579ddfdf65..86f4c0083b7 100644 --- a/src/channels/plugins/bundled.ts +++ b/src/channels/plugins/bundled.ts @@ -2,17 +2,13 @@ import { bluebubblesPlugin } from "../../../extensions/bluebubbles/index.js"; import { discordPlugin, setDiscordRuntime } from "../../../extensions/discord/index.js"; import { discordSetupPlugin } from "../../../extensions/discord/setup-entry.js"; import { feishuPlugin } from "../../../extensions/feishu/index.js"; -import { googlechatPlugin } from "../../../extensions/googlechat/index.js"; import { imessagePlugin } from "../../../extensions/imessage/index.js"; import { imessageSetupPlugin } from "../../../extensions/imessage/setup-entry.js"; import { ircPlugin } from "../../../extensions/irc/index.js"; import { linePlugin, setLineRuntime } from "../../../extensions/line/index.js"; import { lineSetupPlugin } from "../../../extensions/line/setup-entry.js"; -import { matrixPlugin } from "../../../extensions/matrix/index.js"; import { mattermostPlugin } from "../../../extensions/mattermost/index.js"; -import { msteamsPlugin } from "../../../extensions/msteams/index.js"; import { nextcloudTalkPlugin } from "../../../extensions/nextcloud-talk/index.js"; -import { nostrPlugin } from "../../../extensions/nostr/index.js"; import { signalPlugin } from "../../../extensions/signal/index.js"; import { signalSetupPlugin } from "../../../extensions/signal/setup-entry.js"; import { slackPlugin } from "../../../extensions/slack/index.js"; @@ -20,34 +16,26 @@ import { slackSetupPlugin } from "../../../extensions/slack/setup-entry.js"; import { synologyChatPlugin } from "../../../extensions/synology-chat/index.js"; import { telegramPlugin, setTelegramRuntime } from "../../../extensions/telegram/index.js"; import { telegramSetupPlugin } from "../../../extensions/telegram/setup-entry.js"; -import { tlonPlugin } from "../../../extensions/tlon/index.js"; import { whatsappPlugin } from "../../../extensions/whatsapp/index.js"; import { whatsappSetupPlugin } from "../../../extensions/whatsapp/setup-entry.js"; import { zaloPlugin } from "../../../extensions/zalo/index.js"; -import { zalouserPlugin } from "../../../extensions/zalouser/index.js"; import type { ChannelId, ChannelPlugin } from "./types.js"; export const bundledChannelPlugins = [ bluebubblesPlugin, discordPlugin, feishuPlugin, - googlechatPlugin, imessagePlugin, ircPlugin, linePlugin, - matrixPlugin, mattermostPlugin, - msteamsPlugin, nextcloudTalkPlugin, - nostrPlugin, signalPlugin, slackPlugin, synologyChatPlugin, telegramPlugin, - tlonPlugin, whatsappPlugin, zaloPlugin, - zalouserPlugin, ] as ChannelPlugin[]; export const bundledChannelSetupPlugins = [ @@ -55,7 +43,6 @@ export const bundledChannelSetupPlugins = [ whatsappSetupPlugin, discordSetupPlugin, ircPlugin, - googlechatPlugin, slackSetupPlugin, signalSetupPlugin, imessageSetupPlugin, diff --git a/src/channels/plugins/contracts/registry.ts b/src/channels/plugins/contracts/registry.ts index 134d8dddfb1..94892151c7b 100644 --- a/src/channels/plugins/contracts/registry.ts +++ b/src/channels/plugins/contracts/registry.ts @@ -4,7 +4,6 @@ import { createThreadBindingManager as createDiscordThreadBindingManager, } from "../../../../extensions/discord/runtime-api.js"; import { createFeishuThreadBindingManager } from "../../../../extensions/feishu/api.js"; -import { setMatrixRuntime } from "../../../../extensions/matrix/index.js"; import { createTelegramThreadBindingManager } from "../../../../extensions/telegram/runtime-api.js"; import type { OpenClawConfig } from "../../../config/config.js"; import { @@ -208,12 +207,6 @@ bundledChannelRuntimeSetters.setLineRuntime({ }, } as never); -setMatrixRuntime({ - state: { - resolveStateDir: (_env: unknown, homeDir?: () => string) => (homeDir ?? (() => "/tmp"))(), - }, -} as never); - export const pluginContractRegistry: PluginContractEntry[] = bundledChannelPlugins.map( (plugin) => ({ id: plugin.id, @@ -583,25 +576,6 @@ export const threadingContractRegistry: ThreadingContractEntry[] = surfaceContra })); const directoryPresenceOnlyIds = new Set(["whatsapp", "zalouser"]); -const matrixDirectoryCfg = { - channels: { - matrix: { - enabled: true, - homeserver: "https://matrix.example.com", - userId: "@lobster:example.com", - accessToken: "matrix-access-token", - dm: { - allowFrom: ["matrix:@alice:example.com"], - }, - groupAllowFrom: ["matrix:@team:example.com"], - groups: { - "!room:example.com": { - users: ["matrix:@alice:example.com"], - }, - }, - }, - }, -} as OpenClawConfig; export const directoryContractRegistry: DirectoryContractEntry[] = surfaceContractRegistry .filter((entry) => entry.surfaces.includes("directory")) @@ -609,7 +583,6 @@ export const directoryContractRegistry: DirectoryContractEntry[] = surfaceContra id: entry.id, plugin: entry.plugin, coverage: directoryPresenceOnlyIds.has(entry.id) ? "presence" : "lookups", - ...(entry.id === "matrix" ? { cfg: matrixDirectoryCfg } : {}), })); const baseSessionBindingCfg = { diff --git a/src/channels/plugins/directory-adapters.test.ts b/src/channels/plugins/directory-adapters.test.ts new file mode 100644 index 00000000000..8d9a6bfea6b --- /dev/null +++ b/src/channels/plugins/directory-adapters.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { + createChannelDirectoryAdapter, + createEmptyChannelDirectoryAdapter, + emptyChannelDirectoryList, + nullChannelDirectorySelf, +} from "./directory-adapters.js"; + +describe("directory adapters", () => { + it("defaults self to null", async () => { + const adapter = createChannelDirectoryAdapter(); + await expect(adapter.self?.({ cfg: {}, runtime: {} as never })).resolves.toBeNull(); + }); + + it("preserves provided resolvers", async () => { + const adapter = createChannelDirectoryAdapter({ + listPeers: async () => [{ kind: "user", id: "u-1" }], + }); + await expect(adapter.listPeers?.({ cfg: {}, runtime: {} as never })).resolves.toEqual([ + { kind: "user", id: "u-1" }, + ]); + }); + + it("builds empty directory adapters", async () => { + const adapter = createEmptyChannelDirectoryAdapter(); + await expect(adapter.self?.({ cfg: {}, runtime: {} as never })).resolves.toBeNull(); + await expect(adapter.listPeers?.({ cfg: {}, runtime: {} as never })).resolves.toEqual([]); + await expect(adapter.listGroups?.({ cfg: {}, runtime: {} as never })).resolves.toEqual([]); + }); + + it("exports standalone null/empty helpers", async () => { + await expect(nullChannelDirectorySelf({ cfg: {}, runtime: {} as never })).resolves.toBeNull(); + await expect(emptyChannelDirectoryList({ cfg: {}, runtime: {} as never })).resolves.toEqual([]); + }); +}); diff --git a/src/channels/plugins/directory-adapters.ts b/src/channels/plugins/directory-adapters.ts new file mode 100644 index 00000000000..5462f977d0b --- /dev/null +++ b/src/channels/plugins/directory-adapters.ts @@ -0,0 +1,28 @@ +import type { ChannelDirectoryAdapter } from "./types.adapters.js"; + +export const nullChannelDirectorySelf: NonNullable = async () => + null; + +export const emptyChannelDirectoryList: NonNullable< + ChannelDirectoryAdapter["listPeers"] +> = async () => []; + +/** Build a channel directory adapter with a null self resolver by default. */ +export function createChannelDirectoryAdapter( + params: Omit & { + self?: ChannelDirectoryAdapter["self"]; + } = {}, +): ChannelDirectoryAdapter { + return { + self: params.self ?? nullChannelDirectorySelf, + ...params, + }; +} + +/** Build the common empty directory surface for channels without directory support. */ +export function createEmptyChannelDirectoryAdapter(): ChannelDirectoryAdapter { + return createChannelDirectoryAdapter({ + listPeers: emptyChannelDirectoryList, + listGroups: emptyChannelDirectoryList, + }); +} diff --git a/src/channels/plugins/directory-config-helpers.test.ts b/src/channels/plugins/directory-config-helpers.test.ts index 15aa8f0d298..5fadc922328 100644 --- a/src/channels/plugins/directory-config-helpers.test.ts +++ b/src/channels/plugins/directory-config-helpers.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from "vitest"; import { + listDirectoryEntriesFromSources, + listInspectedDirectoryEntriesFromSources, listDirectoryGroupEntriesFromMapKeysAndAllowFrom, listDirectoryGroupEntriesFromMapKeys, + listResolvedDirectoryGroupEntriesFromMapKeys, + listResolvedDirectoryEntriesFromSources, + listResolvedDirectoryUserEntriesFromAllowFrom, listDirectoryUserEntriesFromAllowFromAndMapKeys, listDirectoryUserEntriesFromAllowFrom, } from "./directory-config-helpers.js"; @@ -78,3 +83,95 @@ describe("listDirectoryGroupEntriesFromMapKeysAndAllowFrom", () => { ]); }); }); + +describe("listDirectoryEntriesFromSources", () => { + it("merges source iterables with dedupe/query/limit", () => { + const entries = listDirectoryEntriesFromSources({ + kind: "user", + sources: [ + ["user:alice", "user:bob"], + ["user:carla", "user:alice"], + ], + normalizeId: (entry) => entry.replace(/^user:/i, ""), + query: "a", + limit: 2, + }); + + expectUserDirectoryEntries(entries); + }); +}); + +describe("listInspectedDirectoryEntriesFromSources", () => { + it("returns empty when the inspected account is missing", () => { + const entries = listInspectedDirectoryEntriesFromSources({ + cfg: {} as never, + kind: "user", + inspectAccount: () => null, + resolveSources: () => [["user:alice"]], + normalizeId: (entry) => entry.replace(/^user:/i, ""), + }); + + expect(entries).toEqual([]); + }); + + it("lists entries from inspected account sources", () => { + const entries = listInspectedDirectoryEntriesFromSources({ + cfg: {} as never, + kind: "group", + inspectAccount: () => ({ ids: [["room:a"], ["room:b", "room:a"]] }), + resolveSources: (account) => account.ids, + normalizeId: (entry) => entry.replace(/^room:/i, ""), + query: "a", + }); + + expect(entries).toEqual([{ kind: "group", id: "a" }]); + }); +}); + +describe("resolved account directory helpers", () => { + const cfg = {} as never; + const resolveAccount = () => ({ + allowFrom: ["user:alice", "user:bob"], + groups: { "room:a": {}, "room:b": {} }, + }); + + it("lists user entries from resolved account allowFrom", () => { + const entries = listResolvedDirectoryUserEntriesFromAllowFrom({ + cfg, + resolveAccount, + resolveAllowFrom: (account) => account.allowFrom, + normalizeId: (entry) => entry.replace(/^user:/i, ""), + query: "a", + }); + + expect(entries).toEqual([{ kind: "user", id: "alice" }]); + }); + + it("lists group entries from resolved account map keys", () => { + const entries = listResolvedDirectoryGroupEntriesFromMapKeys({ + cfg, + resolveAccount, + resolveGroups: (account) => account.groups, + normalizeId: (entry) => entry.replace(/^room:/i, ""), + }); + + expect(entries).toEqual([ + { kind: "group", id: "a" }, + { kind: "group", id: "b" }, + ]); + }); + + it("lists entries from resolved account sources", () => { + const entries = listResolvedDirectoryEntriesFromSources({ + cfg, + kind: "user", + resolveAccount, + resolveSources: (account) => [account.allowFrom, ["user:carla", "user:alice"]], + normalizeId: (entry) => entry.replace(/^user:/i, ""), + query: "a", + limit: 2, + }); + + expectUserDirectoryEntries(entries); + }); +}); diff --git a/src/channels/plugins/directory-config-helpers.ts b/src/channels/plugins/directory-config-helpers.ts index 94dc5c3324c..6ee329e578a 100644 --- a/src/channels/plugins/directory-config-helpers.ts +++ b/src/channels/plugins/directory-config-helpers.ts @@ -1,3 +1,5 @@ +import type { OpenClawConfig } from "../../config/types.js"; +import type { DirectoryConfigParams } from "./directory-types.js"; import type { ChannelDirectoryEntry } from "./types.js"; function resolveDirectoryQuery(query?: string | null): string { @@ -81,6 +83,62 @@ export function collectNormalizedDirectoryIds(params: { return Array.from(ids); } +export function listDirectoryEntriesFromSources(params: { + kind: "user" | "group"; + sources: Iterable[]; + query?: string | null; + limit?: number | null; + normalizeId: (entry: string) => string | null | undefined; +}): ChannelDirectoryEntry[] { + const ids = collectNormalizedDirectoryIds({ + sources: params.sources, + normalizeId: params.normalizeId, + }); + return toDirectoryEntries(params.kind, applyDirectoryQueryAndLimit(ids, params)); +} + +export function listInspectedDirectoryEntriesFromSources( + params: DirectoryConfigParams & { + kind: "user" | "group"; + inspectAccount: ( + cfg: OpenClawConfig, + accountId?: string | null, + ) => InspectedAccount | null | undefined; + resolveSources: (account: InspectedAccount) => Iterable[]; + normalizeId: (entry: string) => string | null | undefined; + }, +): ChannelDirectoryEntry[] { + const account = params.inspectAccount(params.cfg, params.accountId); + if (!account) { + return []; + } + return listDirectoryEntriesFromSources({ + kind: params.kind, + sources: params.resolveSources(account), + query: params.query, + limit: params.limit, + normalizeId: params.normalizeId, + }); +} + +export function listResolvedDirectoryEntriesFromSources( + params: DirectoryConfigParams & { + kind: "user" | "group"; + resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => ResolvedAccount; + resolveSources: (account: ResolvedAccount) => Iterable[]; + normalizeId: (entry: string) => string | null | undefined; + }, +): ChannelDirectoryEntry[] { + const account = params.resolveAccount(params.cfg, params.accountId); + return listDirectoryEntriesFromSources({ + kind: params.kind, + sources: params.resolveSources(account), + query: params.query, + limit: params.limit, + normalizeId: params.normalizeId, + }); +} + export function listDirectoryUserEntriesFromAllowFrom(params: { allowFrom?: readonly unknown[]; query?: string | null; @@ -152,3 +210,35 @@ export function listDirectoryGroupEntriesFromMapKeysAndAllowFrom(params: { ]); return toDirectoryEntries("group", applyDirectoryQueryAndLimit(ids, params)); } + +export function listResolvedDirectoryUserEntriesFromAllowFrom( + params: DirectoryConfigParams & { + resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => ResolvedAccount; + resolveAllowFrom: (account: ResolvedAccount) => readonly unknown[] | undefined; + normalizeId?: (entry: string) => string | null | undefined; + }, +): ChannelDirectoryEntry[] { + const account = params.resolveAccount(params.cfg, params.accountId); + return listDirectoryUserEntriesFromAllowFrom({ + allowFrom: params.resolveAllowFrom(account), + query: params.query, + limit: params.limit, + normalizeId: params.normalizeId, + }); +} + +export function listResolvedDirectoryGroupEntriesFromMapKeys( + params: DirectoryConfigParams & { + resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => ResolvedAccount; + resolveGroups: (account: ResolvedAccount) => Record | undefined; + normalizeId?: (entry: string) => string | null | undefined; + }, +): ChannelDirectoryEntry[] { + const account = params.resolveAccount(params.cfg, params.accountId); + return listDirectoryGroupEntriesFromMapKeys({ + groups: params.resolveGroups(account), + query: params.query, + limit: params.limit, + normalizeId: params.normalizeId, + }); +} diff --git a/src/channels/plugins/group-policy-warnings.test.ts b/src/channels/plugins/group-policy-warnings.test.ts index 51a77d992f1..c70e089a288 100644 --- a/src/channels/plugins/group-policy-warnings.test.ts +++ b/src/channels/plugins/group-policy-warnings.test.ts @@ -2,6 +2,16 @@ import { describe, expect, it } from "vitest"; import { collectAllowlistProviderGroupPolicyWarnings, collectAllowlistProviderRestrictSendersWarnings, + composeWarningCollectors, + createAllowlistProviderGroupPolicyWarningCollector, + createConditionalWarningCollector, + createAllowlistProviderOpenWarningCollector, + createAllowlistProviderRestrictSendersWarningCollector, + createAllowlistProviderRouteAllowlistWarningCollector, + createOpenGroupPolicyRestrictSendersWarningCollector, + createOpenProviderGroupPolicyWarningCollector, + createOpenProviderConfiguredRouteWarningCollector, + projectWarningCollector, collectOpenGroupPolicyConfiguredRouteWarnings, collectOpenProviderGroupPolicyWarnings, collectOpenGroupPolicyRestrictSendersWarnings, @@ -13,6 +23,35 @@ import { } from "./group-policy-warnings.js"; describe("group policy warning builders", () => { + it("composes warning collectors", () => { + const collect = composeWarningCollectors<{ enabled: boolean }>( + () => ["a"], + ({ enabled }) => (enabled ? ["b"] : []), + ); + + expect(collect({ enabled: true })).toEqual(["a", "b"]); + expect(collect({ enabled: false })).toEqual(["a"]); + }); + + it("projects warning collector inputs", () => { + const collect = projectWarningCollector( + ({ value }: { value: string }) => value, + (value: string) => [value.toUpperCase()], + ); + + expect(collect({ value: "abc" })).toEqual(["ABC"]); + }); + + it("builds conditional warning collectors", () => { + const collect = createConditionalWarningCollector<{ open: boolean; token?: string }>( + ({ open }) => (open ? "open" : undefined), + ({ token }) => (token ? undefined : ["missing token", "cannot send replies"]), + ); + + expect(collect({ open: true })).toEqual(["open", "missing token", "cannot send replies"]); + expect(collect({ open: false, token: "x" })).toEqual([]); + }); + it("builds base open-policy warning", () => { expect( buildOpenGroupPolicyWarning({ @@ -253,4 +292,205 @@ describe("group policy warning builders", () => { }), ).toEqual([buildOpenGroupPolicyWarning(params.missingRouteAllowlist)]); }); + + it("builds account-aware allowlist-provider restrict-senders collectors", () => { + const collectWarnings = createAllowlistProviderRestrictSendersWarningCollector<{ + groupPolicy?: "open" | "allowlist" | "disabled"; + }>({ + providerConfigPresent: (cfg) => cfg.channels?.example !== undefined, + resolveGroupPolicy: (account) => account.groupPolicy, + surface: "Example groups", + openScope: "any member", + groupPolicyPath: "channels.example.groupPolicy", + groupAllowFromPath: "channels.example.groupAllowFrom", + }); + + expect( + collectWarnings({ + account: { groupPolicy: "open" }, + cfg: { channels: { example: {} } }, + }), + ).toEqual([ + buildOpenGroupPolicyRestrictSendersWarning({ + surface: "Example groups", + openScope: "any member", + groupPolicyPath: "channels.example.groupPolicy", + groupAllowFromPath: "channels.example.groupAllowFrom", + }), + ]); + }); + + it("builds config-aware allowlist-provider collectors", () => { + const collectWarnings = createAllowlistProviderGroupPolicyWarningCollector<{ + cfg: { + channels?: { + defaults?: { groupPolicy?: "open" | "allowlist" | "disabled" }; + example?: unknown; + }; + }; + channelLabel: string; + configuredGroupPolicy?: "open" | "allowlist" | "disabled"; + }>({ + providerConfigPresent: (cfg) => cfg.channels?.example !== undefined, + resolveGroupPolicy: ({ configuredGroupPolicy }) => configuredGroupPolicy, + collect: ({ channelLabel, groupPolicy }) => + groupPolicy === "open" ? [`warn:${channelLabel}`] : [], + }); + + expect( + collectWarnings({ + cfg: { channels: { example: {} } }, + channelLabel: "example", + configuredGroupPolicy: "open", + }), + ).toEqual(["warn:example"]); + }); + + it("builds account-aware route-allowlist collectors", () => { + const collectWarnings = createAllowlistProviderRouteAllowlistWarningCollector<{ + groupPolicy?: "open" | "allowlist" | "disabled"; + groups?: Record; + }>({ + providerConfigPresent: (cfg) => cfg.channels?.example !== undefined, + resolveGroupPolicy: (account) => account.groupPolicy, + resolveRouteAllowlistConfigured: (account) => Object.keys(account.groups ?? {}).length > 0, + restrictSenders: { + surface: "Example groups", + openScope: "any member in allowed groups", + groupPolicyPath: "channels.example.groupPolicy", + groupAllowFromPath: "channels.example.groupAllowFrom", + }, + noRouteAllowlist: { + surface: "Example groups", + routeAllowlistPath: "channels.example.groups", + routeScope: "group", + groupPolicyPath: "channels.example.groupPolicy", + groupAllowFromPath: "channels.example.groupAllowFrom", + }, + }); + + expect( + collectWarnings({ + account: { groupPolicy: "open", groups: {} }, + cfg: { channels: { example: {} } }, + }), + ).toEqual([ + buildOpenGroupPolicyNoRouteAllowlistWarning({ + surface: "Example groups", + routeAllowlistPath: "channels.example.groups", + routeScope: "group", + groupPolicyPath: "channels.example.groupPolicy", + groupAllowFromPath: "channels.example.groupAllowFrom", + }), + ]); + }); + + it("builds account-aware configured-route collectors", () => { + const collectWarnings = createOpenProviderConfiguredRouteWarningCollector<{ + groupPolicy?: "open" | "allowlist" | "disabled"; + channels?: Record; + }>({ + providerConfigPresent: (cfg) => cfg.channels?.example !== undefined, + resolveGroupPolicy: (account) => account.groupPolicy, + resolveRouteAllowlistConfigured: (account) => Object.keys(account.channels ?? {}).length > 0, + configureRouteAllowlist: { + surface: "Example channels", + openScope: "any channel not explicitly denied", + groupPolicyPath: "channels.example.groupPolicy", + routeAllowlistPath: "channels.example.channels", + }, + missingRouteAllowlist: { + surface: "Example channels", + openBehavior: "with no route allowlist; any channel can trigger (mention-gated)", + remediation: + 'Set channels.example.groupPolicy="allowlist" and configure channels.example.channels', + }, + }); + + expect( + collectWarnings({ + account: { groupPolicy: "open", channels: { general: true } }, + cfg: { channels: { example: {} } }, + }), + ).toEqual([ + buildOpenGroupPolicyConfigureRouteAllowlistWarning({ + surface: "Example channels", + openScope: "any channel not explicitly denied", + groupPolicyPath: "channels.example.groupPolicy", + routeAllowlistPath: "channels.example.channels", + }), + ]); + }); + + it("builds config-aware open-provider collectors", () => { + const collectWarnings = createOpenProviderGroupPolicyWarningCollector<{ + cfg: { channels?: { example?: unknown } }; + configuredGroupPolicy?: "open" | "allowlist" | "disabled"; + }>({ + providerConfigPresent: (cfg) => cfg.channels?.example !== undefined, + resolveGroupPolicy: ({ configuredGroupPolicy }) => configuredGroupPolicy, + collect: ({ groupPolicy }) => [groupPolicy], + }); + + expect( + collectWarnings({ + cfg: { channels: { example: {} } }, + configuredGroupPolicy: "open", + }), + ).toEqual(["open"]); + }); + + it("builds account-aware simple open warning collectors", () => { + const collectWarnings = createAllowlistProviderOpenWarningCollector<{ + groupPolicy?: "open" | "allowlist" | "disabled"; + }>({ + providerConfigPresent: (cfg) => cfg.channels?.example !== undefined, + resolveGroupPolicy: (account) => account.groupPolicy, + buildOpenWarning: { + surface: "Example channels", + openBehavior: "allows any channel to trigger (mention-gated)", + remediation: + 'Set channels.example.groupPolicy="allowlist" and configure channels.example.channels', + }, + }); + + expect( + collectWarnings({ + account: { groupPolicy: "open" }, + cfg: { channels: { example: {} } }, + }), + ).toEqual([ + buildOpenGroupPolicyWarning({ + surface: "Example channels", + openBehavior: "allows any channel to trigger (mention-gated)", + remediation: + 'Set channels.example.groupPolicy="allowlist" and configure channels.example.channels', + }), + ]); + }); + + it("builds direct account-aware open-policy restrict-senders collectors", () => { + const collectWarnings = createOpenGroupPolicyRestrictSendersWarningCollector<{ + groupPolicy?: "open" | "allowlist" | "disabled"; + }>({ + resolveGroupPolicy: (account) => account.groupPolicy, + defaultGroupPolicy: "allowlist", + surface: "Example groups", + openScope: "any member", + groupPolicyPath: "channels.example.groupPolicy", + groupAllowFromPath: "channels.example.groupAllowFrom", + mentionGated: false, + }); + + expect(collectWarnings({ groupPolicy: "allowlist" })).toEqual([]); + expect(collectWarnings({ groupPolicy: "open" })).toEqual([ + buildOpenGroupPolicyRestrictSendersWarning({ + surface: "Example groups", + openScope: "any member", + groupPolicyPath: "channels.example.groupPolicy", + groupAllowFromPath: "channels.example.groupAllowFrom", + mentionGated: false, + }), + ]); + }); }); diff --git a/src/channels/plugins/group-policy-warnings.ts b/src/channels/plugins/group-policy-warnings.ts index 67d8c952b02..776ac6ddba4 100644 --- a/src/channels/plugins/group-policy-warnings.ts +++ b/src/channels/plugins/group-policy-warnings.ts @@ -7,6 +7,40 @@ import { import type { GroupPolicy } from "../../config/types.base.js"; type GroupPolicyWarningCollector = (groupPolicy: GroupPolicy) => string[]; +type AccountGroupPolicyWarningCollector = (params: { + account: ResolvedAccount; + cfg: OpenClawConfig; +}) => string[]; +type ConfigGroupPolicyWarningCollector = ( + params: Params, +) => string[]; +type WarningCollector = (params: Params) => string[]; + +export function composeWarningCollectors( + ...collectors: Array | null | undefined> +): WarningCollector { + return (params) => collectors.flatMap((collector) => collector?.(params) ?? []); +} + +export function projectWarningCollector( + project: (params: Params) => Projected, + collector: WarningCollector, +): WarningCollector { + return (params) => collector(project(params)); +} + +export function createConditionalWarningCollector( + ...collectors: Array<(params: Params) => string | string[] | null | undefined | false> +): WarningCollector { + return (params) => + collectors.flatMap((collector) => { + const next = collector(params); + if (!next) { + return []; + } + return Array.isArray(next) ? next : [next]; + }); +} export function buildOpenGroupPolicyWarning(params: { surface: string; @@ -96,6 +130,50 @@ export function collectAllowlistProviderRestrictSendersWarnings( }); } +/** Build an account-aware allowlist-provider warning collector for sender-restricted groups. */ +export function createAllowlistProviderRestrictSendersWarningCollector( + params: { + providerConfigPresent: (cfg: OpenClawConfig) => boolean; + resolveGroupPolicy: (account: ResolvedAccount) => GroupPolicy | null | undefined; + } & Omit< + Parameters[0], + "cfg" | "providerConfigPresent" | "configuredGroupPolicy" + >, +): AccountGroupPolicyWarningCollector { + return createAllowlistProviderGroupPolicyWarningCollector({ + providerConfigPresent: params.providerConfigPresent, + resolveGroupPolicy: ({ account }: { account: ResolvedAccount; cfg: OpenClawConfig }) => + params.resolveGroupPolicy(account), + collect: ({ groupPolicy }) => + collectOpenGroupPolicyRestrictSendersWarnings({ + groupPolicy, + surface: params.surface, + openScope: params.openScope, + groupPolicyPath: params.groupPolicyPath, + groupAllowFromPath: params.groupAllowFromPath, + mentionGated: params.mentionGated, + }), + }); +} + +/** Build a direct account-aware warning collector when the policy already lives on the account. */ +export function createOpenGroupPolicyRestrictSendersWarningCollector( + params: { + resolveGroupPolicy: (account: ResolvedAccount) => GroupPolicy | null | undefined; + defaultGroupPolicy?: GroupPolicy; + } & Omit[0], "groupPolicy">, +): (account: ResolvedAccount) => string[] { + return (account) => + collectOpenGroupPolicyRestrictSendersWarnings({ + groupPolicy: params.resolveGroupPolicy(account) ?? params.defaultGroupPolicy ?? "allowlist", + surface: params.surface, + openScope: params.openScope, + groupPolicyPath: params.groupPolicyPath, + groupAllowFromPath: params.groupAllowFromPath, + mentionGated: params.mentionGated, + }); +} + export function collectAllowlistProviderGroupPolicyWarnings(params: { cfg: OpenClawConfig; providerConfigPresent: boolean; @@ -111,6 +189,23 @@ export function collectAllowlistProviderGroupPolicyWarnings(params: { return params.collect(groupPolicy); } +/** Build a config-aware allowlist-provider warning collector from an arbitrary policy resolver. */ +export function createAllowlistProviderGroupPolicyWarningCollector< + Params extends { cfg: OpenClawConfig }, +>(params: { + providerConfigPresent: (cfg: OpenClawConfig) => boolean; + resolveGroupPolicy: (params: Params) => GroupPolicy | null | undefined; + collect: (params: Params & { groupPolicy: GroupPolicy }) => string[]; +}): ConfigGroupPolicyWarningCollector { + return (runtime) => + collectAllowlistProviderGroupPolicyWarnings({ + cfg: runtime.cfg, + providerConfigPresent: params.providerConfigPresent(runtime.cfg), + configuredGroupPolicy: params.resolveGroupPolicy(runtime), + collect: (groupPolicy) => params.collect({ ...runtime, groupPolicy }), + }); +} + export function collectOpenProviderGroupPolicyWarnings(params: { cfg: OpenClawConfig; providerConfigPresent: boolean; @@ -126,6 +221,38 @@ export function collectOpenProviderGroupPolicyWarnings(params: { return params.collect(groupPolicy); } +/** Build a config-aware open-provider warning collector from an arbitrary policy resolver. */ +export function createOpenProviderGroupPolicyWarningCollector< + Params extends { cfg: OpenClawConfig }, +>(params: { + providerConfigPresent: (cfg: OpenClawConfig) => boolean; + resolveGroupPolicy: (params: Params) => GroupPolicy | null | undefined; + collect: (params: Params & { groupPolicy: GroupPolicy }) => string[]; +}): ConfigGroupPolicyWarningCollector { + return (runtime) => + collectOpenProviderGroupPolicyWarnings({ + cfg: runtime.cfg, + providerConfigPresent: params.providerConfigPresent(runtime.cfg), + configuredGroupPolicy: params.resolveGroupPolicy(runtime), + collect: (groupPolicy) => params.collect({ ...runtime, groupPolicy }), + }); +} + +/** Build an account-aware allowlist-provider warning collector for simple open-policy warnings. */ +export function createAllowlistProviderOpenWarningCollector(params: { + providerConfigPresent: (cfg: OpenClawConfig) => boolean; + resolveGroupPolicy: (account: ResolvedAccount) => GroupPolicy | null | undefined; + buildOpenWarning: Parameters[0]; +}): AccountGroupPolicyWarningCollector { + return createAllowlistProviderGroupPolicyWarningCollector({ + providerConfigPresent: params.providerConfigPresent, + resolveGroupPolicy: ({ account }: { account: ResolvedAccount; cfg: OpenClawConfig }) => + params.resolveGroupPolicy(account), + collect: ({ groupPolicy }) => + groupPolicy === "open" ? [buildOpenGroupPolicyWarning(params.buildOpenWarning)] : [], + }); +} + export function collectOpenGroupPolicyRouteAllowlistWarnings(params: { groupPolicy: "open" | "allowlist" | "disabled"; routeAllowlistConfigured: boolean; @@ -141,6 +268,28 @@ export function collectOpenGroupPolicyRouteAllowlistWarnings(params: { return [buildOpenGroupPolicyNoRouteAllowlistWarning(params.noRouteAllowlist)]; } +/** Build an account-aware allowlist-provider warning collector for route-allowlisted groups. */ +export function createAllowlistProviderRouteAllowlistWarningCollector(params: { + providerConfigPresent: (cfg: OpenClawConfig) => boolean; + resolveGroupPolicy: (account: ResolvedAccount) => GroupPolicy | null | undefined; + resolveRouteAllowlistConfigured: (account: ResolvedAccount) => boolean; + restrictSenders: Parameters[0]; + noRouteAllowlist: Parameters[0]; +}): AccountGroupPolicyWarningCollector { + return createAllowlistProviderGroupPolicyWarningCollector({ + providerConfigPresent: params.providerConfigPresent, + resolveGroupPolicy: ({ account }: { account: ResolvedAccount; cfg: OpenClawConfig }) => + params.resolveGroupPolicy(account), + collect: ({ account, groupPolicy }) => + collectOpenGroupPolicyRouteAllowlistWarnings({ + groupPolicy, + routeAllowlistConfigured: params.resolveRouteAllowlistConfigured(account), + restrictSenders: params.restrictSenders, + noRouteAllowlist: params.noRouteAllowlist, + }), + }); +} + export function collectOpenGroupPolicyConfiguredRouteWarnings(params: { groupPolicy: "open" | "allowlist" | "disabled"; routeAllowlistConfigured: boolean; @@ -155,3 +304,25 @@ export function collectOpenGroupPolicyConfiguredRouteWarnings(params: { } return [buildOpenGroupPolicyWarning(params.missingRouteAllowlist)]; } + +/** Build an account-aware open-provider warning collector for configured-route channels. */ +export function createOpenProviderConfiguredRouteWarningCollector(params: { + providerConfigPresent: (cfg: OpenClawConfig) => boolean; + resolveGroupPolicy: (account: ResolvedAccount) => GroupPolicy | null | undefined; + resolveRouteAllowlistConfigured: (account: ResolvedAccount) => boolean; + configureRouteAllowlist: Parameters[0]; + missingRouteAllowlist: Parameters[0]; +}): AccountGroupPolicyWarningCollector { + return createOpenProviderGroupPolicyWarningCollector({ + providerConfigPresent: params.providerConfigPresent, + resolveGroupPolicy: ({ account }: { account: ResolvedAccount; cfg: OpenClawConfig }) => + params.resolveGroupPolicy(account), + collect: ({ account, groupPolicy }) => + collectOpenGroupPolicyConfiguredRouteWarnings({ + groupPolicy, + routeAllowlistConfigured: params.resolveRouteAllowlistConfigured(account), + configureRouteAllowlist: params.configureRouteAllowlist, + missingRouteAllowlist: params.missingRouteAllowlist, + }), + }); +} diff --git a/src/channels/plugins/outbound/direct-text-media.test.ts b/src/channels/plugins/outbound/direct-text-media.test.ts new file mode 100644 index 00000000000..de979a7704d --- /dev/null +++ b/src/channels/plugins/outbound/direct-text-media.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from "vitest"; +import { + sendPayloadMediaSequenceAndFinalize, + sendPayloadMediaSequenceOrFallback, +} from "./direct-text-media.js"; + +describe("sendPayloadMediaSequenceOrFallback", () => { + it("uses the no-media sender when no media entries exist", async () => { + const send = vi.fn(); + const sendNoMedia = vi.fn(async () => ({ messageId: "text-1" })); + + await expect( + sendPayloadMediaSequenceOrFallback({ + text: "hello", + mediaUrls: [], + send, + sendNoMedia, + fallbackResult: { messageId: "" }, + }), + ).resolves.toEqual({ messageId: "text-1" }); + + expect(send).not.toHaveBeenCalled(); + expect(sendNoMedia).toHaveBeenCalledOnce(); + }); + + it("returns the last media send result and clears text after the first media", async () => { + const calls: Array<{ text: string; mediaUrl: string; isFirst: boolean }> = []; + + await expect( + sendPayloadMediaSequenceOrFallback({ + text: "caption", + mediaUrls: ["a", "b"], + send: async ({ text, mediaUrl, isFirst }) => { + calls.push({ text, mediaUrl, isFirst }); + return { messageId: mediaUrl }; + }, + fallbackResult: { messageId: "" }, + }), + ).resolves.toEqual({ messageId: "b" }); + + expect(calls).toEqual([ + { text: "caption", mediaUrl: "a", isFirst: true }, + { text: "", mediaUrl: "b", isFirst: false }, + ]); + }); +}); + +describe("sendPayloadMediaSequenceAndFinalize", () => { + it("skips media sends and finalizes directly when no media entries exist", async () => { + const send = vi.fn(); + const finalize = vi.fn(async () => ({ messageId: "final-1" })); + + await expect( + sendPayloadMediaSequenceAndFinalize({ + text: "hello", + mediaUrls: [], + send, + finalize, + }), + ).resolves.toEqual({ messageId: "final-1" }); + + expect(send).not.toHaveBeenCalled(); + expect(finalize).toHaveBeenCalledOnce(); + }); + + it("sends the media sequence before the finalizing send", async () => { + const send = vi.fn(async ({ mediaUrl }: { mediaUrl: string }) => ({ messageId: mediaUrl })); + const finalize = vi.fn(async () => ({ messageId: "final-2" })); + + await expect( + sendPayloadMediaSequenceAndFinalize({ + text: "", + mediaUrls: ["a", "b"], + send, + finalize, + }), + ).resolves.toEqual({ messageId: "final-2" }); + + expect(send).toHaveBeenCalledTimes(2); + expect(finalize).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/channels/plugins/outbound/direct-text-media.ts b/src/channels/plugins/outbound/direct-text-media.ts index ea813fcf75b..c0b4caafeba 100644 --- a/src/channels/plugins/outbound/direct-text-media.ts +++ b/src/channels/plugins/outbound/direct-text-media.ts @@ -1,3 +1,4 @@ +import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload"; import { chunkText } from "../../../auto-reply/chunk.js"; import type { OpenClawConfig } from "../../../config/config.js"; import type { OutboundSendDeps } from "../../../infra/outbound/deliver.js"; @@ -29,7 +30,7 @@ type SendPayloadAdapter = Pick< >; export function resolvePayloadMediaUrls(payload: SendPayloadContext["payload"]): string[] { - return payload.mediaUrls?.length ? payload.mediaUrls : payload.mediaUrl ? [payload.mediaUrl] : []; + return resolveOutboundMediaUrls(payload); } export async function sendPayloadMediaSequence(params: { @@ -58,6 +59,41 @@ export async function sendPayloadMediaSequence(params: { return lastResult; } +export async function sendPayloadMediaSequenceOrFallback(params: { + text: string; + mediaUrls: readonly string[]; + send: (input: { + text: string; + mediaUrl: string; + index: number; + isFirst: boolean; + }) => Promise; + fallbackResult: TResult; + sendNoMedia?: () => Promise; +}): Promise { + if (params.mediaUrls.length === 0) { + return params.sendNoMedia ? await params.sendNoMedia() : params.fallbackResult; + } + return (await sendPayloadMediaSequence(params)) ?? params.fallbackResult; +} + +export async function sendPayloadMediaSequenceAndFinalize(params: { + text: string; + mediaUrls: readonly string[]; + send: (input: { + text: string; + mediaUrl: string; + index: number; + isFirst: boolean; + }) => Promise; + finalize: () => Promise; +}): Promise { + if (params.mediaUrls.length > 0) { + await sendPayloadMediaSequence(params); + } + return await params.finalize(); +} + export async function sendTextMediaPayload(params: { channel: string; ctx: SendPayloadContext; diff --git a/src/channels/plugins/pairing-adapters.test.ts b/src/channels/plugins/pairing-adapters.test.ts new file mode 100644 index 00000000000..7fee2155414 --- /dev/null +++ b/src/channels/plugins/pairing-adapters.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createLoggedPairingApprovalNotifier, + createPairingPrefixStripper, + createTextPairingAdapter, +} from "./pairing-adapters.js"; + +describe("pairing adapters", () => { + it("strips prefixes and applies optional mapping", () => { + const strip = createPairingPrefixStripper(/^(telegram|tg):/i); + const lower = createPairingPrefixStripper(/^nextcloud:/i, (entry) => entry.toLowerCase()); + expect(strip("telegram:123")).toBe("123"); + expect(strip("tg:123")).toBe("123"); + expect(lower("nextcloud:USER")).toBe("user"); + }); + + it("builds text pairing adapters", async () => { + const notify = vi.fn(async () => {}); + const pairing = createTextPairingAdapter({ + idLabel: "telegramUserId", + message: "approved", + normalizeAllowEntry: createPairingPrefixStripper(/^telegram:/i), + notify, + }); + expect(pairing.idLabel).toBe("telegramUserId"); + expect(pairing.normalizeAllowEntry?.("telegram:123")).toBe("123"); + await pairing.notifyApproval?.({ cfg: {}, id: "123" }); + expect(notify).toHaveBeenCalledWith({ cfg: {}, id: "123", message: "approved" }); + }); + + it("builds logger-backed approval notifiers", async () => { + const log = vi.fn(); + const notify = createLoggedPairingApprovalNotifier(({ id }) => `approved ${id}`, log); + await notify({ cfg: {}, id: "u-1" }); + expect(log).toHaveBeenCalledWith("approved u-1"); + }); +}); diff --git a/src/channels/plugins/pairing-adapters.ts b/src/channels/plugins/pairing-adapters.ts new file mode 100644 index 00000000000..583fe44a448 --- /dev/null +++ b/src/channels/plugins/pairing-adapters.ts @@ -0,0 +1,34 @@ +import type { ChannelPairingAdapter } from "./types.adapters.js"; + +type PairingNotifyParams = Parameters>[0]; + +export function createPairingPrefixStripper( + prefixRe: RegExp, + map: (entry: string) => string = (entry) => entry, +): NonNullable { + return (entry) => map(entry.replace(prefixRe, "")); +} + +export function createLoggedPairingApprovalNotifier( + format: string | ((params: PairingNotifyParams) => string), + log: (message: string) => void = console.log, +): NonNullable { + return async (params) => { + log(typeof format === "function" ? format(params) : format); + }; +} + +export function createTextPairingAdapter(params: { + idLabel: string; + message: string; + normalizeAllowEntry?: ChannelPairingAdapter["normalizeAllowEntry"]; + notify: (params: PairingNotifyParams & { message: string }) => Promise | void; +}): ChannelPairingAdapter { + return { + idLabel: params.idLabel, + normalizeAllowEntry: params.normalizeAllowEntry, + notifyApproval: async (ctx) => { + await params.notify({ ...ctx, message: params.message }); + }, + }; +} diff --git a/src/channels/plugins/runtime-forwarders.test.ts b/src/channels/plugins/runtime-forwarders.test.ts new file mode 100644 index 00000000000..8b927a319f3 --- /dev/null +++ b/src/channels/plugins/runtime-forwarders.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createRuntimeDirectoryLiveAdapter, + createRuntimeOutboundDelegates, +} from "./runtime-forwarders.js"; + +describe("createRuntimeDirectoryLiveAdapter", () => { + it("forwards live directory calls through the runtime getter", async () => { + const listPeersLive = vi.fn(async (_ctx: unknown) => [{ kind: "user" as const, id: "alice" }]); + const adapter = createRuntimeDirectoryLiveAdapter({ + getRuntime: async () => ({ listPeersLive }), + listPeersLive: (runtime) => runtime.listPeersLive, + }); + + await expect( + adapter.listPeersLive?.({ cfg: {} as never, runtime: {} as never, query: "a", limit: 1 }), + ).resolves.toEqual([{ kind: "user", id: "alice" }]); + expect(listPeersLive).toHaveBeenCalled(); + }); +}); + +describe("createRuntimeOutboundDelegates", () => { + it("forwards outbound methods through the runtime getter", async () => { + const sendText = vi.fn(async () => ({ channel: "x", messageId: "1" })); + const outbound = createRuntimeOutboundDelegates({ + getRuntime: async () => ({ outbound: { sendText } }), + sendText: { resolve: (runtime) => runtime.outbound.sendText }, + }); + + await expect(outbound.sendText?.({ cfg: {} as never, to: "a", text: "hi" })).resolves.toEqual({ + channel: "x", + messageId: "1", + }); + expect(sendText).toHaveBeenCalled(); + }); + + it("throws the configured unavailable message", async () => { + const outbound = createRuntimeOutboundDelegates({ + getRuntime: async () => ({ outbound: {} }), + sendPoll: { + resolve: () => undefined, + unavailableMessage: "poll unavailable", + }, + }); + + await expect( + outbound.sendPoll?.({ + cfg: {} as never, + to: "a", + poll: { question: "q", options: ["a"] }, + }), + ).rejects.toThrow("poll unavailable"); + }); +}); diff --git a/src/channels/plugins/runtime-forwarders.ts b/src/channels/plugins/runtime-forwarders.ts new file mode 100644 index 00000000000..9730e4a94e8 --- /dev/null +++ b/src/channels/plugins/runtime-forwarders.ts @@ -0,0 +1,117 @@ +import type { ChannelDirectoryAdapter, ChannelOutboundAdapter } from "./types.adapters.js"; + +type MaybePromise = T | Promise; + +type DirectoryListMethod = "listPeersLive" | "listGroupsLive" | "listGroupMembers"; +type OutboundMethod = "sendText" | "sendMedia" | "sendPoll"; + +type DirectoryListParams = Parameters>[0]; +type DirectoryGroupMembersParams = Parameters< + NonNullable +>[0]; +type SendTextParams = Parameters>[0]; +type SendMediaParams = Parameters>[0]; +type SendPollParams = Parameters>[0]; + +async function resolveForwardedMethod(params: { + getRuntime: () => MaybePromise; + resolve: (runtime: Runtime) => Fn | null | undefined; + unavailableMessage?: string; +}): Promise { + const runtime = await params.getRuntime(); + const method = params.resolve(runtime); + if (method) { + return method; + } + throw new Error(params.unavailableMessage ?? "Runtime method is unavailable"); +} + +export function createRuntimeDirectoryLiveAdapter(params: { + getRuntime: () => MaybePromise; + listPeersLive?: (runtime: Runtime) => ChannelDirectoryAdapter["listPeersLive"] | null | undefined; + listGroupsLive?: ( + runtime: Runtime, + ) => ChannelDirectoryAdapter["listGroupsLive"] | null | undefined; + listGroupMembers?: ( + runtime: Runtime, + ) => ChannelDirectoryAdapter["listGroupMembers"] | null | undefined; +}): Pick { + return { + listPeersLive: params.listPeersLive + ? async (ctx: DirectoryListParams) => + await ( + await resolveForwardedMethod({ + getRuntime: params.getRuntime, + resolve: params.listPeersLive!, + }) + )(ctx) + : undefined, + listGroupsLive: params.listGroupsLive + ? async (ctx: DirectoryListParams) => + await ( + await resolveForwardedMethod({ + getRuntime: params.getRuntime, + resolve: params.listGroupsLive!, + }) + )(ctx) + : undefined, + listGroupMembers: params.listGroupMembers + ? async (ctx: DirectoryGroupMembersParams) => + await ( + await resolveForwardedMethod({ + getRuntime: params.getRuntime, + resolve: params.listGroupMembers!, + }) + )(ctx) + : undefined, + }; +} + +export function createRuntimeOutboundDelegates(params: { + getRuntime: () => MaybePromise; + sendText?: { + resolve: (runtime: Runtime) => ChannelOutboundAdapter["sendText"] | null | undefined; + unavailableMessage?: string; + }; + sendMedia?: { + resolve: (runtime: Runtime) => ChannelOutboundAdapter["sendMedia"] | null | undefined; + unavailableMessage?: string; + }; + sendPoll?: { + resolve: (runtime: Runtime) => ChannelOutboundAdapter["sendPoll"] | null | undefined; + unavailableMessage?: string; + }; +}): Pick { + return { + sendText: params.sendText + ? async (ctx: SendTextParams) => + await ( + await resolveForwardedMethod({ + getRuntime: params.getRuntime, + resolve: params.sendText!.resolve, + unavailableMessage: params.sendText!.unavailableMessage, + }) + )(ctx) + : undefined, + sendMedia: params.sendMedia + ? async (ctx: SendMediaParams) => + await ( + await resolveForwardedMethod({ + getRuntime: params.getRuntime, + resolve: params.sendMedia!.resolve, + unavailableMessage: params.sendMedia!.unavailableMessage, + }) + )(ctx) + : undefined, + sendPoll: params.sendPoll + ? async (ctx: SendPollParams) => + await ( + await resolveForwardedMethod({ + getRuntime: params.getRuntime, + resolve: params.sendPoll!.resolve, + unavailableMessage: params.sendPoll!.unavailableMessage, + }) + )(ctx) + : undefined, + }; +} diff --git a/src/channels/plugins/target-resolvers.test.ts b/src/channels/plugins/target-resolvers.test.ts new file mode 100644 index 00000000000..161b94a8fb2 --- /dev/null +++ b/src/channels/plugins/target-resolvers.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + buildUnresolvedTargetResults, + resolveTargetsWithOptionalToken, +} from "./target-resolvers.js"; + +describe("buildUnresolvedTargetResults", () => { + it("marks each input unresolved with the same note", () => { + expect(buildUnresolvedTargetResults(["a", "b"], "missing token")).toEqual([ + { input: "a", resolved: false, note: "missing token" }, + { input: "b", resolved: false, note: "missing token" }, + ]); + }); +}); + +describe("resolveTargetsWithOptionalToken", () => { + it("returns unresolved entries when the token is missing", async () => { + const resolved = await resolveTargetsWithOptionalToken({ + inputs: ["alice"], + missingTokenNote: "missing token", + resolveWithToken: async () => [{ input: "alice", id: "1" }], + mapResolved: (entry) => ({ input: entry.input, resolved: true, id: entry.id }), + }); + + expect(resolved).toEqual([{ input: "alice", resolved: false, note: "missing token" }]); + }); + + it("resolves and maps entries when a token is present", async () => { + const resolved = await resolveTargetsWithOptionalToken({ + token: " x ", + inputs: ["alice"], + missingTokenNote: "missing token", + resolveWithToken: async ({ token, inputs }) => + inputs.map((input) => ({ input, id: `${token}:${input}` })), + mapResolved: (entry) => ({ input: entry.input, resolved: true, id: entry.id }), + }); + + expect(resolved).toEqual([{ input: "alice", resolved: true, id: "x:alice" }]); + }); +}); diff --git a/src/channels/plugins/target-resolvers.ts b/src/channels/plugins/target-resolvers.ts new file mode 100644 index 00000000000..81bdd82fd6c --- /dev/null +++ b/src/channels/plugins/target-resolvers.ts @@ -0,0 +1,30 @@ +import type { ChannelResolveResult } from "./types.adapters.js"; + +export function buildUnresolvedTargetResults( + inputs: string[], + note: string, +): ChannelResolveResult[] { + return inputs.map((input) => ({ + input, + resolved: false, + note, + })); +} + +export async function resolveTargetsWithOptionalToken(params: { + token?: string | null; + inputs: string[]; + missingTokenNote: string; + resolveWithToken: (params: { token: string; inputs: string[] }) => Promise; + mapResolved: (entry: TResult) => ChannelResolveResult; +}): Promise { + const token = params.token?.trim(); + if (!token) { + return buildUnresolvedTargetResults(params.inputs, params.missingTokenNote); + } + const resolved = await params.resolveWithToken({ + token, + inputs: params.inputs, + }); + return resolved.map(params.mapResolved); +} diff --git a/src/channels/plugins/threading-helpers.test.ts b/src/channels/plugins/threading-helpers.test.ts new file mode 100644 index 00000000000..48688d33ed0 --- /dev/null +++ b/src/channels/plugins/threading-helpers.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { + createScopedAccountReplyToModeResolver, + createStaticReplyToModeResolver, + createTopLevelChannelReplyToModeResolver, +} from "./threading-helpers.js"; + +describe("createStaticReplyToModeResolver", () => { + it("always returns the configured mode", () => { + expect(createStaticReplyToModeResolver("off")({ cfg: {} as OpenClawConfig })).toBe("off"); + expect(createStaticReplyToModeResolver("all")({ cfg: {} as OpenClawConfig })).toBe("all"); + }); +}); + +describe("createTopLevelChannelReplyToModeResolver", () => { + it("reads the top-level channel config", () => { + const resolver = createTopLevelChannelReplyToModeResolver("discord"); + expect( + resolver({ + cfg: { channels: { discord: { replyToMode: "first" } } } as OpenClawConfig, + }), + ).toBe("first"); + }); + + it("falls back to off", () => { + const resolver = createTopLevelChannelReplyToModeResolver("discord"); + expect(resolver({ cfg: {} as OpenClawConfig })).toBe("off"); + }); +}); + +describe("createScopedAccountReplyToModeResolver", () => { + it("reads the scoped account reply mode", () => { + const resolver = createScopedAccountReplyToModeResolver({ + resolveAccount: (cfg, accountId) => + (( + cfg.channels as { + matrix?: { accounts?: Record }; + } + ).matrix?.accounts?.[accountId?.toLowerCase() ?? "default"] ?? {}) as { + replyToMode?: "off" | "first" | "all"; + }, + resolveReplyToMode: (account) => account.replyToMode, + }); + + const cfg = { + channels: { + matrix: { + accounts: { + assistant: { replyToMode: "all" }, + }, + }, + }, + } as OpenClawConfig; + + expect(resolver({ cfg, accountId: "assistant" })).toBe("all"); + expect(resolver({ cfg, accountId: "default" })).toBe("off"); + }); + + it("passes chatType through", () => { + const seen: Array = []; + const resolver = createScopedAccountReplyToModeResolver({ + resolveAccount: () => ({ replyToMode: "first" as const }), + resolveReplyToMode: (account, chatType) => { + seen.push(chatType); + return account.replyToMode; + }, + }); + + expect(resolver({ cfg: {} as OpenClawConfig, chatType: "group" })).toBe("first"); + expect(seen).toEqual(["group"]); + }); +}); diff --git a/src/channels/plugins/threading-helpers.ts b/src/channels/plugins/threading-helpers.ts new file mode 100644 index 00000000000..360e4a7048b --- /dev/null +++ b/src/channels/plugins/threading-helpers.ts @@ -0,0 +1,32 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import type { ReplyToMode } from "../../config/types.base.js"; +import type { ChannelThreadingAdapter } from "./types.core.js"; + +type ReplyToModeResolver = NonNullable; + +export function createStaticReplyToModeResolver(mode: ReplyToMode): ReplyToModeResolver { + return () => mode; +} + +export function createTopLevelChannelReplyToModeResolver(channelId: string): ReplyToModeResolver { + return ({ cfg }) => { + const channelConfig = ( + cfg.channels as Record | undefined + )?.[channelId]; + return channelConfig?.replyToMode ?? "off"; + }; +} + +export function createScopedAccountReplyToModeResolver(params: { + resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => TAccount; + resolveReplyToMode: ( + account: TAccount, + chatType?: string | null, + ) => ReplyToMode | null | undefined; + fallback?: ReplyToMode; +}): ReplyToModeResolver { + return ({ cfg, accountId, chatType }) => + params.resolveReplyToMode(params.resolveAccount(cfg, accountId), chatType) ?? + params.fallback ?? + "off"; +} diff --git a/src/channels/plugins/whatsapp-shared.ts b/src/channels/plugins/whatsapp-shared.ts index c798e7fe3ca..efbd832dd09 100644 --- a/src/channels/plugins/whatsapp-shared.ts +++ b/src/channels/plugins/whatsapp-shared.ts @@ -1,4 +1,5 @@ import { resolveOutboundSendDep } from "../../infra/outbound/send-deps.js"; +import { createAttachedChannelResultAdapter } from "../../plugin-sdk/channel-send-result.js"; import type { PluginRuntimeChannel } from "../../plugins/runtime/types-channel.js"; import { escapeRegExp } from "../../utils.js"; import { resolveWhatsAppOutboundTarget } from "../../whatsapp/resolve-outbound-target.js"; @@ -62,48 +63,49 @@ export function createWhatsAppOutboundBase({ textChunkLimit: 4000, pollMaxOptions: 12, resolveTarget, - sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => { - const normalizedText = normalizeText(text); - if (skipEmptyText && !normalizedText) { - return { channel: "whatsapp", messageId: "" }; - } - const send = - resolveOutboundSendDep(deps, "whatsapp") ?? sendMessageWhatsApp; - const result = await send(to, normalizedText, { - verbose: false, - cfg, - accountId: accountId ?? undefined, - gifPlayback, - }); - return { channel: "whatsapp", ...result }; - }, - sendMedia: async ({ - cfg, - to, - text, - mediaUrl, - mediaLocalRoots, - accountId, - deps, - gifPlayback, - }) => { - const send = - resolveOutboundSendDep(deps, "whatsapp") ?? sendMessageWhatsApp; - const result = await send(to, normalizeText(text), { - verbose: false, + ...createAttachedChannelResultAdapter({ + channel: "whatsapp", + sendText: async ({ cfg, to, text, accountId, deps, gifPlayback }) => { + const normalizedText = normalizeText(text); + if (skipEmptyText && !normalizedText) { + return { messageId: "" }; + } + const send = + resolveOutboundSendDep(deps, "whatsapp") ?? sendMessageWhatsApp; + return await send(to, normalizedText, { + verbose: false, + cfg, + accountId: accountId ?? undefined, + gifPlayback, + }); + }, + sendMedia: async ({ cfg, + to, + text, mediaUrl, mediaLocalRoots, - accountId: accountId ?? undefined, + accountId, + deps, gifPlayback, - }); - return { channel: "whatsapp", ...result }; - }, - sendPoll: async ({ cfg, to, poll, accountId }) => - await sendPollWhatsApp(to, poll, { - verbose: shouldLogVerbose(), - accountId: accountId ?? undefined, - cfg, - }), + }) => { + const send = + resolveOutboundSendDep(deps, "whatsapp") ?? sendMessageWhatsApp; + return await send(to, normalizeText(text), { + verbose: false, + cfg, + mediaUrl, + mediaLocalRoots, + accountId: accountId ?? undefined, + gifPlayback, + }); + }, + sendPoll: async ({ cfg, to, poll, accountId }) => + await sendPollWhatsApp(to, poll, { + verbose: shouldLogVerbose(), + accountId: accountId ?? undefined, + cfg, + }), + }), }; } diff --git a/src/channels/read-only-account-inspect.discord.runtime.ts b/src/channels/read-only-account-inspect.discord.runtime.ts index 28db6fd4c1e..d52f56ad316 100644 --- a/src/channels/read-only-account-inspect.discord.runtime.ts +++ b/src/channels/read-only-account-inspect.discord.runtime.ts @@ -1,8 +1,8 @@ -import { inspectDiscordAccount as inspectDiscordAccountImpl } from "../plugin-sdk/discord.js"; +import { inspectDiscordAccount as inspectDiscordAccountImpl } from "openclaw/plugin-sdk/discord"; -export type { InspectedDiscordAccount } from "../plugin-sdk/discord.js"; +export type { InspectedDiscordAccount } from "openclaw/plugin-sdk/discord"; -type InspectDiscordAccount = typeof import("../plugin-sdk/discord.js").inspectDiscordAccount; +type InspectDiscordAccount = typeof import("openclaw/plugin-sdk/discord").inspectDiscordAccount; export function inspectDiscordAccount( ...args: Parameters diff --git a/src/channels/read-only-account-inspect.slack.runtime.ts b/src/channels/read-only-account-inspect.slack.runtime.ts index f2a9260b63e..0d3e2c878c1 100644 --- a/src/channels/read-only-account-inspect.slack.runtime.ts +++ b/src/channels/read-only-account-inspect.slack.runtime.ts @@ -1,8 +1,8 @@ -import { inspectSlackAccount as inspectSlackAccountImpl } from "../plugin-sdk/slack.js"; +import { inspectSlackAccount as inspectSlackAccountImpl } from "openclaw/plugin-sdk/slack"; -export type { InspectedSlackAccount } from "../plugin-sdk/slack.js"; +export type { InspectedSlackAccount } from "openclaw/plugin-sdk/slack"; -type InspectSlackAccount = typeof import("../plugin-sdk/slack.js").inspectSlackAccount; +type InspectSlackAccount = typeof import("openclaw/plugin-sdk/slack").inspectSlackAccount; export function inspectSlackAccount( ...args: Parameters diff --git a/src/channels/read-only-account-inspect.telegram.runtime.ts b/src/channels/read-only-account-inspect.telegram.runtime.ts index 01c492dfffd..12158022b2b 100644 --- a/src/channels/read-only-account-inspect.telegram.runtime.ts +++ b/src/channels/read-only-account-inspect.telegram.runtime.ts @@ -1,8 +1,8 @@ -import { inspectTelegramAccount as inspectTelegramAccountImpl } from "../plugin-sdk/telegram.js"; +import { inspectTelegramAccount as inspectTelegramAccountImpl } from "openclaw/plugin-sdk/telegram"; -export type { InspectedTelegramAccount } from "../plugin-sdk/telegram.js"; +export type { InspectedTelegramAccount } from "openclaw/plugin-sdk/telegram"; -type InspectTelegramAccount = typeof import("../plugin-sdk/telegram.js").inspectTelegramAccount; +type InspectTelegramAccount = typeof import("openclaw/plugin-sdk/telegram").inspectTelegramAccount; export function inspectTelegramAccount( ...args: Parameters diff --git a/src/cli/deps.ts b/src/cli/deps.ts index 1d9d6885fe2..23d2d9af399 100644 --- a/src/cli/deps.ts +++ b/src/cli/deps.ts @@ -70,4 +70,4 @@ export function createOutboundSendDeps(deps: CliDeps): OutboundSendDeps { return createOutboundSendDepsFromCliSource(deps); } -export { logWebSelfId } from "../plugin-sdk/whatsapp.js"; +export { logWebSelfId } from "openclaw/plugin-sdk/whatsapp"; diff --git a/src/cli/plugins-cli.test.ts b/src/cli/plugins-cli.test.ts new file mode 100644 index 00000000000..50bc8633e70 --- /dev/null +++ b/src/cli/plugins-cli.test.ts @@ -0,0 +1,424 @@ +import { Command } from "commander"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { createCliRuntimeCapture } from "./test-runtime-capture.js"; + +const loadConfig = vi.fn<() => OpenClawConfig>(() => ({}) as OpenClawConfig); +const writeConfigFile = vi.fn<(config: OpenClawConfig) => Promise>(async () => undefined); +const resolveStateDir = vi.fn(() => "/tmp/openclaw-state"); +const installPluginFromMarketplace = vi.fn(); +const listMarketplacePlugins = vi.fn(); +const resolveMarketplaceInstallShortcut = vi.fn(); +const enablePluginInConfig = vi.fn(); +const recordPluginInstall = vi.fn(); +const clearPluginManifestRegistryCache = vi.fn(); +const buildPluginStatusReport = vi.fn(); +const applyExclusiveSlotSelection = vi.fn(); +const uninstallPlugin = vi.fn(); +const updateNpmInstalledPlugins = vi.fn(); +const promptYesNo = vi.fn(); +const installPluginFromNpmSpec = vi.fn(); +const installPluginFromPath = vi.fn(); + +const { defaultRuntime, runtimeLogs, runtimeErrors, resetRuntimeCapture } = + createCliRuntimeCapture(); + +vi.mock("../runtime.js", () => ({ + defaultRuntime, +})); + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => loadConfig(), + writeConfigFile: (config: OpenClawConfig) => writeConfigFile(config), + }; +}); + +vi.mock("../config/paths.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveStateDir: () => resolveStateDir(), + }; +}); + +vi.mock("../plugins/marketplace.js", () => ({ + installPluginFromMarketplace: (...args: unknown[]) => installPluginFromMarketplace(...args), + listMarketplacePlugins: (...args: unknown[]) => listMarketplacePlugins(...args), + resolveMarketplaceInstallShortcut: (...args: unknown[]) => + resolveMarketplaceInstallShortcut(...args), +})); + +vi.mock("../plugins/enable.js", () => ({ + enablePluginInConfig: (...args: unknown[]) => enablePluginInConfig(...args), +})); + +vi.mock("../plugins/installs.js", () => ({ + recordPluginInstall: (...args: unknown[]) => recordPluginInstall(...args), +})); + +vi.mock("../plugins/manifest-registry.js", () => ({ + clearPluginManifestRegistryCache: () => clearPluginManifestRegistryCache(), +})); + +vi.mock("../plugins/status.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + buildPluginStatusReport: (...args: unknown[]) => buildPluginStatusReport(...args), + }; +}); + +vi.mock("../plugins/slots.js", () => ({ + applyExclusiveSlotSelection: (...args: unknown[]) => applyExclusiveSlotSelection(...args), +})); + +vi.mock("../plugins/uninstall.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + uninstallPlugin: (...args: unknown[]) => uninstallPlugin(...args), + }; +}); + +vi.mock("../plugins/update.js", () => ({ + updateNpmInstalledPlugins: (...args: unknown[]) => updateNpmInstalledPlugins(...args), +})); + +vi.mock("./prompt.js", () => ({ + promptYesNo: (...args: unknown[]) => promptYesNo(...args), +})); + +vi.mock("../plugins/install.js", () => ({ + installPluginFromNpmSpec: (...args: unknown[]) => installPluginFromNpmSpec(...args), + installPluginFromPath: (...args: unknown[]) => installPluginFromPath(...args), +})); + +const { registerPluginsCli } = await import("./plugins-cli.js"); + +describe("plugins cli", () => { + const createProgram = () => { + const program = new Command(); + program.exitOverride(); + registerPluginsCli(program); + return program; + }; + + const runCommand = (argv: string[]) => createProgram().parseAsync(argv, { from: "user" }); + + beforeEach(() => { + resetRuntimeCapture(); + loadConfig.mockReset(); + writeConfigFile.mockReset(); + resolveStateDir.mockReset(); + installPluginFromMarketplace.mockReset(); + listMarketplacePlugins.mockReset(); + resolveMarketplaceInstallShortcut.mockReset(); + enablePluginInConfig.mockReset(); + recordPluginInstall.mockReset(); + clearPluginManifestRegistryCache.mockReset(); + buildPluginStatusReport.mockReset(); + applyExclusiveSlotSelection.mockReset(); + uninstallPlugin.mockReset(); + updateNpmInstalledPlugins.mockReset(); + promptYesNo.mockReset(); + installPluginFromNpmSpec.mockReset(); + installPluginFromPath.mockReset(); + + loadConfig.mockReturnValue({} as OpenClawConfig); + writeConfigFile.mockResolvedValue(undefined); + resolveStateDir.mockReturnValue("/tmp/openclaw-state"); + resolveMarketplaceInstallShortcut.mockResolvedValue(null); + installPluginFromMarketplace.mockResolvedValue({ + ok: false, + error: "marketplace install failed", + }); + enablePluginInConfig.mockImplementation((cfg: OpenClawConfig) => ({ config: cfg })); + recordPluginInstall.mockImplementation((cfg: OpenClawConfig) => cfg); + buildPluginStatusReport.mockReturnValue({ + plugins: [], + diagnostics: [], + }); + applyExclusiveSlotSelection.mockImplementation(({ config }: { config: OpenClawConfig }) => ({ + config, + warnings: [], + })); + uninstallPlugin.mockResolvedValue({ + ok: true, + config: {} as OpenClawConfig, + warnings: [], + actions: { + entry: false, + install: false, + allowlist: false, + loadPath: false, + memorySlot: false, + directory: false, + }, + }); + updateNpmInstalledPlugins.mockResolvedValue({ + outcomes: [], + changed: false, + config: {} as OpenClawConfig, + }); + promptYesNo.mockResolvedValue(true); + installPluginFromPath.mockResolvedValue({ ok: false, error: "path install disabled in test" }); + installPluginFromNpmSpec.mockResolvedValue({ + ok: false, + error: "npm install disabled in test", + }); + }); + + it("exits when --marketplace is combined with --link", async () => { + await expect( + runCommand(["plugins", "install", "alpha", "--marketplace", "local/repo", "--link"]), + ).rejects.toThrow("__exit__:1"); + + expect(runtimeErrors.at(-1)).toContain("`--link` is not supported with `--marketplace`."); + expect(installPluginFromMarketplace).not.toHaveBeenCalled(); + }); + + it("exits when marketplace install fails", async () => { + await expect( + runCommand(["plugins", "install", "alpha", "--marketplace", "local/repo"]), + ).rejects.toThrow("__exit__:1"); + + expect(installPluginFromMarketplace).toHaveBeenCalledWith( + expect.objectContaining({ + marketplace: "local/repo", + plugin: "alpha", + }), + ); + expect(writeConfigFile).not.toHaveBeenCalled(); + }); + + it("installs marketplace plugins and persists config", async () => { + const cfg = { + plugins: { + entries: {}, + }, + } as OpenClawConfig; + const enabledCfg = { + plugins: { + entries: { + alpha: { + enabled: true, + }, + }, + }, + } as OpenClawConfig; + const installedCfg = { + ...enabledCfg, + plugins: { + ...enabledCfg.plugins, + installs: { + alpha: { + source: "marketplace", + installPath: "/tmp/openclaw-state/extensions/alpha", + }, + }, + }, + } as OpenClawConfig; + + loadConfig.mockReturnValue(cfg); + installPluginFromMarketplace.mockResolvedValue({ + ok: true, + pluginId: "alpha", + targetDir: "/tmp/openclaw-state/extensions/alpha", + version: "1.2.3", + marketplaceName: "Claude", + marketplaceSource: "local/repo", + marketplacePlugin: "alpha", + }); + enablePluginInConfig.mockReturnValue({ config: enabledCfg }); + recordPluginInstall.mockReturnValue(installedCfg); + buildPluginStatusReport.mockReturnValue({ + plugins: [{ id: "alpha", kind: "provider" }], + diagnostics: [], + }); + applyExclusiveSlotSelection.mockReturnValue({ + config: installedCfg, + warnings: ["slot adjusted"], + }); + + await runCommand(["plugins", "install", "alpha", "--marketplace", "local/repo"]); + + expect(clearPluginManifestRegistryCache).toHaveBeenCalledTimes(1); + expect(writeConfigFile).toHaveBeenCalledWith(installedCfg); + expect(runtimeLogs.some((line) => line.includes("slot adjusted"))).toBe(true); + expect(runtimeLogs.some((line) => line.includes("Installed plugin: alpha"))).toBe(true); + }); + + it("shows uninstall dry-run preview without mutating config", async () => { + loadConfig.mockReturnValue({ + plugins: { + entries: { + alpha: { + enabled: true, + }, + }, + installs: { + alpha: { + source: "path", + sourcePath: "/tmp/openclaw-state/extensions/alpha", + installPath: "/tmp/openclaw-state/extensions/alpha", + }, + }, + }, + } as OpenClawConfig); + buildPluginStatusReport.mockReturnValue({ + plugins: [{ id: "alpha", name: "alpha" }], + diagnostics: [], + }); + + await runCommand(["plugins", "uninstall", "alpha", "--dry-run"]); + + expect(uninstallPlugin).not.toHaveBeenCalled(); + expect(writeConfigFile).not.toHaveBeenCalled(); + expect(runtimeLogs.some((line) => line.includes("Dry run, no changes made."))).toBe(true); + }); + + it("uninstalls with --force and --keep-files without prompting", async () => { + const baseConfig = { + plugins: { + entries: { + alpha: { enabled: true }, + }, + installs: { + alpha: { + source: "path", + sourcePath: "/tmp/openclaw-state/extensions/alpha", + installPath: "/tmp/openclaw-state/extensions/alpha", + }, + }, + }, + } as OpenClawConfig; + const nextConfig = { + plugins: { + entries: {}, + installs: {}, + }, + } as OpenClawConfig; + + loadConfig.mockReturnValue(baseConfig); + buildPluginStatusReport.mockReturnValue({ + plugins: [{ id: "alpha", name: "alpha" }], + diagnostics: [], + }); + uninstallPlugin.mockResolvedValue({ + ok: true, + config: nextConfig, + warnings: [], + actions: { + entry: true, + install: true, + allowlist: false, + loadPath: false, + memorySlot: false, + directory: false, + }, + }); + + await runCommand(["plugins", "uninstall", "alpha", "--force", "--keep-files"]); + + expect(promptYesNo).not.toHaveBeenCalled(); + expect(uninstallPlugin).toHaveBeenCalledWith( + expect.objectContaining({ + pluginId: "alpha", + deleteFiles: false, + }), + ); + expect(writeConfigFile).toHaveBeenCalledWith(nextConfig); + }); + + it("exits when uninstall target is not managed by plugin install records", async () => { + loadConfig.mockReturnValue({ + plugins: { + entries: {}, + installs: {}, + }, + } as OpenClawConfig); + buildPluginStatusReport.mockReturnValue({ + plugins: [{ id: "alpha", name: "alpha" }], + diagnostics: [], + }); + + await expect(runCommand(["plugins", "uninstall", "alpha", "--force"])).rejects.toThrow( + "__exit__:1", + ); + + expect(runtimeErrors.at(-1)).toContain("is not managed by plugins config/install records"); + expect(uninstallPlugin).not.toHaveBeenCalled(); + }); + + it("exits when update is called without id and without --all", async () => { + loadConfig.mockReturnValue({ + plugins: { + installs: {}, + }, + } as OpenClawConfig); + + await expect(runCommand(["plugins", "update"])).rejects.toThrow("__exit__:1"); + + expect(runtimeErrors.at(-1)).toContain("Provide a plugin id or use --all."); + expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); + }); + + it("reports no tracked plugins when update --all has empty install records", async () => { + loadConfig.mockReturnValue({ + plugins: { + installs: {}, + }, + } as OpenClawConfig); + + await runCommand(["plugins", "update", "--all"]); + + expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); + expect(runtimeLogs.at(-1)).toBe("No tracked plugins to update."); + }); + + it("writes updated config when updater reports changes", async () => { + const cfg = { + plugins: { + installs: { + alpha: { + source: "npm", + spec: "@openclaw/alpha@1.0.0", + }, + }, + }, + } as OpenClawConfig; + const nextConfig = { + plugins: { + installs: { + alpha: { + source: "npm", + spec: "@openclaw/alpha@1.1.0", + }, + }, + }, + } as OpenClawConfig; + loadConfig.mockReturnValue(cfg); + updateNpmInstalledPlugins.mockResolvedValue({ + outcomes: [{ status: "ok", message: "Updated alpha -> 1.1.0" }], + changed: true, + config: nextConfig, + }); + + await runCommand(["plugins", "update", "alpha"]); + + expect(updateNpmInstalledPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + config: cfg, + pluginIds: ["alpha"], + dryRun: false, + }), + ); + expect(writeConfigFile).toHaveBeenCalledWith(nextConfig); + expect(runtimeLogs.some((line) => line.includes("Restart the gateway to load plugins."))).toBe( + true, + ); + }); +}); diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index b180b0a38e8..79fca829281 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -288,7 +288,7 @@ async function runPluginInstallCommand(params: { : null; if (shorthand?.ok === false) { defaultRuntime.error(shorthand.error); - process.exit(1); + return defaultRuntime.exit(1); } const raw = shorthand?.ok ? shorthand.plugin : params.raw; @@ -301,11 +301,11 @@ async function runPluginInstallCommand(params: { if (opts.marketplace) { if (opts.link) { defaultRuntime.error("`--link` is not supported with `--marketplace`."); - process.exit(1); + return defaultRuntime.exit(1); } if (opts.pin) { defaultRuntime.error("`--pin` is not supported with `--marketplace`."); - process.exit(1); + return defaultRuntime.exit(1); } const cfg = loadConfig(); @@ -316,7 +316,7 @@ async function runPluginInstallCommand(params: { }); if (!result.ok) { defaultRuntime.error(result.error); - process.exit(1); + return defaultRuntime.exit(1); } clearPluginManifestRegistryCache(); @@ -343,7 +343,7 @@ async function runPluginInstallCommand(params: { const fileSpec = resolveFileNpmSpecToLocalPath(raw); if (fileSpec && !fileSpec.ok) { defaultRuntime.error(fileSpec.error); - process.exit(1); + return defaultRuntime.exit(1); } const normalized = fileSpec && fileSpec.ok ? fileSpec.path : raw; const resolved = resolveUserPath(normalized); @@ -356,7 +356,7 @@ async function runPluginInstallCommand(params: { const probe = await installPluginFromPath({ path: resolved, dryRun: true }); if (!probe.ok) { defaultRuntime.error(probe.error); - process.exit(1); + return defaultRuntime.exit(1); } let next: OpenClawConfig = enablePluginInConfig( @@ -394,7 +394,7 @@ async function runPluginInstallCommand(params: { }); if (!result.ok) { defaultRuntime.error(result.error); - process.exit(1); + return defaultRuntime.exit(1); } // Plugin CLI registrars may have warmed the manifest registry cache before install; // force a rescan so config validation sees the freshly installed plugin. @@ -420,7 +420,7 @@ async function runPluginInstallCommand(params: { if (opts.link) { defaultRuntime.error("`--link` requires a local path."); - process.exit(1); + return defaultRuntime.exit(1); } if ( @@ -436,7 +436,7 @@ async function runPluginInstallCommand(params: { ]) ) { defaultRuntime.error(`Path not found: ${resolved}`); - process.exit(1); + return defaultRuntime.exit(1); } const bundledPreNpmPlan = resolveBundledInstallPlanBeforeNpm({ @@ -465,7 +465,7 @@ async function runPluginInstallCommand(params: { }); if (!bundledFallbackPlan) { defaultRuntime.error(result.error); - process.exit(1); + return defaultRuntime.exit(1); } await installBundledPluginSource({ @@ -623,7 +623,7 @@ export function registerPluginsCli(program: Command) { if (opts.all) { if (id) { defaultRuntime.error("Pass either a plugin id or --all, not both."); - process.exit(1); + return defaultRuntime.exit(1); } const inspectAll = buildAllPluginInspectReports({ config: cfg, @@ -689,7 +689,7 @@ export function registerPluginsCli(program: Command) { if (!id) { defaultRuntime.error("Provide a plugin id or use --all."); - process.exit(1); + return defaultRuntime.exit(1); } const inspect = buildPluginInspectReport({ @@ -699,7 +699,7 @@ export function registerPluginsCli(program: Command) { }); if (!inspect) { defaultRuntime.error(`Plugin not found: ${id}`); - process.exit(1); + return defaultRuntime.exit(1); } const install = cfg.plugins?.installs?.[inspect.plugin.id]; @@ -905,7 +905,7 @@ export function registerPluginsCli(program: Command) { } else { defaultRuntime.error(`Plugin not found: ${id}`); } - process.exit(1); + return defaultRuntime.exit(1); } const install = cfg.plugins?.installs?.[pluginId]; @@ -972,7 +972,7 @@ export function registerPluginsCli(program: Command) { if (!result.ok) { defaultRuntime.error(result.error); - process.exit(1); + return defaultRuntime.exit(1); } for (const warning of result.warnings) { defaultRuntime.log(theme.warn(warning)); @@ -1040,7 +1040,7 @@ export function registerPluginsCli(program: Command) { return; } defaultRuntime.error("Provide a plugin id or use --all."); - process.exit(1); + return defaultRuntime.exit(1); } const result = await updateNpmInstalledPlugins({ @@ -1148,7 +1148,7 @@ export function registerPluginsCli(program: Command) { }); if (!result.ok) { defaultRuntime.error(result.error); - process.exit(1); + return defaultRuntime.exit(1); } if (opts.json) { diff --git a/src/cli/send-runtime/discord.ts b/src/cli/send-runtime/discord.ts index 768653752b6..3c6527a8175 100644 --- a/src/cli/send-runtime/discord.ts +++ b/src/cli/send-runtime/discord.ts @@ -1,7 +1,7 @@ -import { sendMessageDiscord as sendMessageDiscordImpl } from "../../plugin-sdk/discord.js"; +import { sendMessageDiscord as sendMessageDiscordImpl } from "openclaw/plugin-sdk/discord"; type RuntimeSend = { - sendMessage: typeof import("../../plugin-sdk/discord.js").sendMessageDiscord; + sendMessage: typeof import("openclaw/plugin-sdk/discord").sendMessageDiscord; }; export const runtimeSend = { diff --git a/src/cli/send-runtime/signal.ts b/src/cli/send-runtime/signal.ts index 151f13cc351..967fde0bc35 100644 --- a/src/cli/send-runtime/signal.ts +++ b/src/cli/send-runtime/signal.ts @@ -1,7 +1,7 @@ -import { sendMessageSignal as sendMessageSignalImpl } from "../../plugin-sdk/signal.js"; +import { sendMessageSignal as sendMessageSignalImpl } from "openclaw/plugin-sdk/signal"; type RuntimeSend = { - sendMessage: typeof import("../../plugin-sdk/signal.js").sendMessageSignal; + sendMessage: typeof import("openclaw/plugin-sdk/signal").sendMessageSignal; }; export const runtimeSend = { diff --git a/src/cli/send-runtime/slack.ts b/src/cli/send-runtime/slack.ts index 354186cd128..beec4f55906 100644 --- a/src/cli/send-runtime/slack.ts +++ b/src/cli/send-runtime/slack.ts @@ -1,7 +1,7 @@ -import { sendMessageSlack as sendMessageSlackImpl } from "../../plugin-sdk/slack.js"; +import { sendMessageSlack as sendMessageSlackImpl } from "openclaw/plugin-sdk/slack"; type RuntimeSend = { - sendMessage: typeof import("../../plugin-sdk/slack.js").sendMessageSlack; + sendMessage: typeof import("openclaw/plugin-sdk/slack").sendMessageSlack; }; export const runtimeSend = { diff --git a/src/cli/send-runtime/telegram.ts b/src/cli/send-runtime/telegram.ts index 09d5e3e9b19..bfa22643976 100644 --- a/src/cli/send-runtime/telegram.ts +++ b/src/cli/send-runtime/telegram.ts @@ -1,7 +1,7 @@ -import { sendMessageTelegram as sendMessageTelegramImpl } from "../../plugin-sdk/telegram.js"; +import { sendMessageTelegram as sendMessageTelegramImpl } from "openclaw/plugin-sdk/telegram"; type RuntimeSend = { - sendMessage: typeof import("../../plugin-sdk/telegram.js").sendMessageTelegram; + sendMessage: typeof import("openclaw/plugin-sdk/telegram").sendMessageTelegram; }; export const runtimeSend = { diff --git a/src/cli/send-runtime/whatsapp.ts b/src/cli/send-runtime/whatsapp.ts index 49f0e50baa6..b1e731e7c44 100644 --- a/src/cli/send-runtime/whatsapp.ts +++ b/src/cli/send-runtime/whatsapp.ts @@ -1,7 +1,7 @@ -import { sendMessageWhatsApp as sendMessageWhatsAppImpl } from "../../plugin-sdk/whatsapp.js"; +import { sendMessageWhatsApp as sendMessageWhatsAppImpl } from "openclaw/plugin-sdk/whatsapp"; type RuntimeSend = { - sendMessage: typeof import("../../plugin-sdk/whatsapp.js").sendMessageWhatsApp; + sendMessage: typeof import("openclaw/plugin-sdk/whatsapp").sendMessageWhatsApp; }; export const runtimeSend = { diff --git a/src/commands/agent-via-gateway.ts b/src/commands/agent-via-gateway.ts index a44caa3f3bf..79e05cc6047 100644 --- a/src/commands/agent-via-gateway.ts +++ b/src/commands/agent-via-gateway.ts @@ -1,3 +1,4 @@ +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { listAgentIds } from "../agents/agent-scope.js"; import { formatCliCommand } from "../cli/command-format.js"; import type { CliDeps } from "../cli/deps.js"; @@ -69,16 +70,16 @@ function formatPayloadForLog(payload: { mediaUrls?: string[]; mediaUrl?: string | null; }) { + const parts = resolveSendableOutboundReplyParts({ + text: payload.text, + mediaUrls: payload.mediaUrls, + mediaUrl: typeof payload.mediaUrl === "string" ? payload.mediaUrl : undefined, + }); const lines: string[] = []; - if (payload.text) { - lines.push(payload.text.trimEnd()); + if (parts.text) { + lines.push(parts.text.trimEnd()); } - const mediaUrl = - typeof payload.mediaUrl === "string" && payload.mediaUrl.trim() - ? payload.mediaUrl.trim() - : undefined; - const media = payload.mediaUrls ?? (mediaUrl ? [mediaUrl] : []); - for (const url of media) { + for (const url of parts.mediaUrls) { lines.push(`MEDIA:${url}`); } return lines.join("\n").trimEnd(); diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index dcd518829ec..d5c749a30c7 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -1465,7 +1465,7 @@ describe("applyAuthChoice", () => { profileId: "minimax-portal:default", baseUrl: "https://api.minimax.io/anthropic", api: "anthropic-messages", - defaultModel: "minimax-portal/MiniMax-M2.5", + defaultModel: "minimax-portal/MiniMax-M2.7", apiKey: "minimax-oauth", // pragma: allowlist secret }, ]; diff --git a/src/commands/channel-test-helpers.ts b/src/commands/channel-test-helpers.ts index eff2b5ecc33..455ff235be6 100644 --- a/src/commands/channel-test-helpers.ts +++ b/src/commands/channel-test-helpers.ts @@ -1,3 +1,7 @@ +import { matrixPlugin } from "../../extensions/matrix/index.js"; +import { msteamsPlugin } from "../../extensions/msteams/index.js"; +import { nostrPlugin } from "../../extensions/nostr/index.js"; +import { tlonPlugin } from "../../extensions/tlon/index.js"; import { bundledChannelPlugins } from "../channels/plugins/bundled.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; @@ -20,7 +24,13 @@ type PatchedSetupAdapterFields = { }; export function setDefaultChannelPluginRegistryForTests(): void { - const channels = bundledChannelPlugins.map((plugin) => ({ + const channels = [ + ...bundledChannelPlugins, + matrixPlugin, + msteamsPlugin, + nostrPlugin, + tlonPlugin, + ].map((plugin) => ({ pluginId: plugin.id, plugin, source: "test" as const, diff --git a/src/commands/configure.gateway-auth.prompt-auth-config.test.ts b/src/commands/configure.gateway-auth.prompt-auth-config.test.ts index b6ba81a432e..971429bb2bf 100644 --- a/src/commands/configure.gateway-auth.prompt-auth-config.test.ts +++ b/src/commands/configure.gateway-auth.prompt-auth-config.test.ts @@ -88,7 +88,7 @@ function createApplyAuthChoiceConfig(includeMinimaxProvider = false) { minimax: { baseUrl: "https://api.minimax.io/anthropic", api: "anthropic-messages", - models: [{ id: "MiniMax-M2.5", name: "MiniMax M2.5" }], + models: [{ id: "MiniMax-M2.7", name: "MiniMax M2.7" }], }, } : {}), @@ -127,7 +127,7 @@ describe("promptAuthConfig", () => { "anthropic/claude-sonnet-4", ]); expect(result.models?.providers?.minimax?.models?.map((model) => model.id)).toEqual([ - "MiniMax-M2.5", + "MiniMax-M2.7", ]); }); diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index ae755423987..10721412927 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -1,5 +1,12 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { + fetchTelegramChatId, + inspectTelegramAccount, + isNumericTelegramUserId, + listTelegramAccountIds, + normalizeTelegramAllowFromEntry, +} from "openclaw/plugin-sdk/telegram"; import { normalizeChatChannelId } from "../channels/registry.js"; import { formatCliCommand } from "../cli/command-format.js"; import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js"; @@ -23,13 +30,6 @@ import { } from "../infra/exec-safe-bin-trust.js"; import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; import { resolveTelegramAccount } from "../plugin-sdk/account-resolution.js"; -import { - fetchTelegramChatId, - inspectTelegramAccount, - isNumericTelegramUserId, - listTelegramAccountIds, - normalizeTelegramAllowFromEntry, -} from "../plugin-sdk/telegram.js"; import { formatChannelAccountsDefaultPath, formatSetExplicitDefaultInstruction, diff --git a/src/commands/onboard-auth.test.ts b/src/commands/onboard-auth.test.ts index d245d64f703..58f7f94b484 100644 --- a/src/commands/onboard-auth.test.ts +++ b/src/commands/onboard-auth.test.ts @@ -386,8 +386,8 @@ describe("applyMinimaxApiConfig", () => { }); }); - it("keeps reasoning enabled for MiniMax-M2.5", () => { - const cfg = applyMinimaxApiConfig({}, "MiniMax-M2.5"); + it("keeps reasoning enabled for MiniMax-M2.7", () => { + const cfg = applyMinimaxApiConfig({}, "MiniMax-M2.7"); expect(cfg.models?.providers?.minimax?.models[0]?.reasoning).toBe(true); }); @@ -397,7 +397,7 @@ describe("applyMinimaxApiConfig", () => { agents: { defaults: { models: { - "minimax/MiniMax-M2.5": { + "minimax/MiniMax-M2.7": { alias: "MiniMax", params: { custom: "value" }, }, @@ -405,9 +405,9 @@ describe("applyMinimaxApiConfig", () => { }, }, }, - "MiniMax-M2.5", + "MiniMax-M2.7", ); - expect(cfg.agents?.defaults?.models?.["minimax/MiniMax-M2.5"]).toMatchObject({ + expect(cfg.agents?.defaults?.models?.["minimax/MiniMax-M2.7"]).toMatchObject({ alias: "Minimax", params: { custom: "value" }, }); @@ -426,7 +426,7 @@ describe("applyMinimaxApiConfig", () => { expect(cfg.models?.providers?.minimax?.apiKey).toBe("old-key"); expect(cfg.models?.providers?.minimax?.models.map((m) => m.id)).toEqual([ "old-model", - "MiniMax-M2.5", + "MiniMax-M2.7", ]); }); @@ -669,8 +669,8 @@ describe("provider alias defaults", () => { it("adds expected alias for provider defaults", () => { const aliasCases = [ { - applyConfig: () => applyMinimaxApiConfig({}, "MiniMax-M2.5"), - modelRef: "minimax/MiniMax-M2.5", + applyConfig: () => applyMinimaxApiConfig({}, "MiniMax-M2.7"), + modelRef: "minimax/MiniMax-M2.7", alias: "Minimax", }, { diff --git a/src/commands/onboard-channels.e2e.test.ts b/src/commands/onboard-channels.e2e.test.ts index 7d64a4d120f..4934d3674ff 100644 --- a/src/commands/onboard-channels.e2e.test.ts +++ b/src/commands/onboard-channels.e2e.test.ts @@ -277,6 +277,32 @@ describe("setupChannels", () => { expect(multiselect).not.toHaveBeenCalled(); }); + it("renders the QuickStart channel picker without requiring the LINE runtime", async () => { + const select = vi.fn(async ({ message }: { message: string }) => { + if (message === "Select channel (QuickStart)") { + return "__skip__"; + } + return "__done__"; + }); + const { multiselect, text } = createUnexpectedPromptGuards(); + const prompter = createPrompter({ + select: select as unknown as WizardPrompter["select"], + multiselect, + text, + }); + + await expect( + runSetupChannels({} as OpenClawConfig, prompter, { + quickstartDefaults: true, + }), + ).resolves.toEqual({} as OpenClawConfig); + + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ message: "Select channel (QuickStart)" }), + ); + expect(multiselect).not.toHaveBeenCalled(); + }); + it("continues Telegram setup when the plugin registry is empty", async () => { // Simulate missing registry entries (the scenario reported in #25545). setActivePluginRegistry(createEmptyPluginRegistry()); diff --git a/src/commands/onboard-non-interactive.provider-auth.test.ts b/src/commands/onboard-non-interactive.provider-auth.test.ts index 612d9aaaf00..e58ed61427e 100644 --- a/src/commands/onboard-non-interactive.provider-auth.test.ts +++ b/src/commands/onboard-non-interactive.provider-auth.test.ts @@ -236,7 +236,7 @@ describe("onboard (non-interactive): provider auth", () => { expect(cfg.auth?.profiles?.["minimax:global"]?.provider).toBe("minimax"); expect(cfg.auth?.profiles?.["minimax:global"]?.mode).toBe("api_key"); expect(cfg.models?.providers?.minimax?.baseUrl).toBe(MINIMAX_API_BASE_URL); - expect(cfg.agents?.defaults?.model?.primary).toBe("minimax/MiniMax-M2.5"); + expect(cfg.agents?.defaults?.model?.primary).toBe("minimax/MiniMax-M2.7"); await expectApiKeyProfile({ profileId: "minimax:global", provider: "minimax", @@ -255,7 +255,7 @@ describe("onboard (non-interactive): provider auth", () => { expect(cfg.auth?.profiles?.["minimax:cn"]?.provider).toBe("minimax"); expect(cfg.auth?.profiles?.["minimax:cn"]?.mode).toBe("api_key"); expect(cfg.models?.providers?.minimax?.baseUrl).toBe(MINIMAX_CN_API_BASE_URL); - expect(cfg.agents?.defaults?.model?.primary).toBe("minimax/MiniMax-M2.5"); + expect(cfg.agents?.defaults?.model?.primary).toBe("minimax/MiniMax-M2.7"); await expectApiKeyProfile({ profileId: "minimax:cn", provider: "minimax", diff --git a/src/config/config.identity-defaults.test.ts b/src/config/config.identity-defaults.test.ts index 92a4769c1fd..42f721edd6b 100644 --- a/src/config/config.identity-defaults.test.ts +++ b/src/config/config.identity-defaults.test.ts @@ -131,8 +131,8 @@ describe("config identity defaults", () => { api: "anthropic-messages", models: [ { - id: "MiniMax-M2.5", - name: "MiniMax M2.5", + id: "MiniMax-M2.7", + name: "MiniMax M2.7", reasoning: false, input: ["text"], cost: { diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index 1deaad96d6f..54fd24b5880 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -1,3 +1,4 @@ +import { hasAnyWhatsAppAuth } from "openclaw/plugin-sdk/whatsapp"; import { normalizeProviderId } from "../agents/model-selection.js"; import { hasMeaningfulChannelConfig } from "../channels/config-presence.js"; import { @@ -9,7 +10,6 @@ import { listChatChannels, normalizeChatChannelId, } from "../channels/registry.js"; -import { hasAnyWhatsAppAuth } from "../plugin-sdk/whatsapp.js"; import { loadPluginManifestRegistry, type PluginManifestRegistry, diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index b83c1cfeda2..684246b9ddc 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1,7 +1,7 @@ import { DISCORD_DEFAULT_INBOUND_WORKER_TIMEOUT_MS, DISCORD_DEFAULT_LISTENER_TIMEOUT_MS, -} from "../plugin-sdk/discord.js"; +} from "openclaw/plugin-sdk/discord"; import { MEDIA_AUDIO_FIELD_HELP } from "./media-audio-field-metadata.js"; import { IRC_FIELD_HELP } from "./schema.irc.js"; import { describeTalkSilenceTimeoutDefaults } from "./talk-defaults.js"; diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index c9269c6b8fd..2b115ec67b6 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -1,4 +1,4 @@ -import type { DiscordPluralKitConfig } from "../plugin-sdk/discord.js"; +import type { DiscordPluralKitConfig } from "openclaw/plugin-sdk/discord"; import type { BlockStreamingChunkConfig, BlockStreamingCoalesceConfig, diff --git a/src/cron/heartbeat-policy.ts b/src/cron/heartbeat-policy.ts index 61edfa0701f..f95f9dd8422 100644 --- a/src/cron/heartbeat-policy.ts +++ b/src/cron/heartbeat-policy.ts @@ -1,3 +1,4 @@ +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { stripHeartbeatToken } from "../auto-reply/heartbeat.js"; export type HeartbeatDeliveryPayload = { @@ -14,7 +15,7 @@ export function shouldSkipHeartbeatOnlyDelivery( return true; } const hasAnyMedia = payloads.some( - (payload) => (payload.mediaUrls?.length ?? 0) > 0 || Boolean(payload.mediaUrl), + (payload) => resolveSendableOutboundReplyParts(payload).hasMedia, ); if (hasAnyMedia) { return false; diff --git a/src/cron/isolated-agent/delivery-target.ts b/src/cron/isolated-agent/delivery-target.ts index e903cd15cab..85966c3e07c 100644 --- a/src/cron/isolated-agent/delivery-target.ts +++ b/src/cron/isolated-agent/delivery-target.ts @@ -1,3 +1,4 @@ +import { resolveWhatsAppAccount } from "openclaw/plugin-sdk/whatsapp"; import type { ChannelId } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; import { @@ -13,7 +14,6 @@ import { resolveSessionDeliveryTarget, } from "../../infra/outbound/targets.js"; import { readChannelAllowFromStoreSync } from "../../pairing/pairing-store.js"; -import { resolveWhatsAppAccount } from "../../plugin-sdk/whatsapp.js"; import { buildChannelAccountBindings } from "../../routing/bindings.js"; import { normalizeAccountId, normalizeAgentId } from "../../routing/session-key.js"; import { normalizeWhatsAppTarget } from "../../whatsapp/normalize.js"; diff --git a/src/cron/isolated-agent/helpers.ts b/src/cron/isolated-agent/helpers.ts index 448ef1c59ae..2e647423036 100644 --- a/src/cron/isolated-agent/helpers.ts +++ b/src/cron/isolated-agent/helpers.ts @@ -1,3 +1,4 @@ +import { hasOutboundReplyContent } from "openclaw/plugin-sdk/reply-payload"; import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS } from "../../auto-reply/heartbeat.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import { truncateUtf16Safe } from "../../utils.js"; @@ -61,11 +62,9 @@ export function pickLastNonEmptyTextFromPayloads( export function pickLastDeliverablePayload(payloads: DeliveryPayload[]) { const isDeliverable = (p: DeliveryPayload) => { - const text = (p?.text ?? "").trim(); - const hasMedia = Boolean(p?.mediaUrl) || (p?.mediaUrls?.length ?? 0) > 0; const hasInteractive = (p?.interactive?.blocks?.length ?? 0) > 0; const hasChannelData = Object.keys(p?.channelData ?? {}).length > 0; - return text || hasMedia || hasInteractive || hasChannelData; + return hasOutboundReplyContent(p, { trimText: true }) || hasInteractive || hasChannelData; }; for (let i = payloads.length - 1; i >= 0; i--) { if (payloads[i]?.isError) { diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 78f045d03cf..1c0b42398e5 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -1,3 +1,4 @@ +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { resolveAgentConfig, resolveAgentDir, @@ -687,9 +688,9 @@ export async function runCronIsolatedAgentTurn(params: { const interimPayloads = interimRunResult.payloads ?? []; const interimDeliveryPayload = pickLastDeliverablePayload(interimPayloads); const interimPayloadHasStructuredContent = - Boolean(interimDeliveryPayload?.mediaUrl) || - (interimDeliveryPayload?.mediaUrls?.length ?? 0) > 0 || - Object.keys(interimDeliveryPayload?.channelData ?? {}).length > 0; + (interimDeliveryPayload + ? resolveSendableOutboundReplyParts(interimDeliveryPayload).hasMedia + : false) || Object.keys(interimDeliveryPayload?.channelData ?? {}).length > 0; const interimText = pickLastNonEmptyTextFromPayloads(interimPayloads)?.trim() ?? ""; const hasDescendantsSinceRunStart = listDescendantRunsForRequester(agentSessionKey).some( (entry) => { @@ -809,8 +810,7 @@ export async function runCronIsolatedAgentTurn(params: { ? [{ text: synthesizedText }] : []; const deliveryPayloadHasStructuredContent = - Boolean(deliveryPayload?.mediaUrl) || - (deliveryPayload?.mediaUrls?.length ?? 0) > 0 || + (deliveryPayload ? resolveSendableOutboundReplyParts(deliveryPayload).hasMedia : false) || Object.keys(deliveryPayload?.channelData ?? {}).length > 0; const deliveryBestEffort = resolveCronDeliveryBestEffort(params.job); const hasErrorPayload = payloads.some((payload) => payload?.isError === true); diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 0ad655f4990..9366a917059 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -7,13 +7,13 @@ import { } from "node:http"; import { createServer as createHttpsServer } from "node:https"; import type { TlsOptions } from "node:tls"; +import { handleSlackHttpRequest } from "openclaw/plugin-sdk/slack"; import type { WebSocketServer } from "ws"; import { resolveAgentAvatar } from "../agents/identity-avatar.js"; import { CANVAS_WS_PATH, handleA2uiHttpRequest } from "../canvas-host/a2ui.js"; import type { CanvasHostHandler } from "../canvas-host/server.js"; import { loadConfig } from "../config/config.js"; import type { createSubsystemLogger } from "../logging/subsystem.js"; -import { handleSlackHttpRequest } from "../plugin-sdk/slack.js"; import { safeEqualSecret } from "../security/secret-equal.js"; import { AUTH_RATE_LIMIT_SCOPE_HOOK_AUTH, diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index 4dcdd1f61f9..a118002dc45 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -1,3 +1,4 @@ +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { normalizeChannelId } from "../../channels/plugins/index.js"; import { createOutboundSendDeps } from "../../cli/deps.js"; @@ -211,7 +212,7 @@ export const sendHandlers: GatewayRequestHandlers = { .filter(Boolean) .join("\n"); const mirrorMediaUrls = mirrorPayloads.flatMap( - (payload) => payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []), + (payload) => resolveSendableOutboundReplyParts(payload).mediaUrls, ); const providedSessionKey = typeof request.sessionKey === "string" && request.sessionKey.trim() diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 3c69ce1bcd7..e965d10b5db 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -415,7 +415,7 @@ describe("resolveSessionModelRef", () => { test("preserves openrouter provider when model contains vendor prefix", () => { const cfg = createModelDefaultsConfig({ - primary: "openrouter/minimax/minimax-m2.5", + primary: "openrouter/minimax/minimax-m2.7", }); const resolved = resolveSessionModelRef(cfg, { diff --git a/src/gateway/ws-log.ts b/src/gateway/ws-log.ts index f987ccf8d37..356d9a4c4dc 100644 --- a/src/gateway/ws-log.ts +++ b/src/gateway/ws-log.ts @@ -1,4 +1,5 @@ import chalk from "chalk"; +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { isVerbose } from "../globals.js"; import { shouldLogSubsystemToConsole } from "../logging/console.js"; import { getDefaultRedactPatterns, redactSensitiveText } from "../logging/redact.js"; @@ -204,9 +205,11 @@ export function summarizeAgentEventForWsLog(payload: unknown): Record 0) { - extra.media = mediaUrls.length; + const mediaCount = resolveSendableOutboundReplyParts({ + mediaUrls: Array.isArray(data.mediaUrls) ? data.mediaUrls : undefined, + }).mediaCount; + if (mediaCount > 0) { + extra.media = mediaCount; } return extra; } diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 34b3a7b5f86..5e6ddcf07cf 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -1,5 +1,9 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { + hasOutboundReplyContent, + resolveSendableOutboundReplyParts, +} from "openclaw/plugin-sdk/reply-payload"; import { resolveAgentConfig, resolveAgentWorkspaceDir, @@ -368,7 +372,7 @@ function normalizeHeartbeatReply( mode: "heartbeat", maxAckChars: ackMaxChars, }); - const hasMedia = Boolean(payload.mediaUrl || (payload.mediaUrls?.length ?? 0) > 0); + const hasMedia = resolveSendableOutboundReplyParts(payload).hasMedia; if (stripped.shouldSkip && !hasMedia) { return { shouldSkip: true, @@ -720,10 +724,7 @@ export async function runHeartbeatOnce(opts: { ? resolveHeartbeatReasoningPayloads(replyResult).filter((payload) => payload !== replyPayload) : []; - if ( - !replyPayload || - (!replyPayload.text && !replyPayload.mediaUrl && !replyPayload.mediaUrls?.length) - ) { + if (!replyPayload || !hasOutboundReplyContent(replyPayload)) { await restoreHeartbeatUpdatedAt({ storePath, sessionKey, @@ -780,8 +781,7 @@ export async function runHeartbeatOnce(opts: { return { status: "ran", durationMs: Date.now() - startedAt }; } - const mediaUrls = - replyPayload.mediaUrls ?? (replyPayload.mediaUrl ? [replyPayload.mediaUrl] : []); + const mediaUrls = resolveSendableOutboundReplyParts(replyPayload).mediaUrls; // Suppress duplicate heartbeats (same payload) within a short window. // This prevents "nagging" when nothing changed but the model repeats the same items. diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 452875d9cff..e1be816c910 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -1,3 +1,7 @@ +import { + resolveSendableOutboundReplyParts, + sendMediaWithLeadingCaption, +} from "openclaw/plugin-sdk/reply-payload"; import { chunkByParagraph, chunkMarkdownTextWithMode, @@ -23,7 +27,7 @@ import { toPluginMessageContext, toPluginMessageSentEvent, } from "../../hooks/message-hook-mappers.js"; -import { hasReplyChannelData, hasReplyContent } from "../../interactive/payload.js"; +import { hasReplyPayloadContent } from "../../interactive/payload.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; @@ -280,17 +284,8 @@ type MessageSentEvent = { function normalizeEmptyPayloadForDelivery(payload: ReplyPayload): ReplyPayload | null { const text = typeof payload.text === "string" ? payload.text : ""; - const hasChannelData = hasReplyChannelData(payload.channelData); if (!text.trim()) { - if ( - !hasReplyContent({ - text, - mediaUrl: payload.mediaUrl, - mediaUrls: payload.mediaUrls, - interactive: payload.interactive, - hasChannelData, - }) - ) { + if (!hasReplyPayloadContent({ ...payload, text })) { return null; } if (text) { @@ -336,9 +331,10 @@ function normalizePayloadsForChannelDelivery( } function buildPayloadSummary(payload: ReplyPayload): NormalizedOutboundPayload { + const parts = resolveSendableOutboundReplyParts(payload); return { - text: payload.text ?? "", - mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []), + text: parts.text, + mediaUrls: parts.mediaUrls, interactive: payload.interactive, channelData: payload.channelData, }; @@ -665,10 +661,10 @@ async function deliverOutboundPayloadsCore( }; if ( handler.sendPayload && - (effectivePayload.channelData || - hasReplyContent({ - interactive: effectivePayload.interactive, - })) + hasReplyPayloadContent({ + interactive: effectivePayload.interactive, + channelData: effectivePayload.channelData, + }) ) { const delivery = await handler.sendPayload(effectivePayload, sendOverrides); results.push(delivery); @@ -721,22 +717,27 @@ async function deliverOutboundPayloadsCore( continue; } - let first = true; let lastMessageId: string | undefined; - for (const url of payloadSummary.mediaUrls) { - throwIfAborted(abortSignal); - const caption = first ? payloadSummary.text : ""; - first = false; - if (handler.sendFormattedMedia) { - const delivery = await handler.sendFormattedMedia(caption, url, sendOverrides); + await sendMediaWithLeadingCaption({ + mediaUrls: payloadSummary.mediaUrls, + caption: payloadSummary.text, + send: async ({ mediaUrl, caption }) => { + throwIfAborted(abortSignal); + if (handler.sendFormattedMedia) { + const delivery = await handler.sendFormattedMedia( + caption ?? "", + mediaUrl, + sendOverrides, + ); + results.push(delivery); + lastMessageId = delivery.messageId; + return; + } + const delivery = await handler.sendMedia(caption ?? "", mediaUrl, sendOverrides); results.push(delivery); lastMessageId = delivery.messageId; - } else { - const delivery = await handler.sendMedia(caption, url, sendOverrides); - results.push(delivery); - lastMessageId = delivery.messageId; - } - } + }, + }); emitMessageSent({ success: true, content: payloadSummary.text, diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 1777fbb32e3..635c9df1005 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -14,7 +14,7 @@ import type { ChannelThreadingToolContext, } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { hasInteractiveReplyBlocks, hasReplyContent } from "../../interactive/payload.js"; +import { hasInteractiveReplyBlocks, hasReplyPayloadContent } from "../../interactive/payload.js"; import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; import { hasPollCreationParams, resolveTelegramPollVisibility } from "../../poll-params.js"; import { resolvePollMaxSelections } from "../../polls.js"; @@ -484,13 +484,17 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []), + (payload) => resolveSendableOutboundReplyParts(payload).mediaUrls, ); const primaryMediaUrl = mirrorMediaUrls[0] ?? params.mediaUrl ?? null; diff --git a/src/infra/outbound/payloads.ts b/src/infra/outbound/payloads.ts index d98bf22c218..39da3d2fdcb 100644 --- a/src/infra/outbound/payloads.ts +++ b/src/infra/outbound/payloads.ts @@ -1,3 +1,4 @@ +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import { parseReplyDirectives } from "../../auto-reply/reply/reply-directives.js"; import { formatBtwTextForExternalDelivery, @@ -8,7 +9,7 @@ import type { ReplyPayload } from "../../auto-reply/types.js"; import { hasInteractiveReplyBlocks, hasReplyChannelData, - hasReplyContent, + hasReplyPayloadContent, type InteractiveReply, } from "../../interactive/payload.js"; @@ -96,25 +97,20 @@ export function normalizeOutboundPayloads( ): NormalizedOutboundPayload[] { const normalizedPayloads: NormalizedOutboundPayload[] = []; for (const payload of normalizeReplyPayloadsForDelivery(payloads)) { - const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + const parts = resolveSendableOutboundReplyParts(payload); const interactive = payload.interactive; const channelData = payload.channelData; const hasChannelData = hasReplyChannelData(channelData); const hasInteractive = hasInteractiveReplyBlocks(interactive); - const text = payload.text ?? ""; + const text = parts.text; if ( - !hasReplyContent({ - text, - mediaUrls, - interactive, - hasChannelData, - }) + !hasReplyPayloadContent({ ...payload, text, mediaUrls: parts.mediaUrls }, { hasChannelData }) ) { continue; } normalizedPayloads.push({ text, - mediaUrls, + mediaUrls: parts.mediaUrls, ...(hasInteractive ? { interactive } : {}), ...(hasChannelData ? { channelData } : {}), }); @@ -127,10 +123,11 @@ export function normalizeOutboundPayloadsForJson( ): OutboundPayloadJson[] { const normalized: OutboundPayloadJson[] = []; for (const payload of normalizeReplyPayloadsForDelivery(payloads)) { + const parts = resolveSendableOutboundReplyParts(payload); normalized.push({ - text: payload.text ?? "", + text: parts.text, mediaUrl: payload.mediaUrl ?? null, - mediaUrls: payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : undefined), + mediaUrls: parts.mediaUrls.length ? parts.mediaUrls : undefined, interactive: payload.interactive, channelData: payload.channelData, }); diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index b15dfb881b2..2d294efbef9 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -1,10 +1,10 @@ +import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; import { normalizeChatType, type ChatType } from "../../channels/chat-type.js"; import type { ChannelOutboundTargetMode } from "../../channels/plugins/types.js"; import { formatCliCommand } from "../../cli/command-format.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; import type { AgentDefaultsConfig } from "../../config/types.agent-defaults.js"; -import { mapAllowFromEntries } from "../../plugin-sdk/channel-config-helpers.js"; import { normalizeAccountId } from "../../routing/session-key.js"; import { deliveryContextFromSession } from "../../utils/delivery-context.js"; import type { diff --git a/src/infra/provider-usage.auth.normalizes-keys.test.ts b/src/infra/provider-usage.auth.normalizes-keys.test.ts index 261ff0203bc..2408a28a9bd 100644 --- a/src/infra/provider-usage.auth.normalizes-keys.test.ts +++ b/src/infra/provider-usage.auth.normalizes-keys.test.ts @@ -1,9 +1,17 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { NON_ENV_SECRETREF_MARKER } from "../agents/model-auth-markers.js"; -import { resolveProviderAuths, type ProviderAuth } from "./provider-usage.auth.js"; + +const resolveProviderUsageAuthWithPluginMock = vi.fn(async (..._args: unknown[]) => null); + +vi.mock("../plugins/provider-runtime.js", () => ({ + resolveProviderUsageAuthWithPlugin: resolveProviderUsageAuthWithPluginMock, +})); + +let resolveProviderAuths: typeof import("./provider-usage.auth.js").resolveProviderAuths; +type ProviderAuth = import("./provider-usage.auth.js").ProviderAuth; describe("resolveProviderAuths key normalization", () => { let suiteRoot = ""; @@ -18,6 +26,7 @@ describe("resolveProviderAuths key normalization", () => { beforeAll(async () => { suiteRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-provider-auth-suite-")); + ({ resolveProviderAuths } = await import("./provider-usage.auth.js")); }); afterAll(async () => { @@ -26,6 +35,11 @@ describe("resolveProviderAuths key normalization", () => { suiteCase = 0; }); + beforeEach(() => { + resolveProviderUsageAuthWithPluginMock.mockReset(); + resolveProviderUsageAuthWithPluginMock.mockResolvedValue(null); + }); + async function withSuiteHome( fn: (home: string) => Promise, env: Record, diff --git a/src/infra/provider-usage.auth.ts b/src/infra/provider-usage.auth.ts index 982ffbc8be5..c503779b6f5 100644 --- a/src/infra/provider-usage.auth.ts +++ b/src/infra/provider-usage.auth.ts @@ -229,17 +229,19 @@ export async function resolveProviderAuths(params: { providers: UsageProviderId[]; auth?: ProviderAuth[]; agentDir?: string; + config?: OpenClawConfig; + env?: NodeJS.ProcessEnv; }): Promise { if (params.auth) { return params.auth; } const state: UsageAuthState = { - cfg: loadConfig(), + cfg: params.config ?? loadConfig(), store: ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false, }), - env: process.env, + env: params.env ?? process.env, agentDir: params.agentDir, }; const auths: ProviderAuth[] = []; diff --git a/src/infra/provider-usage.load.ts b/src/infra/provider-usage.load.ts index a8658889c68..ec870aa27ee 100644 --- a/src/infra/provider-usage.load.ts +++ b/src/infra/provider-usage.load.ts @@ -179,6 +179,8 @@ export async function loadProviderUsageSummary( providers: opts.providers ?? usageProviders, auth: opts.auth, agentDir: opts.agentDir, + config, + env, }); if (auths.length === 0) { return { updatedAt: now, providers: [] }; diff --git a/src/infra/provider-usage.test-support.ts b/src/infra/provider-usage.test-support.ts index 2d2609a29d6..13006bb7213 100644 --- a/src/infra/provider-usage.test-support.ts +++ b/src/infra/provider-usage.test-support.ts @@ -1,3 +1,4 @@ +import type { OpenClawConfig } from "../config/config.js"; import { createProviderUsageFetch } from "../test-utils/provider-usage-fetch.js"; import type { ProviderAuth } from "./provider-usage.auth.js"; import type { UsageSummary } from "./provider-usage.types.js"; @@ -8,6 +9,7 @@ type ProviderUsageLoader = (params: { now: number; auth?: ProviderAuth[]; fetch?: typeof fetch; + config?: OpenClawConfig; }) => Promise; export type ProviderUsageAuth = NonNullable< @@ -23,5 +25,7 @@ export async function loadUsageWithAuth( now: usageNow, auth, fetch: mockFetch as unknown as typeof fetch, + // These tests exercise the built-in usage fetchers, not provider plugin hooks. + config: { plugins: { enabled: false } } as OpenClawConfig, }); } diff --git a/src/infra/provider-usage.test.ts b/src/infra/provider-usage.test.ts index fdd2326a9a0..fb267613184 100644 --- a/src/infra/provider-usage.test.ts +++ b/src/infra/provider-usage.test.ts @@ -294,6 +294,7 @@ describe("provider usage loading", () => { providers: ["anthropic"], agentDir, fetch: mockFetch as unknown as typeof fetch, + config: { plugins: { enabled: false } }, }); const claude = expectSingleAnthropicProvider(summary); diff --git a/src/infra/state-migrations.ts b/src/infra/state-migrations.ts index b429365a4a4..8c8dd821df6 100644 --- a/src/infra/state-migrations.ts +++ b/src/infra/state-migrations.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { listTelegramAccountIds } from "openclaw/plugin-sdk/telegram"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/config.js"; import { @@ -15,7 +16,6 @@ import { canonicalizeMainSessionAlias } from "../config/sessions/main-session.js import type { SessionScope } from "../config/sessions/types.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveChannelAllowFromPath } from "../pairing/pairing-store.js"; -import { listTelegramAccountIds } from "../plugin-sdk/telegram.js"; import { buildAgentMainSessionKey, DEFAULT_ACCOUNT_ID, diff --git a/src/infra/system-run-normalize.ts b/src/infra/system-run-normalize.ts index 850685e033b..cbf37809356 100644 --- a/src/infra/system-run-normalize.ts +++ b/src/infra/system-run-normalize.ts @@ -1,4 +1,4 @@ -import { mapAllowFromEntries } from "../plugin-sdk/channel-config-helpers.js"; +import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; export function normalizeNonEmptyString(value: unknown): string | null { if (typeof value !== "string") { diff --git a/src/interactive/payload.test.ts b/src/interactive/payload.test.ts index 3000716cd2e..12c071d5652 100644 --- a/src/interactive/payload.test.ts +++ b/src/interactive/payload.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { hasReplyChannelData, hasReplyContent, + hasReplyPayloadContent, normalizeInteractiveReply, resolveInteractiveTextFallback, } from "./payload.js"; @@ -44,6 +45,41 @@ describe("hasReplyContent", () => { }); }); +describe("hasReplyPayloadContent", () => { + it("trims text and falls back to channel data by default", () => { + expect( + hasReplyPayloadContent({ + text: " ", + channelData: { slack: { blocks: [] } }, + }), + ).toBe(true); + }); + + it("accepts explicit channel-data overrides and extra content", () => { + expect( + hasReplyPayloadContent( + { + text: " ", + channelData: {}, + }, + { + hasChannelData: true, + }, + ), + ).toBe(true); + expect( + hasReplyPayloadContent( + { + text: " ", + }, + { + extraContent: true, + }, + ), + ).toBe(true); + }); +}); + describe("interactive payload helpers", () => { it("normalizes interactive replies and resolves text fallbacks", () => { const interactive = normalizeInteractiveReply({ diff --git a/src/interactive/payload.ts b/src/interactive/payload.ts index 5ccd55d0eff..8ab80131a8e 100644 --- a/src/interactive/payload.ts +++ b/src/interactive/payload.ts @@ -160,6 +160,30 @@ export function hasReplyContent(params: { ); } +export function hasReplyPayloadContent( + payload: { + text?: string | null; + mediaUrl?: string | null; + mediaUrls?: ReadonlyArray; + interactive?: unknown; + channelData?: unknown; + }, + options?: { + trimText?: boolean; + hasChannelData?: boolean; + extraContent?: boolean; + }, +): boolean { + return hasReplyContent({ + text: options?.trimText ? payload.text?.trim() : payload.text, + mediaUrl: payload.mediaUrl, + mediaUrls: payload.mediaUrls, + interactive: payload.interactive, + hasChannelData: options?.hasChannelData ?? hasReplyChannelData(payload.channelData), + extraContent: options?.extraContent, + }); +} + export function resolveInteractiveTextFallback(params: { text?: string; interactive?: InteractiveReply; diff --git a/src/line/auto-reply-delivery.ts b/src/line/auto-reply-delivery.ts index aa5443a536e..1e641707ce5 100644 --- a/src/line/auto-reply-delivery.ts +++ b/src/line/auto-reply-delivery.ts @@ -1,4 +1,5 @@ import type { messagingApi } from "@line/bot-sdk"; +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import type { ReplyPayload } from "../auto-reply/types.js"; import type { FlexContainer } from "./flex-templates.js"; import type { ProcessedLineMessage } from "./markdown-to-line.js"; @@ -123,7 +124,7 @@ export async function deliverLineAutoReply(params: { const chunks = processed.text ? deps.chunkMarkdownText(processed.text, textLimit) : []; - const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + const mediaUrls = resolveSendableOutboundReplyParts(payload).mediaUrls; const mediaMessages = mediaUrls .map((url) => url?.trim()) .filter((url): url is string => Boolean(url)) diff --git a/src/line/bot-handlers.ts b/src/line/bot-handlers.ts index 96d82afd33c..0a0d91bf19f 100644 --- a/src/line/bot-handlers.ts +++ b/src/line/bot-handlers.ts @@ -7,6 +7,7 @@ import type { LeaveEvent, PostbackEvent, } from "@line/bot-sdk"; +import { evaluateMatchedGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access"; import { hasControlCommand } from "../auto-reply/command-detection.js"; import { clearHistoryEntriesIfEnabled, @@ -30,7 +31,6 @@ import { readChannelAllowFromStore, upsertChannelPairingRequest, } from "../pairing/pairing-store.js"; -import { evaluateMatchedGroupAccessForPolicy } from "../plugin-sdk/group-access.js"; import { resolveAgentRoute } from "../routing/resolve-route.js"; import type { RuntimeEnv } from "../runtime.js"; import { diff --git a/src/line/download.ts b/src/line/download.ts index 8ec7ad45c32..6067fcc01f4 100644 --- a/src/line/download.ts +++ b/src/line/download.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import { messagingApi } from "@line/bot-sdk"; +import { buildRandomTempFilePath } from "openclaw/plugin-sdk/temp-path"; import { logVerbose } from "../globals.js"; -import { buildRandomTempFilePath } from "../plugin-sdk/temp-path.js"; interface DownloadResult { path: string; diff --git a/src/media-understanding/attachments.cache.ts b/src/media-understanding/attachments.cache.ts index f8e61265022..ce4f966d56d 100644 --- a/src/media-understanding/attachments.cache.ts +++ b/src/media-understanding/attachments.cache.ts @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { buildRandomTempFilePath } from "openclaw/plugin-sdk/temp-path"; import { logVerbose, shouldLogVerbose } from "../globals.js"; import { isAbortError } from "../infra/unhandled-rejections.js"; import { fetchRemoteMedia, MediaFetchError } from "../media/fetch.js"; @@ -10,7 +11,6 @@ import { } from "../media/inbound-path-policy.js"; import { getDefaultMediaLocalRoots } from "../media/local-roots.js"; import { detectMime } from "../media/mime.js"; -import { buildRandomTempFilePath } from "../plugin-sdk/temp-path.js"; import { normalizeAttachmentPath } from "./attachments.normalize.js"; import { MediaUnderstandingSkipError } from "./errors.js"; import { fetchWithTimeout } from "./providers/shared.js"; diff --git a/src/memory/qmd-process.ts b/src/memory/qmd-process.ts index 5a70cd3c361..60d1efd41ed 100644 --- a/src/memory/qmd-process.ts +++ b/src/memory/qmd-process.ts @@ -2,7 +2,7 @@ import { spawn } from "node:child_process"; import { materializeWindowsSpawnProgram, resolveWindowsSpawnProgram, -} from "../plugin-sdk/windows-spawn.js"; +} from "openclaw/plugin-sdk/windows-spawn"; export type CliSpawnInvocation = { command: string; diff --git a/src/plugin-sdk/allowlist-config-edit.test.ts b/src/plugin-sdk/allowlist-config-edit.test.ts new file mode 100644 index 00000000000..45305fcc0ed --- /dev/null +++ b/src/plugin-sdk/allowlist-config-edit.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it } from "vitest"; +import { + buildDmGroupAccountAllowlistAdapter, + buildLegacyDmAccountAllowlistAdapter, + collectAllowlistOverridesFromRecord, + collectNestedAllowlistOverridesFromRecord, + createAccountScopedAllowlistNameResolver, + createFlatAllowlistOverrideResolver, + createNestedAllowlistOverrideResolver, + readConfiguredAllowlistEntries, +} from "./allowlist-config-edit.js"; + +describe("readConfiguredAllowlistEntries", () => { + it("coerces mixed entries to non-empty strings", () => { + expect(readConfiguredAllowlistEntries(["owner", 42, ""])).toEqual(["owner", "42"]); + }); +}); + +describe("collectAllowlistOverridesFromRecord", () => { + it("collects only non-empty overrides from a flat record", () => { + expect( + collectAllowlistOverridesFromRecord({ + record: { + room1: { users: ["a", "b"] }, + room2: { users: [] }, + }, + label: (key) => key, + resolveEntries: (value) => value.users, + }), + ).toEqual([{ label: "room1", entries: ["a", "b"] }]); + }); +}); + +describe("collectNestedAllowlistOverridesFromRecord", () => { + it("collects outer and nested overrides from a hierarchical record", () => { + expect( + collectNestedAllowlistOverridesFromRecord({ + record: { + guild1: { + users: ["owner"], + channels: { + chan1: { users: ["member"] }, + }, + }, + }, + outerLabel: (key) => `guild ${key}`, + resolveOuterEntries: (value) => value.users, + resolveChildren: (value) => value.channels, + innerLabel: (outerKey, innerKey) => `guild ${outerKey} / channel ${innerKey}`, + resolveInnerEntries: (value) => value.users, + }), + ).toEqual([ + { label: "guild guild1", entries: ["owner"] }, + { label: "guild guild1 / channel chan1", entries: ["member"] }, + ]); + }); +}); + +describe("createFlatAllowlistOverrideResolver", () => { + it("builds an account-scoped flat override resolver", () => { + const resolveOverrides = createFlatAllowlistOverrideResolver({ + resolveRecord: (account: { channels?: Record }) => + account.channels, + label: (key) => key, + resolveEntries: (value) => value.users, + }); + + expect(resolveOverrides({ channels: { room1: { users: ["a"] } } })).toEqual([ + { label: "room1", entries: ["a"] }, + ]); + }); +}); + +describe("createNestedAllowlistOverrideResolver", () => { + it("builds an account-scoped nested override resolver", () => { + const resolveOverrides = createNestedAllowlistOverrideResolver({ + resolveRecord: (account: { + groups?: Record< + string, + { allowFrom?: string[]; topics?: Record } + >; + }) => account.groups, + outerLabel: (groupId) => groupId, + resolveOuterEntries: (group) => group.allowFrom, + resolveChildren: (group) => group.topics, + innerLabel: (groupId, topicId) => `${groupId} topic ${topicId}`, + resolveInnerEntries: (topic) => topic.allowFrom, + }); + + expect( + resolveOverrides({ + groups: { + g1: { allowFrom: ["owner"], topics: { t1: { allowFrom: ["member"] } } }, + }, + }), + ).toEqual([ + { label: "g1", entries: ["owner"] }, + { label: "g1 topic t1", entries: ["member"] }, + ]); + }); +}); + +describe("createAccountScopedAllowlistNameResolver", () => { + it("returns empty results when the resolved account has no token", async () => { + const resolveNames = createAccountScopedAllowlistNameResolver({ + resolveAccount: () => ({ token: "" }), + resolveToken: (account) => account.token, + resolveNames: async ({ token, entries }) => + entries.map((entry) => ({ input: `${token}:${entry}`, resolved: true })), + }); + + expect(await resolveNames({ cfg: {}, accountId: "alt", scope: "dm", entries: ["a"] })).toEqual( + [], + ); + }); + + it("delegates to the resolver when a token is present", async () => { + const resolveNames = createAccountScopedAllowlistNameResolver({ + resolveAccount: () => ({ token: " secret " }), + resolveToken: (account) => account.token, + resolveNames: async ({ token, entries }) => + entries.map((entry) => ({ input: entry, resolved: true, name: `${token}:${entry}` })), + }); + + expect(await resolveNames({ cfg: {}, accountId: "alt", scope: "dm", entries: ["a"] })).toEqual([ + { input: "a", resolved: true, name: "secret:a" }, + ]); + }); +}); + +describe("buildDmGroupAccountAllowlistAdapter", () => { + const adapter = buildDmGroupAccountAllowlistAdapter({ + channelId: "demo", + resolveAccount: ({ accountId }) => ({ + accountId: accountId ?? "default", + dmAllowFrom: ["dm-owner"], + groupAllowFrom: ["group-owner"], + dmPolicy: "allowlist", + groupPolicy: "allowlist", + groupOverrides: [{ label: "room-1", entries: ["member-1"] }], + }), + normalize: ({ values }) => values.map((entry) => String(entry).trim().toLowerCase()), + resolveDmAllowFrom: (account) => account.dmAllowFrom, + resolveGroupAllowFrom: (account) => account.groupAllowFrom, + resolveDmPolicy: (account) => account.dmPolicy, + resolveGroupPolicy: (account) => account.groupPolicy, + resolveGroupOverrides: (account) => account.groupOverrides, + }); + + it("supports dm, group, and all scopes", () => { + expect(adapter.supportsScope?.({ scope: "dm" })).toBe(true); + expect(adapter.supportsScope?.({ scope: "group" })).toBe(true); + expect(adapter.supportsScope?.({ scope: "all" })).toBe(true); + }); + + it("reads dm/group config from the resolved account", () => { + expect(adapter.readConfig?.({ cfg: {}, accountId: "alt" })).toEqual({ + dmAllowFrom: ["dm-owner"], + groupAllowFrom: ["group-owner"], + dmPolicy: "allowlist", + groupPolicy: "allowlist", + groupOverrides: [{ label: "room-1", entries: ["member-1"] }], + }); + }); + + it("writes group allowlist entries to groupAllowFrom", () => { + expect( + adapter.applyConfigEdit?.({ + cfg: {}, + parsedConfig: {}, + accountId: "alt", + scope: "group", + action: "add", + entry: " Member-2 ", + }), + ).toEqual({ + kind: "ok", + changed: true, + pathLabel: "channels.demo.accounts.alt.groupAllowFrom", + writeTarget: { + kind: "account", + scope: { channelId: "demo", accountId: "alt" }, + }, + }); + }); +}); + +describe("buildLegacyDmAccountAllowlistAdapter", () => { + const adapter = buildLegacyDmAccountAllowlistAdapter({ + channelId: "demo", + resolveAccount: ({ accountId }) => ({ + accountId: accountId ?? "default", + dmAllowFrom: ["owner"], + groupPolicy: "allowlist", + groupOverrides: [{ label: "group-1", entries: ["member-1"] }], + }), + normalize: ({ values }) => values.map((entry) => String(entry).trim().toLowerCase()), + resolveDmAllowFrom: (account) => account.dmAllowFrom, + resolveGroupPolicy: (account) => account.groupPolicy, + resolveGroupOverrides: (account) => account.groupOverrides, + }); + + it("supports only dm scope", () => { + expect(adapter.supportsScope?.({ scope: "dm" })).toBe(true); + expect(adapter.supportsScope?.({ scope: "group" })).toBe(false); + expect(adapter.supportsScope?.({ scope: "all" })).toBe(false); + }); + + it("reads legacy dm config from the resolved account", () => { + expect(adapter.readConfig?.({ cfg: {}, accountId: "alt" })).toEqual({ + dmAllowFrom: ["owner"], + groupPolicy: "allowlist", + groupOverrides: [{ label: "group-1", entries: ["member-1"] }], + }); + }); + + it("writes dm allowlist entries and keeps legacy cleanup behavior", () => { + expect( + adapter.applyConfigEdit?.({ + cfg: {}, + parsedConfig: { + channels: { + demo: { + accounts: { + alt: { + dm: { allowFrom: ["owner"] }, + }, + }, + }, + }, + }, + accountId: "alt", + scope: "dm", + action: "add", + entry: "admin", + }), + ).toEqual({ + kind: "ok", + changed: true, + pathLabel: "channels.demo.accounts.alt.allowFrom", + writeTarget: { + kind: "account", + scope: { channelId: "demo", accountId: "alt" }, + }, + }); + }); +}); diff --git a/src/plugin-sdk/allowlist-config-edit.ts b/src/plugin-sdk/allowlist-config-edit.ts index e92e4cb8551..4891bb5075a 100644 --- a/src/plugin-sdk/allowlist-config-edit.ts +++ b/src/plugin-sdk/allowlist-config-edit.ts @@ -11,16 +11,152 @@ type AllowlistConfigPaths = { cleanupPaths?: string[][]; }; +export type AllowlistGroupOverride = { label: string; entries: string[] }; +export type AllowlistNameResolution = Array<{ + input: string; + resolved: boolean; + name?: string | null; +}>; +type AllowlistNormalizer = (params: { + cfg: OpenClawConfig; + accountId?: string | null; + values: Array; +}) => string[]; +type AllowlistAccountResolver = (params: { + cfg: OpenClawConfig; + accountId?: string | null; +}) => ResolvedAccount; + +const DM_ALLOWLIST_CONFIG_PATHS: AllowlistConfigPaths = { + readPaths: [["allowFrom"]], + writePath: ["allowFrom"], +}; + +const GROUP_ALLOWLIST_CONFIG_PATHS: AllowlistConfigPaths = { + readPaths: [["groupAllowFrom"]], + writePath: ["groupAllowFrom"], +}; + const LEGACY_DM_ALLOWLIST_CONFIG_PATHS: AllowlistConfigPaths = { readPaths: [["allowFrom"], ["dm", "allowFrom"]], writePath: ["allowFrom"], cleanupPaths: [["dm", "allowFrom"]], }; +export function resolveDmGroupAllowlistConfigPaths(scope: "dm" | "group") { + return scope === "dm" ? DM_ALLOWLIST_CONFIG_PATHS : GROUP_ALLOWLIST_CONFIG_PATHS; +} + export function resolveLegacyDmAllowlistConfigPaths(scope: "dm" | "group") { return scope === "dm" ? LEGACY_DM_ALLOWLIST_CONFIG_PATHS : null; } +/** Coerce stored allowlist entries into presentable non-empty strings. */ +export function readConfiguredAllowlistEntries( + entries: Array | null | undefined, +): string[] { + return (entries ?? []).map(String).filter(Boolean); +} + +/** Collect labeled allowlist overrides from a flat keyed record. */ +export function collectAllowlistOverridesFromRecord(params: { + record: Record | null | undefined; + label: (key: string, value: T) => string; + resolveEntries: (value: T) => Array | null | undefined; +}): AllowlistGroupOverride[] { + const overrides: AllowlistGroupOverride[] = []; + for (const [key, value] of Object.entries(params.record ?? {})) { + if (!value) { + continue; + } + const entries = readConfiguredAllowlistEntries(params.resolveEntries(value)); + if (entries.length === 0) { + continue; + } + overrides.push({ label: params.label(key, value), entries }); + } + return overrides; +} + +/** Collect labeled allowlist overrides from an outer record with nested child records. */ +export function collectNestedAllowlistOverridesFromRecord(params: { + record: Record | null | undefined; + outerLabel: (key: string, value: Outer) => string; + resolveOuterEntries: (value: Outer) => Array | null | undefined; + resolveChildren: (value: Outer) => Record | null | undefined; + innerLabel: (outerKey: string, innerKey: string, inner: Inner) => string; + resolveInnerEntries: (value: Inner) => Array | null | undefined; +}): AllowlistGroupOverride[] { + const overrides: AllowlistGroupOverride[] = []; + for (const [outerKey, outerValue] of Object.entries(params.record ?? {})) { + if (!outerValue) { + continue; + } + const outerEntries = readConfiguredAllowlistEntries(params.resolveOuterEntries(outerValue)); + if (outerEntries.length > 0) { + overrides.push({ label: params.outerLabel(outerKey, outerValue), entries: outerEntries }); + } + overrides.push( + ...collectAllowlistOverridesFromRecord({ + record: params.resolveChildren(outerValue), + label: (innerKey, innerValue) => params.innerLabel(outerKey, innerKey, innerValue), + resolveEntries: params.resolveInnerEntries, + }), + ); + } + return overrides; +} + +/** Build an account-scoped flat override resolver from a keyed allowlist record. */ +export function createFlatAllowlistOverrideResolver(params: { + resolveRecord: (account: ResolvedAccount) => Record | null | undefined; + label: (key: string, value: Entry) => string; + resolveEntries: (value: Entry) => Array | null | undefined; +}): (account: ResolvedAccount) => AllowlistGroupOverride[] { + return (account) => + collectAllowlistOverridesFromRecord({ + record: params.resolveRecord(account), + label: params.label, + resolveEntries: params.resolveEntries, + }); +} + +/** Build an account-scoped nested override resolver from hierarchical allowlist records. */ +export function createNestedAllowlistOverrideResolver(params: { + resolveRecord: (account: ResolvedAccount) => Record | null | undefined; + outerLabel: (key: string, value: Outer) => string; + resolveOuterEntries: (value: Outer) => Array | null | undefined; + resolveChildren: (value: Outer) => Record | null | undefined; + innerLabel: (outerKey: string, innerKey: string, inner: Inner) => string; + resolveInnerEntries: (value: Inner) => Array | null | undefined; +}): (account: ResolvedAccount) => AllowlistGroupOverride[] { + return (account) => + collectNestedAllowlistOverridesFromRecord({ + record: params.resolveRecord(account), + outerLabel: params.outerLabel, + resolveOuterEntries: params.resolveOuterEntries, + resolveChildren: params.resolveChildren, + innerLabel: params.innerLabel, + resolveInnerEntries: params.resolveInnerEntries, + }); +} + +/** Build the common account-scoped token-gated allowlist name resolver. */ +export function createAccountScopedAllowlistNameResolver(params: { + resolveAccount: (params: { cfg: OpenClawConfig; accountId?: string | null }) => ResolvedAccount; + resolveToken: (account: ResolvedAccount) => string | null | undefined; + resolveNames: (params: { token: string; entries: string[] }) => Promise; +}): NonNullable { + return async ({ cfg, accountId, entries }) => { + const account = params.resolveAccount({ cfg, accountId }); + const token = params.resolveToken(account)?.trim(); + if (!token) { + return []; + } + return await params.resolveNames({ token, entries }); + }; +} + function resolveAccountScopedWriteTarget( parsed: Record, channelId: ChannelId, @@ -196,11 +332,7 @@ function applyAccountScopedAllowlistConfigEdit(params: { /** Build the default account-scoped allowlist editor used by channel plugins with config-backed lists. */ export function buildAccountScopedAllowlistConfigEditor(params: { channelId: ChannelId; - normalize: (params: { - cfg: OpenClawConfig; - accountId?: string | null; - values: Array; - }) => string[]; + normalize: AllowlistNormalizer; resolvePaths: (scope: "dm" | "group") => AllowlistConfigPaths | null; }): NonNullable { return ({ cfg, parsedConfig, accountId, scope, action, entry }) => { @@ -219,3 +351,75 @@ export function buildAccountScopedAllowlistConfigEditor(params: { }); }; } + +function buildAccountAllowlistAdapter(params: { + channelId: ChannelId; + resolveAccount: AllowlistAccountResolver; + normalize: AllowlistNormalizer; + supportsScope: NonNullable; + resolvePaths: (scope: "dm" | "group") => AllowlistConfigPaths | null; + readConfig: ( + account: ResolvedAccount, + ) => Awaited>>; +}): Pick { + return { + supportsScope: params.supportsScope, + readConfig: ({ cfg, accountId }) => + params.readConfig(params.resolveAccount({ cfg, accountId })), + applyConfigEdit: buildAccountScopedAllowlistConfigEditor({ + channelId: params.channelId, + normalize: params.normalize, + resolvePaths: params.resolvePaths, + }), + }; +} + +/** Build the common DM/group allowlist adapter used by channels that store both lists in config. */ +export function buildDmGroupAccountAllowlistAdapter(params: { + channelId: ChannelId; + resolveAccount: AllowlistAccountResolver; + normalize: AllowlistNormalizer; + resolveDmAllowFrom: (account: ResolvedAccount) => Array | null | undefined; + resolveGroupAllowFrom: (account: ResolvedAccount) => Array | null | undefined; + resolveDmPolicy?: (account: ResolvedAccount) => string | null | undefined; + resolveGroupPolicy?: (account: ResolvedAccount) => string | null | undefined; + resolveGroupOverrides?: (account: ResolvedAccount) => AllowlistGroupOverride[] | undefined; +}): Pick { + return buildAccountAllowlistAdapter({ + channelId: params.channelId, + resolveAccount: params.resolveAccount, + normalize: params.normalize, + supportsScope: ({ scope }) => scope === "dm" || scope === "group" || scope === "all", + resolvePaths: resolveDmGroupAllowlistConfigPaths, + readConfig: (account) => ({ + dmAllowFrom: readConfiguredAllowlistEntries(params.resolveDmAllowFrom(account)), + groupAllowFrom: readConfiguredAllowlistEntries(params.resolveGroupAllowFrom(account)), + dmPolicy: params.resolveDmPolicy?.(account) ?? undefined, + groupPolicy: params.resolveGroupPolicy?.(account) ?? undefined, + groupOverrides: params.resolveGroupOverrides?.(account), + }), + }); +} + +/** Build the common DM-only allowlist adapter for channels with legacy dm.allowFrom fallback paths. */ +export function buildLegacyDmAccountAllowlistAdapter(params: { + channelId: ChannelId; + resolveAccount: AllowlistAccountResolver; + normalize: AllowlistNormalizer; + resolveDmAllowFrom: (account: ResolvedAccount) => Array | null | undefined; + resolveGroupPolicy?: (account: ResolvedAccount) => string | null | undefined; + resolveGroupOverrides?: (account: ResolvedAccount) => AllowlistGroupOverride[] | undefined; +}): Pick { + return buildAccountAllowlistAdapter({ + channelId: params.channelId, + resolveAccount: params.resolveAccount, + normalize: params.normalize, + supportsScope: ({ scope }) => scope === "dm", + resolvePaths: resolveLegacyDmAllowlistConfigPaths, + readConfig: (account) => ({ + dmAllowFrom: readConfiguredAllowlistEntries(params.resolveDmAllowFrom(account)), + groupPolicy: params.resolveGroupPolicy?.(account) ?? undefined, + groupOverrides: params.resolveGroupOverrides?.(account), + }), + }); +} diff --git a/src/plugin-sdk/channel-policy.ts b/src/plugin-sdk/channel-policy.ts index c59643a4e4b..06dc117b9b2 100644 --- a/src/plugin-sdk/channel-policy.ts +++ b/src/plugin-sdk/channel-policy.ts @@ -5,6 +5,15 @@ export type { } from "../config/types.tools.js"; export { buildOpenGroupPolicyConfigureRouteAllowlistWarning, + composeWarningCollectors, + createAllowlistProviderGroupPolicyWarningCollector, + createConditionalWarningCollector, + createAllowlistProviderOpenWarningCollector, + createAllowlistProviderRestrictSendersWarningCollector, + createAllowlistProviderRouteAllowlistWarningCollector, + createOpenGroupPolicyRestrictSendersWarningCollector, + createOpenProviderGroupPolicyWarningCollector, + createOpenProviderConfiguredRouteWarningCollector, buildOpenGroupPolicyRestrictSendersWarning, buildOpenGroupPolicyWarning, collectAllowlistProviderGroupPolicyWarnings, @@ -12,6 +21,7 @@ export { collectOpenGroupPolicyRestrictSendersWarnings, collectOpenGroupPolicyRouteAllowlistWarnings, collectOpenProviderGroupPolicyWarnings, + projectWarningCollector, } from "../channels/plugins/group-policy-warnings.js"; export { buildAccountScopedDmSecurityPolicy } from "../channels/plugins/helpers.js"; export { diff --git a/src/plugin-sdk/channel-runtime.ts b/src/plugin-sdk/channel-runtime.ts index 59832d70f80..67e4ceef1ea 100644 --- a/src/plugin-sdk/channel-runtime.ts +++ b/src/plugin-sdk/channel-runtime.ts @@ -32,12 +32,17 @@ export * from "../channels/plugins/actions/reaction-message-id.js"; export * from "../channels/plugins/actions/shared.js"; export type * from "../channels/plugins/types.js"; export * from "../channels/plugins/config-writes.js"; +export * from "../channels/plugins/directory-adapters.js"; export * from "../channels/plugins/media-payload.js"; export * from "../channels/plugins/message-tool-schema.js"; export * from "../channels/plugins/normalize/signal.js"; export * from "../channels/plugins/normalize/whatsapp.js"; export * from "../channels/plugins/outbound/direct-text-media.js"; export * from "../channels/plugins/outbound/interactive.js"; +export * from "../channels/plugins/pairing-adapters.js"; +export * from "../channels/plugins/runtime-forwarders.js"; +export * from "../channels/plugins/target-resolvers.js"; +export * from "../channels/plugins/threading-helpers.js"; export * from "../channels/plugins/status-issues/shared.js"; export * from "../channels/plugins/whatsapp-heartbeat.js"; export * from "../infra/outbound/send-deps.js"; @@ -45,6 +50,7 @@ export * from "../polls.js"; export * from "../utils/message-channel.js"; export * from "../whatsapp/normalize.js"; export { createActionGate, jsonResult, readStringParam } from "../agents/tools/common.js"; +export * from "./channel-send-result.js"; export * from "./channel-lifecycle.js"; export * from "./directory-runtime.js"; export type { diff --git a/src/plugin-sdk/channel-send-result.test.ts b/src/plugin-sdk/channel-send-result.test.ts new file mode 100644 index 00000000000..37d29a5a190 --- /dev/null +++ b/src/plugin-sdk/channel-send-result.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { + attachChannelToResult, + attachChannelToResults, + buildChannelSendResult, + createAttachedChannelResultAdapter, + createEmptyChannelResult, + createRawChannelSendResultAdapter, +} from "./channel-send-result.js"; + +describe("attachChannelToResult", () => { + it("preserves the existing result shape and stamps the channel", () => { + expect( + attachChannelToResult("discord", { + messageId: "m1", + ok: true, + extra: "value", + }), + ).toEqual({ + channel: "discord", + messageId: "m1", + ok: true, + extra: "value", + }); + }); +}); + +describe("attachChannelToResults", () => { + it("stamps each result in a list with the shared channel id", () => { + expect( + attachChannelToResults("signal", [ + { messageId: "m1", timestamp: 1 }, + { messageId: "m2", timestamp: 2 }, + ]), + ).toEqual([ + { channel: "signal", messageId: "m1", timestamp: 1 }, + { channel: "signal", messageId: "m2", timestamp: 2 }, + ]); + }); +}); + +describe("buildChannelSendResult", () => { + it("normalizes raw send results", () => { + const result = buildChannelSendResult("zalo", { + ok: false, + messageId: null, + error: "boom", + }); + + expect(result.channel).toBe("zalo"); + expect(result.ok).toBe(false); + expect(result.messageId).toBe(""); + expect(result.error).toEqual(new Error("boom")); + }); +}); + +describe("createEmptyChannelResult", () => { + it("builds an empty outbound result with channel metadata", () => { + expect(createEmptyChannelResult("line", { chatId: "u1" })).toEqual({ + channel: "line", + messageId: "", + chatId: "u1", + }); + }); +}); + +describe("createAttachedChannelResultAdapter", () => { + it("wraps outbound delivery and poll results", async () => { + const adapter = createAttachedChannelResultAdapter({ + channel: "discord", + sendText: async () => ({ messageId: "m1", channelId: "c1" }), + sendMedia: async () => ({ messageId: "m2" }), + sendPoll: async () => ({ messageId: "m3", pollId: "p1" }), + }); + + await expect(adapter.sendText!({ cfg: {} as never, to: "x", text: "hi" })).resolves.toEqual({ + channel: "discord", + messageId: "m1", + channelId: "c1", + }); + await expect(adapter.sendMedia!({ cfg: {} as never, to: "x", text: "hi" })).resolves.toEqual({ + channel: "discord", + messageId: "m2", + }); + await expect( + adapter.sendPoll!({ + cfg: {} as never, + to: "x", + poll: { question: "t", options: ["a", "b"] }, + }), + ).resolves.toEqual({ + channel: "discord", + messageId: "m3", + pollId: "p1", + }); + }); +}); + +describe("createRawChannelSendResultAdapter", () => { + it("normalizes raw send results", async () => { + const adapter = createRawChannelSendResultAdapter({ + channel: "zalo", + sendText: async () => ({ ok: true, messageId: "m1" }), + sendMedia: async () => ({ ok: false, error: "boom" }), + }); + + await expect(adapter.sendText!({ cfg: {} as never, to: "x", text: "hi" })).resolves.toEqual({ + channel: "zalo", + ok: true, + messageId: "m1", + error: undefined, + }); + await expect(adapter.sendMedia!({ cfg: {} as never, to: "x", text: "hi" })).resolves.toEqual({ + channel: "zalo", + ok: false, + messageId: "", + error: new Error("boom"), + }); + }); +}); diff --git a/src/plugin-sdk/channel-send-result.ts b/src/plugin-sdk/channel-send-result.ts index b73df6f0448..12e74741264 100644 --- a/src/plugin-sdk/channel-send-result.ts +++ b/src/plugin-sdk/channel-send-result.ts @@ -1,9 +1,74 @@ +import type { ChannelOutboundAdapter, ChannelPollResult } from "../channels/plugins/types.js"; +import type { OutboundDeliveryResult } from "../infra/outbound/deliver.js"; + export type ChannelSendRawResult = { ok: boolean; messageId?: string | null; error?: string | null; }; +export function attachChannelToResult(channel: string, result: T) { + return { + channel, + ...result, + }; +} + +export function attachChannelToResults(channel: string, results: readonly T[]) { + return results.map((result) => attachChannelToResult(channel, result)); +} + +export function createEmptyChannelResult( + channel: string, + result: Partial> & { + messageId?: string; + } = {}, +): OutboundDeliveryResult { + return attachChannelToResult(channel, { + messageId: "", + ...result, + }); +} + +type MaybePromise = T | Promise; +type SendTextParams = Parameters>[0]; +type SendMediaParams = Parameters>[0]; +type SendPollParams = Parameters>[0]; + +export function createAttachedChannelResultAdapter(params: { + channel: string; + sendText?: (ctx: SendTextParams) => MaybePromise>; + sendMedia?: (ctx: SendMediaParams) => MaybePromise>; + sendPoll?: (ctx: SendPollParams) => MaybePromise>; +}): Pick { + return { + sendText: params.sendText + ? async (ctx) => attachChannelToResult(params.channel, await params.sendText!(ctx)) + : undefined, + sendMedia: params.sendMedia + ? async (ctx) => attachChannelToResult(params.channel, await params.sendMedia!(ctx)) + : undefined, + sendPoll: params.sendPoll + ? async (ctx) => attachChannelToResult(params.channel, await params.sendPoll!(ctx)) + : undefined, + }; +} + +export function createRawChannelSendResultAdapter(params: { + channel: string; + sendText?: (ctx: SendTextParams) => MaybePromise; + sendMedia?: (ctx: SendMediaParams) => MaybePromise; +}): Pick { + return { + sendText: params.sendText + ? async (ctx) => buildChannelSendResult(params.channel, await params.sendText!(ctx)) + : undefined, + sendMedia: params.sendMedia + ? async (ctx) => buildChannelSendResult(params.channel, await params.sendMedia!(ctx)) + : undefined, + }; +} + /** Normalize raw channel send results into the shape shared outbound callers expect. */ export function buildChannelSendResult(channel: string, result: ChannelSendRawResult) { return { diff --git a/src/plugin-sdk/directory-runtime.ts b/src/plugin-sdk/directory-runtime.ts index a13a368abd4..caa21657810 100644 --- a/src/plugin-sdk/directory-runtime.ts +++ b/src/plugin-sdk/directory-runtime.ts @@ -4,8 +4,13 @@ export type { ReadOnlyInspectedAccount } from "../channels/read-only-account-ins export { applyDirectoryQueryAndLimit, collectNormalizedDirectoryIds, + listDirectoryEntriesFromSources, listDirectoryGroupEntriesFromMapKeys, listDirectoryGroupEntriesFromMapKeysAndAllowFrom, + listInspectedDirectoryEntriesFromSources, + listResolvedDirectoryEntriesFromSources, + listResolvedDirectoryGroupEntriesFromMapKeys, + listResolvedDirectoryUserEntriesFromAllowFrom, listDirectoryUserEntriesFromAllowFrom, listDirectoryUserEntriesFromAllowFromAndMapKeys, toDirectoryEntries, diff --git a/src/plugin-sdk/discord-send.ts b/src/plugin-sdk/discord-send.ts index 679b5109a5e..7870bc2f2fa 100644 --- a/src/plugin-sdk/discord-send.ts +++ b/src/plugin-sdk/discord-send.ts @@ -1,4 +1,5 @@ import type { DiscordSendResult } from "../../extensions/discord/api.js"; +import { attachChannelToResult } from "./channel-send-result.js"; type DiscordSendOptionInput = { replyToId?: string | null; @@ -32,5 +33,5 @@ export function buildDiscordSendMediaOptions(input: DiscordSendMediaOptionInput) /** Stamp raw Discord send results with the channel id expected by shared outbound flows. */ export function tagDiscordChannelResult(result: DiscordSendResult) { - return { channel: "discord" as const, ...result }; + return attachChannelToResult("discord", result); } diff --git a/src/plugin-sdk/discord.ts b/src/plugin-sdk/discord.ts index 2949446fef6..4a968f2fbbc 100644 --- a/src/plugin-sdk/discord.ts +++ b/src/plugin-sdk/discord.ts @@ -84,7 +84,14 @@ export { export { normalizeExplicitDiscordSessionKey } from "../../extensions/discord/session-key-api.js"; export { autoBindSpawnedDiscordSubagent, + getThreadBindingManager, listThreadBindingsBySessionKey, + resolveThreadBindingIdleTimeoutMs, + resolveThreadBindingInactivityExpiresAt, + resolveThreadBindingMaxAgeExpiresAt, + resolveThreadBindingMaxAgeMs, + setThreadBindingIdleTimeoutBySessionKey, + setThreadBindingMaxAgeBySessionKey, unbindThreadBindingsBySessionKey, } from "../../extensions/discord/runtime-api.js"; export { getGateway } from "../../extensions/discord/runtime-api.js"; @@ -93,6 +100,7 @@ export { readDiscordComponentSpec } from "../../extensions/discord/api.js"; export { resolveDiscordChannelId } from "../../extensions/discord/api.js"; export { addRoleDiscord, + auditDiscordChannelPermissions, banMemberDiscord, createChannelDiscord, createScheduledEventDiscord, @@ -110,23 +118,30 @@ export { fetchVoiceStatusDiscord, hasAnyGuildPermissionDiscord, kickMemberDiscord, + listDiscordDirectoryGroupsLive, + listDiscordDirectoryPeersLive, listGuildChannelsDiscord, listGuildEmojisDiscord, listPinsDiscord, listScheduledEventsDiscord, listThreadsDiscord, + monitorDiscordProvider, moveChannelDiscord, pinMessageDiscord, + probeDiscord, reactMessageDiscord, readMessagesDiscord, removeChannelPermissionDiscord, removeOwnReactionsDiscord, removeReactionDiscord, removeRoleDiscord, + resolveDiscordChannelAllowlist, + resolveDiscordUserAllowlist, searchMessagesDiscord, sendDiscordComponentMessage, sendMessageDiscord, sendPollDiscord, + sendTypingDiscord, sendStickerDiscord, sendVoiceMessageDiscord, setChannelPermissionDiscord, diff --git a/src/plugin-sdk/googlechat.ts b/src/plugin-sdk/googlechat.ts index ade38097fad..bbb818b78b8 100644 --- a/src/plugin-sdk/googlechat.ts +++ b/src/plugin-sdk/googlechat.ts @@ -1,6 +1,12 @@ // Narrow plugin-sdk surface for the bundled googlechat plugin. // Keep this list additive and scoped to symbols used under extensions/googlechat. +import { resolveChannelGroupRequireMention } from "./channel-policy.js"; +import { + createOptionalChannelSetupAdapter, + createOptionalChannelSetupWizard, +} from "./optional-channel-setup.js"; + export { createActionGate, jsonResult, @@ -20,7 +26,6 @@ export { export { buildComputedAccountStatusSnapshot } from "./status-helpers.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export { createAccountStatusSink, runPassiveAccountLifecycle } from "./channel-lifecycle.js"; -export { resolveGoogleChatGroupRequireMention } from "../../extensions/googlechat/src/group-policy.js"; export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; export { @@ -65,8 +70,6 @@ export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.j export { resolveDmGroupAccessWithLists } from "../security/dm-policy-shared.js"; export { formatDocsLink } from "../terminal/links.js"; export type { WizardPrompter } from "../wizard/prompts.js"; -export { googlechatSetupAdapter } from "../../extensions/googlechat/api.js"; -export { googlechatSetupWizard } from "../../extensions/googlechat/api.js"; export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "./inbound-envelope.js"; export { createScopedPairingAccess } from "./pairing-access.js"; export { issuePairingChallenge } from "../pairing/pairing-challenge.js"; @@ -88,3 +91,32 @@ export { resolveWebhookTargetWithAuthOrReject, withResolvedWebhookRequestPipeline, } from "./webhook-targets.js"; + +type GoogleChatGroupContext = { + cfg: import("../config/config.js").OpenClawConfig; + accountId?: string | null; + groupId?: string | null; +}; + +export function resolveGoogleChatGroupRequireMention(params: GoogleChatGroupContext): boolean { + return resolveChannelGroupRequireMention({ + cfg: params.cfg, + channel: "googlechat", + groupId: params.groupId, + accountId: params.accountId, + }); +} + +export const googlechatSetupAdapter = createOptionalChannelSetupAdapter({ + channel: "googlechat", + label: "Google Chat", + npmSpec: "@openclaw/googlechat", + docsPath: "/channels/googlechat", +}); + +export const googlechatSetupWizard = createOptionalChannelSetupWizard({ + channel: "googlechat", + label: "Google Chat", + npmSpec: "@openclaw/googlechat", + docsPath: "/channels/googlechat", +}); diff --git a/src/plugin-sdk/imessage.ts b/src/plugin-sdk/imessage.ts index c69abdc6b5c..b6c98da97c6 100644 --- a/src/plugin-sdk/imessage.ts +++ b/src/plugin-sdk/imessage.ts @@ -43,4 +43,8 @@ export { IMessageConfigSchema } from "../config/zod-schema.providers-core.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; export { collectStatusIssuesFromLastError } from "./status-helpers.js"; -export { sendMessageIMessage } from "../../extensions/imessage/runtime-api.js"; +export { + monitorIMessageProvider, + probeIMessage, + sendMessageIMessage, +} from "../../extensions/imessage/runtime-api.js"; diff --git a/src/plugin-sdk/irc.ts b/src/plugin-sdk/irc.ts index 47ba490ec42..b64614348cb 100644 --- a/src/plugin-sdk/irc.ts +++ b/src/plugin-sdk/irc.ts @@ -76,6 +76,7 @@ export { ircSetupAdapter, ircSetupWizard } from "../../extensions/irc/api.js"; export type { OutboundReplyPayload } from "./reply-payload.js"; export { createNormalizedOutboundDeliverer, + deliverFormattedTextWithAttachments, formatTextWithAttachmentLinks, resolveOutboundMediaUrls, } from "./reply-payload.js"; diff --git a/src/plugin-sdk/matrix.ts b/src/plugin-sdk/matrix.ts index 099b53792da..5bbaac2ce48 100644 --- a/src/plugin-sdk/matrix.ts +++ b/src/plugin-sdk/matrix.ts @@ -1,6 +1,11 @@ // Narrow plugin-sdk surface for the bundled matrix plugin. // Keep this list additive and scoped to symbols used under extensions/matrix. +import { + createOptionalChannelSetupAdapter, + createOptionalChannelSetupWizard, +} from "./optional-channel-setup.js"; + export { createActionGate, jsonResult, @@ -108,5 +113,17 @@ export { buildProbeChannelStatusSummary, collectStatusIssuesFromLastError, } from "./status-helpers.js"; -export { matrixSetupWizard } from "../../extensions/matrix/api.js"; -export { matrixSetupAdapter } from "../../extensions/matrix/api.js"; + +export const matrixSetupWizard = createOptionalChannelSetupWizard({ + channel: "matrix", + label: "Matrix", + npmSpec: "@openclaw/matrix", + docsPath: "/channels/matrix", +}); + +export const matrixSetupAdapter = createOptionalChannelSetupAdapter({ + channel: "matrix", + label: "Matrix", + npmSpec: "@openclaw/matrix", + docsPath: "/channels/matrix", +}); diff --git a/src/plugin-sdk/msteams.ts b/src/plugin-sdk/msteams.ts index 1185558de79..51f8ef257b2 100644 --- a/src/plugin-sdk/msteams.ts +++ b/src/plugin-sdk/msteams.ts @@ -1,6 +1,11 @@ // Narrow plugin-sdk surface for the bundled msteams plugin. // Keep this list additive and scoped to symbols used under extensions/msteams. +import { + createOptionalChannelSetupAdapter, + createOptionalChannelSetupWizard, +} from "./optional-channel-setup.js"; + export type { ChunkMode } from "../auto-reply/chunk.js"; export type { HistoryEntry } from "../auto-reply/reply/history.js"; export { @@ -41,6 +46,7 @@ export { splitSetupEntries, } from "../channels/plugins/setup-wizard-helpers.js"; export { PAIRING_APPROVED_MESSAGE } from "../channels/plugins/pairing-message.js"; +export { resolveOutboundMediaUrls, resolveSendableOutboundReplyParts } from "./reply-payload.js"; export type { BaseProbeResult, ChannelDirectoryEntry, @@ -117,5 +123,17 @@ export { createDefaultChannelRuntimeState, } from "./status-helpers.js"; export { normalizeStringEntries } from "../shared/string-normalization.js"; -export { msteamsSetupWizard } from "../../extensions/msteams/api.js"; -export { msteamsSetupAdapter } from "../../extensions/msteams/api.js"; + +export const msteamsSetupWizard = createOptionalChannelSetupWizard({ + channel: "msteams", + label: "Microsoft Teams", + npmSpec: "@openclaw/msteams", + docsPath: "/channels/msteams", +}); + +export const msteamsSetupAdapter = createOptionalChannelSetupAdapter({ + channel: "msteams", + label: "Microsoft Teams", + npmSpec: "@openclaw/msteams", + docsPath: "/channels/msteams", +}); diff --git a/src/plugin-sdk/nextcloud-talk.ts b/src/plugin-sdk/nextcloud-talk.ts index 4ce53e1ec15..e3be0cd868d 100644 --- a/src/plugin-sdk/nextcloud-talk.ts +++ b/src/plugin-sdk/nextcloud-talk.ts @@ -94,6 +94,7 @@ export { createPersistentDedupe } from "./persistent-dedupe.js"; export type { OutboundReplyPayload } from "./reply-payload.js"; export { createNormalizedOutboundDeliverer, + deliverFormattedTextWithAttachments, formatTextWithAttachmentLinks, resolveOutboundMediaUrls, } from "./reply-payload.js"; diff --git a/src/plugin-sdk/nostr.ts b/src/plugin-sdk/nostr.ts index 4c8abc0f15a..a3bd64e34fc 100644 --- a/src/plugin-sdk/nostr.ts +++ b/src/plugin-sdk/nostr.ts @@ -1,6 +1,11 @@ // Narrow plugin-sdk surface for the bundled nostr plugin. // Keep this list additive and scoped to symbols used under extensions/nostr. +import { + createOptionalChannelSetupAdapter, + createOptionalChannelSetupWizard, +} from "./optional-channel-setup.js"; + export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js"; export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; @@ -19,4 +24,17 @@ export { } from "./status-helpers.js"; export { createFixedWindowRateLimiter } from "./webhook-memory-guards.js"; export { mapAllowFromEntries } from "./channel-config-helpers.js"; -export { nostrSetupAdapter, nostrSetupWizard } from "../../extensions/nostr/setup-api.js"; + +export const nostrSetupAdapter = createOptionalChannelSetupAdapter({ + channel: "nostr", + label: "Nostr", + npmSpec: "@openclaw/nostr", + docsPath: "/channels/nostr", +}); + +export const nostrSetupWizard = createOptionalChannelSetupWizard({ + channel: "nostr", + label: "Nostr", + npmSpec: "@openclaw/nostr", + docsPath: "/channels/nostr", +}); diff --git a/src/plugin-sdk/optional-channel-setup.ts b/src/plugin-sdk/optional-channel-setup.ts new file mode 100644 index 00000000000..42f62e2efcd --- /dev/null +++ b/src/plugin-sdk/optional-channel-setup.ts @@ -0,0 +1,56 @@ +import type { ChannelSetupWizard } from "../channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js"; +import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; +import { formatDocsLink } from "../terminal/links.js"; + +type OptionalChannelSetupParams = { + channel: string; + label: string; + npmSpec?: string; + docsPath?: string; +}; + +function buildOptionalChannelSetupMessage(params: OptionalChannelSetupParams): string { + const installTarget = params.npmSpec ?? `the ${params.label} plugin`; + const message = [`${params.label} setup requires ${installTarget} to be installed.`]; + if (params.docsPath) { + message.push(`Docs: ${formatDocsLink(params.docsPath, params.docsPath.replace(/^\/+/u, ""))}`); + } + return message.join(" "); +} + +export function createOptionalChannelSetupAdapter( + params: OptionalChannelSetupParams, +): ChannelSetupAdapter { + const message = buildOptionalChannelSetupMessage(params); + return { + resolveAccountId: ({ accountId }) => accountId ?? DEFAULT_ACCOUNT_ID, + applyAccountConfig: () => { + throw new Error(message); + }, + validateInput: () => message, + }; +} + +export function createOptionalChannelSetupWizard( + params: OptionalChannelSetupParams, +): ChannelSetupWizard { + const message = buildOptionalChannelSetupMessage(params); + return { + channel: params.channel, + status: { + configuredLabel: `${params.label} plugin installed`, + unconfiguredLabel: `install ${params.label} plugin`, + configuredHint: message, + unconfiguredHint: message, + unconfiguredScore: 0, + resolveConfigured: () => false, + resolveStatusLines: () => [message], + resolveSelectionHint: () => message, + }, + credentials: [], + finalize: async () => { + throw new Error(message); + }, + }; +} diff --git a/src/plugin-sdk/outbound-media.test.ts b/src/plugin-sdk/outbound-media.test.ts index 6efb42df7fe..b68f382cd3a 100644 --- a/src/plugin-sdk/outbound-media.test.ts +++ b/src/plugin-sdk/outbound-media.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const loadWebMediaMock = vi.hoisted(() => vi.fn()); -vi.mock("../media/web-media.js", () => ({ +vi.mock("./web-media.js", () => ({ loadWebMedia: loadWebMediaMock, })); diff --git a/src/plugin-sdk/provider-models.ts b/src/plugin-sdk/provider-models.ts index b82bc09dc2f..8f6f2565138 100644 --- a/src/plugin-sdk/provider-models.ts +++ b/src/plugin-sdk/provider-models.ts @@ -36,8 +36,10 @@ export { OPENCODE_ZEN_DEFAULT_MODEL } from "../plugins/provider-model-defaults.j export { OPENCODE_ZEN_DEFAULT_MODEL_REF } from "../agents/opencode-zen-models.js"; export { buildMinimaxApiModelDefinition, + buildMinimaxModelDefinition, DEFAULT_MINIMAX_BASE_URL, MINIMAX_API_BASE_URL, + MINIMAX_API_COST, MINIMAX_CN_API_BASE_URL, MINIMAX_HOSTED_COST, MINIMAX_HOSTED_MODEL_ID, @@ -47,6 +49,7 @@ export { export { buildMistralModelDefinition, MISTRAL_BASE_URL, + MISTRAL_DEFAULT_COST, MISTRAL_DEFAULT_MODEL_ID, MISTRAL_DEFAULT_MODEL_REF, } from "../../extensions/mistral/model-definitions.js"; @@ -54,15 +57,29 @@ export { buildModelStudioDefaultModelDefinition, buildModelStudioModelDefinition, MODELSTUDIO_CN_BASE_URL, + MODELSTUDIO_DEFAULT_COST, MODELSTUDIO_DEFAULT_MODEL_ID, MODELSTUDIO_DEFAULT_MODEL_REF, MODELSTUDIO_GLOBAL_BASE_URL, } from "../../extensions/modelstudio/model-definitions.js"; -export { MOONSHOT_BASE_URL } from "../../extensions/moonshot/provider-catalog.js"; +export { + buildMoonshotProvider, + MOONSHOT_BASE_URL, + MOONSHOT_DEFAULT_MODEL_ID, +} from "../../extensions/moonshot/provider-catalog.js"; export { MOONSHOT_CN_BASE_URL } from "../../extensions/moonshot/onboard.js"; +export { + KIMI_CODING_BASE_URL, + KIMI_CODING_DEFAULT_MODEL_ID, +} from "../../extensions/kimi-coding/provider-catalog.js"; +export { + QIANFAN_BASE_URL, + QIANFAN_DEFAULT_MODEL_ID, +} from "../../extensions/qianfan/provider-catalog.js"; export { buildXaiModelDefinition, XAI_BASE_URL, + XAI_DEFAULT_COST, XAI_DEFAULT_MODEL_ID, XAI_DEFAULT_MODEL_REF, } from "../../extensions/xai/model-definitions.js"; @@ -72,6 +89,7 @@ export { ZAI_CODING_CN_BASE_URL, ZAI_CODING_GLOBAL_BASE_URL, ZAI_CN_BASE_URL, + ZAI_DEFAULT_COST, ZAI_DEFAULT_MODEL_ID, ZAI_DEFAULT_MODEL_REF, ZAI_GLOBAL_BASE_URL, diff --git a/src/plugin-sdk/reply-payload.test.ts b/src/plugin-sdk/reply-payload.test.ts index 780b75686a1..ce393a9ecd3 100644 --- a/src/plugin-sdk/reply-payload.test.ts +++ b/src/plugin-sdk/reply-payload.test.ts @@ -1,5 +1,18 @@ -import { describe, expect, it } from "vitest"; -import { isNumericTargetId, sendPayloadWithChunkedTextAndMedia } from "./reply-payload.js"; +import { describe, expect, it, vi } from "vitest"; +import { + countOutboundMedia, + deliverFormattedTextWithAttachments, + deliverTextOrMediaReply, + hasOutboundMedia, + hasOutboundReplyContent, + hasOutboundText, + isNumericTargetId, + resolveOutboundMediaUrls, + resolveSendableOutboundReplyParts, + resolveTextChunksWithFallback, + sendMediaWithLeadingCaption, + sendPayloadWithChunkedTextAndMedia, +} from "./reply-payload.js"; describe("sendPayloadWithChunkedTextAndMedia", () => { it("returns empty result when payload has no text and no media", async () => { @@ -56,3 +69,271 @@ describe("sendPayloadWithChunkedTextAndMedia", () => { expect(isNumericTargetId("")).toBe(false); }); }); + +describe("resolveOutboundMediaUrls", () => { + it("prefers mediaUrls over the legacy single-media field", () => { + expect( + resolveOutboundMediaUrls({ + mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"], + mediaUrl: "https://example.com/legacy.png", + }), + ).toEqual(["https://example.com/a.png", "https://example.com/b.png"]); + }); + + it("falls back to the legacy single-media field", () => { + expect( + resolveOutboundMediaUrls({ + mediaUrl: "https://example.com/legacy.png", + }), + ).toEqual(["https://example.com/legacy.png"]); + }); +}); + +describe("countOutboundMedia", () => { + it("counts normalized media entries", () => { + expect( + countOutboundMedia({ + mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"], + }), + ).toBe(2); + }); + + it("counts legacy single-media payloads", () => { + expect( + countOutboundMedia({ + mediaUrl: "https://example.com/legacy.png", + }), + ).toBe(1); + }); +}); + +describe("hasOutboundMedia", () => { + it("reports whether normalized payloads include media", () => { + expect(hasOutboundMedia({ mediaUrls: ["https://example.com/a.png"] })).toBe(true); + expect(hasOutboundMedia({ mediaUrl: "https://example.com/legacy.png" })).toBe(true); + expect(hasOutboundMedia({})).toBe(false); + }); +}); + +describe("hasOutboundText", () => { + it("checks raw text presence by default", () => { + expect(hasOutboundText({ text: "hello" })).toBe(true); + expect(hasOutboundText({ text: " " })).toBe(true); + expect(hasOutboundText({})).toBe(false); + }); + + it("can trim whitespace-only text", () => { + expect(hasOutboundText({ text: " " }, { trim: true })).toBe(false); + expect(hasOutboundText({ text: " hi " }, { trim: true })).toBe(true); + }); +}); + +describe("hasOutboundReplyContent", () => { + it("detects text or media content", () => { + expect(hasOutboundReplyContent({ text: "hello" })).toBe(true); + expect(hasOutboundReplyContent({ mediaUrl: "https://example.com/a.png" })).toBe(true); + expect(hasOutboundReplyContent({})).toBe(false); + }); + + it("can ignore whitespace-only text unless media exists", () => { + expect(hasOutboundReplyContent({ text: " " }, { trimText: true })).toBe(false); + expect( + hasOutboundReplyContent( + { text: " ", mediaUrls: ["https://example.com/a.png"] }, + { trimText: true }, + ), + ).toBe(true); + }); +}); + +describe("resolveSendableOutboundReplyParts", () => { + it("normalizes missing text and trims media urls", () => { + expect( + resolveSendableOutboundReplyParts({ + mediaUrls: [" https://example.com/a.png ", " "], + }), + ).toEqual({ + text: "", + trimmedText: "", + mediaUrls: ["https://example.com/a.png"], + mediaCount: 1, + hasText: false, + hasMedia: true, + hasContent: true, + }); + }); + + it("accepts transformed text overrides", () => { + expect( + resolveSendableOutboundReplyParts( + { + text: "ignored", + }, + { + text: " hello ", + }, + ), + ).toEqual({ + text: " hello ", + trimmedText: "hello", + mediaUrls: [], + mediaCount: 0, + hasText: true, + hasMedia: false, + hasContent: true, + }); + }); +}); + +describe("resolveTextChunksWithFallback", () => { + it("returns existing chunks unchanged", () => { + expect(resolveTextChunksWithFallback("hello", ["a", "b"])).toEqual(["a", "b"]); + }); + + it("falls back to the full text when chunkers return nothing", () => { + expect(resolveTextChunksWithFallback("hello", [])).toEqual(["hello"]); + }); + + it("returns empty for empty text with no chunks", () => { + expect(resolveTextChunksWithFallback("", [])).toEqual([]); + }); +}); + +describe("deliverTextOrMediaReply", () => { + it("sends media first with caption only on the first attachment", async () => { + const sendMedia = vi.fn(async () => undefined); + const sendText = vi.fn(async () => undefined); + + await expect( + deliverTextOrMediaReply({ + payload: { text: "hello", mediaUrls: ["https://a", "https://b"] }, + text: "hello", + sendText, + sendMedia, + }), + ).resolves.toBe("media"); + + expect(sendMedia).toHaveBeenNthCalledWith(1, { + mediaUrl: "https://a", + caption: "hello", + }); + expect(sendMedia).toHaveBeenNthCalledWith(2, { + mediaUrl: "https://b", + caption: undefined, + }); + expect(sendText).not.toHaveBeenCalled(); + }); + + it("falls back to chunked text delivery when there is no media", async () => { + const sendMedia = vi.fn(async () => undefined); + const sendText = vi.fn(async () => undefined); + + await expect( + deliverTextOrMediaReply({ + payload: { text: "alpha beta gamma" }, + text: "alpha beta gamma", + chunkText: () => ["alpha", "beta", "gamma"], + sendText, + sendMedia, + }), + ).resolves.toBe("text"); + + expect(sendText).toHaveBeenCalledTimes(3); + expect(sendText).toHaveBeenNthCalledWith(1, "alpha"); + expect(sendText).toHaveBeenNthCalledWith(2, "beta"); + expect(sendText).toHaveBeenNthCalledWith(3, "gamma"); + expect(sendMedia).not.toHaveBeenCalled(); + }); + + it("returns empty when chunking produces no sendable text", async () => { + const sendMedia = vi.fn(async () => undefined); + const sendText = vi.fn(async () => undefined); + + await expect( + deliverTextOrMediaReply({ + payload: { text: " " }, + text: " ", + chunkText: () => [], + sendText, + sendMedia, + }), + ).resolves.toBe("empty"); + + expect(sendText).not.toHaveBeenCalled(); + expect(sendMedia).not.toHaveBeenCalled(); + }); + + it("ignores blank media urls before sending", async () => { + const sendMedia = vi.fn(async () => undefined); + const sendText = vi.fn(async () => undefined); + + await expect( + deliverTextOrMediaReply({ + payload: { text: "hello", mediaUrls: [" ", " https://a "] }, + text: "hello", + sendText, + sendMedia, + }), + ).resolves.toBe("media"); + + expect(sendMedia).toHaveBeenCalledTimes(1); + expect(sendMedia).toHaveBeenCalledWith({ + mediaUrl: "https://a", + caption: "hello", + }); + }); +}); + +describe("sendMediaWithLeadingCaption", () => { + it("passes leading-caption metadata to async error handlers", async () => { + const send = vi + .fn<({ mediaUrl, caption }: { mediaUrl: string; caption?: string }) => Promise>() + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValueOnce(undefined); + const onError = vi.fn(async () => undefined); + + await expect( + sendMediaWithLeadingCaption({ + mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"], + caption: "hello", + send, + onError, + }), + ).resolves.toBe(true); + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + mediaUrl: "https://example.com/a.png", + caption: "hello", + index: 0, + isFirst: true, + }), + ); + expect(send).toHaveBeenNthCalledWith(2, { + mediaUrl: "https://example.com/b.png", + caption: undefined, + }); + }); +}); + +describe("deliverFormattedTextWithAttachments", () => { + it("combines attachment links and forwards replyToId", async () => { + const send = vi.fn(async () => undefined); + + await expect( + deliverFormattedTextWithAttachments({ + payload: { + text: "hello", + mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"], + replyToId: "r1", + }, + send, + }), + ).resolves.toBe(true); + + expect(send).toHaveBeenCalledWith({ + text: "hello\n\nAttachment: https://example.com/a.png\nAttachment: https://example.com/b.png", + replyToId: "r1", + }); + }); +}); diff --git a/src/plugin-sdk/reply-payload.ts b/src/plugin-sdk/reply-payload.ts index a35380f5250..52cc878c83d 100644 --- a/src/plugin-sdk/reply-payload.ts +++ b/src/plugin-sdk/reply-payload.ts @@ -5,6 +5,16 @@ export type OutboundReplyPayload = { replyToId?: string; }; +export type SendableOutboundReplyParts = { + text: string; + trimmedText: string; + mediaUrls: string[]; + mediaCount: number; + hasText: boolean; + hasMedia: boolean; + hasContent: boolean; +}; + /** Extract the supported outbound reply fields from loose tool or agent payload objects. */ export function normalizeOutboundReplyPayload( payload: Record, @@ -52,6 +62,65 @@ export function resolveOutboundMediaUrls(payload: { return []; } +/** Count outbound media items after legacy single-media fallback normalization. */ +export function countOutboundMedia(payload: { mediaUrls?: string[]; mediaUrl?: string }): number { + return resolveOutboundMediaUrls(payload).length; +} + +/** Check whether an outbound payload includes any media after normalization. */ +export function hasOutboundMedia(payload: { mediaUrls?: string[]; mediaUrl?: string }): boolean { + return countOutboundMedia(payload) > 0; +} + +/** Check whether an outbound payload includes text, optionally trimming whitespace first. */ +export function hasOutboundText(payload: { text?: string }, options?: { trim?: boolean }): boolean { + const text = options?.trim ? payload.text?.trim() : payload.text; + return Boolean(text); +} + +/** Check whether an outbound payload includes any sendable text or media. */ +export function hasOutboundReplyContent( + payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }, + options?: { trimText?: boolean }, +): boolean { + return hasOutboundText(payload, { trim: options?.trimText }) || hasOutboundMedia(payload); +} + +/** Normalize reply payload text/media into a trimmed, sendable shape for delivery paths. */ +export function resolveSendableOutboundReplyParts( + payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }, + options?: { text?: string }, +): SendableOutboundReplyParts { + const text = options?.text ?? payload.text ?? ""; + const trimmedText = text.trim(); + const mediaUrls = resolveOutboundMediaUrls(payload) + .map((entry) => entry.trim()) + .filter(Boolean); + const mediaCount = mediaUrls.length; + const hasText = Boolean(trimmedText); + const hasMedia = mediaCount > 0; + return { + text, + trimmedText, + mediaUrls, + mediaCount, + hasText, + hasMedia, + hasContent: hasText || hasMedia, + }; +} + +/** Preserve caller-provided chunking, but fall back to the full text when chunkers return nothing. */ +export function resolveTextChunksWithFallback(text: string, chunks: readonly string[]): string[] { + if (chunks.length > 0) { + return [...chunks]; + } + if (!text) { + return []; + } + return [text]; +} + /** Send media-first payloads intact, or chunk text-only payloads through the caller's transport hooks. */ export async function sendPayloadWithChunkedTextAndMedia< TContext extends { payload: object }, @@ -129,21 +198,32 @@ export async function sendMediaWithLeadingCaption(params: { mediaUrls: string[]; caption: string; send: (payload: { mediaUrl: string; caption?: string }) => Promise; - onError?: (error: unknown, mediaUrl: string) => void; + onError?: (params: { + error: unknown; + mediaUrl: string; + caption?: string; + index: number; + isFirst: boolean; + }) => Promise | void; }): Promise { if (params.mediaUrls.length === 0) { return false; } - let first = true; - for (const mediaUrl of params.mediaUrls) { - const caption = first ? params.caption : undefined; - first = false; + for (const [index, mediaUrl] of params.mediaUrls.entries()) { + const isFirst = index === 0; + const caption = isFirst ? params.caption : undefined; try { await params.send({ mediaUrl, caption }); } catch (error) { if (params.onError) { - params.onError(error, mediaUrl); + await params.onError({ + error, + mediaUrl, + caption, + index, + isFirst, + }); continue; } throw error; @@ -151,3 +231,62 @@ export async function sendMediaWithLeadingCaption(params: { } return true; } + +export async function deliverTextOrMediaReply(params: { + payload: OutboundReplyPayload; + text: string; + chunkText?: (text: string) => readonly string[]; + sendText: (text: string) => Promise; + sendMedia: (payload: { mediaUrl: string; caption?: string }) => Promise; + onMediaError?: (params: { + error: unknown; + mediaUrl: string; + caption?: string; + index: number; + isFirst: boolean; + }) => Promise | void; +}): Promise<"empty" | "text" | "media"> { + const { mediaUrls } = resolveSendableOutboundReplyParts(params.payload, { + text: params.text, + }); + const sentMedia = await sendMediaWithLeadingCaption({ + mediaUrls, + caption: params.text, + send: params.sendMedia, + onError: params.onMediaError, + }); + if (sentMedia) { + return "media"; + } + if (!params.text) { + return "empty"; + } + const chunks = params.chunkText ? params.chunkText(params.text) : [params.text]; + let sentText = false; + for (const chunk of chunks) { + if (!chunk) { + continue; + } + await params.sendText(chunk); + sentText = true; + } + return sentText ? "text" : "empty"; +} + +export async function deliverFormattedTextWithAttachments(params: { + payload: OutboundReplyPayload; + send: (params: { text: string; replyToId?: string }) => Promise; +}): Promise { + const text = formatTextWithAttachmentLinks( + params.payload.text, + resolveOutboundMediaUrls(params.payload), + ); + if (!text) { + return false; + } + await params.send({ + text, + replyToId: params.payload.replyToId, + }); + return true; +} diff --git a/src/plugin-sdk/root-alias.cjs b/src/plugin-sdk/root-alias.cjs index 0013b32d21f..d9d742c3070 100644 --- a/src/plugin-sdk/root-alias.cjs +++ b/src/plugin-sdk/root-alias.cjs @@ -4,7 +4,7 @@ const path = require("node:path"); const fs = require("node:fs"); let monolithicSdk = null; -let jitiLoader = null; +const jitiLoaders = new Map(); function emptyPluginConfigSchema() { function error(message) { @@ -61,19 +61,20 @@ function resolveControlCommandGate(params) { return { commandAuthorized, shouldBlock }; } -function getJiti() { - if (jitiLoader) { - return jitiLoader; +function getJiti(tryNative) { + if (jitiLoaders.has(tryNative)) { + return jitiLoaders.get(tryNative); } const { createJiti } = require("jiti"); - jitiLoader = createJiti(__filename, { + const jitiLoader = createJiti(__filename, { interopDefault: true, // Prefer Node's native sync ESM loader for built dist/plugin-sdk/*.js files // so local plugins do not create a second transpiled OpenClaw core graph. - tryNative: true, + tryNative, extensions: [".ts", ".tsx", ".mts", ".cts", ".mtsx", ".ctsx", ".js", ".mjs", ".cjs", ".json"], }); + jitiLoaders.set(tryNative, jitiLoader); return jitiLoader; } @@ -82,19 +83,17 @@ function loadMonolithicSdk() { return monolithicSdk; } - const jiti = getJiti(); - const distCandidate = path.resolve(__dirname, "..", "..", "dist", "plugin-sdk", "compat.js"); if (fs.existsSync(distCandidate)) { try { - monolithicSdk = jiti(distCandidate); + monolithicSdk = getJiti(true)(distCandidate); return monolithicSdk; } catch { // Fall through to source alias if dist is unavailable or stale. } } - monolithicSdk = jiti(path.join(__dirname, "compat.ts")); + monolithicSdk = getJiti(false)(path.join(__dirname, "compat.ts")); return monolithicSdk; } diff --git a/src/plugin-sdk/root-alias.test.ts b/src/plugin-sdk/root-alias.test.ts index 6767ca773e3..95565cab89a 100644 --- a/src/plugin-sdk/root-alias.test.ts +++ b/src/plugin-sdk/root-alias.test.ts @@ -25,7 +25,7 @@ function loadRootAliasWithStubs(options?: { }) { let createJitiCalls = 0; let jitiLoadCalls = 0; - let lastJitiOptions: Record | undefined; + const createJitiOptions: Record[] = []; const loadedSpecifiers: string[] = []; const monolithicExports = options?.monolithicExports ?? { slowHelper: () => "loaded", @@ -55,7 +55,7 @@ function loadRootAliasWithStubs(options?: { return { createJiti(_filename: string, jitiOptions?: Record) { createJitiCalls += 1; - lastJitiOptions = jitiOptions; + createJitiOptions.push(jitiOptions ?? {}); return (specifier: string) => { jitiLoadCalls += 1; loadedSpecifiers.push(specifier); @@ -75,8 +75,8 @@ function loadRootAliasWithStubs(options?: { get jitiLoadCalls() { return jitiLoadCalls; }, - get lastJitiOptions() { - return lastJitiOptions; + get createJitiOptions() { + return createJitiOptions; }, loadedSpecifiers, }; @@ -121,12 +121,24 @@ describe("plugin-sdk root alias", () => { expect("slowHelper" in lazyRootSdk).toBe(true); expect(lazyModule.createJitiCalls).toBe(1); expect(lazyModule.jitiLoadCalls).toBe(1); - expect(lazyModule.lastJitiOptions?.tryNative).toBe(true); + expect(lazyModule.createJitiOptions.at(-1)?.tryNative).toBe(false); expect((lazyRootSdk.slowHelper as () => string)()).toBe("loaded"); expect(Object.keys(lazyRootSdk)).toContain("slowHelper"); expect(Object.getOwnPropertyDescriptor(lazyRootSdk, "slowHelper")).toBeDefined(); }); + it("prefers native loading when compat resolves to dist", () => { + const lazyModule = loadRootAliasWithStubs({ + distExists: true, + monolithicExports: { + slowHelper: () => "loaded", + }, + }); + + expect((lazyModule.moduleExports.slowHelper as () => string)()).toBe("loaded"); + expect(lazyModule.createJitiOptions.at(-1)?.tryNative).toBe(true); + }); + it("forwards delegateCompactionToRuntime through the compat-backed root alias", () => { const delegateCompactionToRuntime = () => "delegated"; const lazyModule = loadRootAliasWithStubs({ diff --git a/src/plugin-sdk/slack.ts b/src/plugin-sdk/slack.ts index 0b1159cbb22..bef98db2bfc 100644 --- a/src/plugin-sdk/slack.ts +++ b/src/plugin-sdk/slack.ts @@ -60,7 +60,16 @@ export { extractSlackToolSend, listSlackMessageActions } from "../../extensions/ export { buildSlackThreadingToolContext } from "../../extensions/slack/api.js"; export { parseSlackBlocksInput } from "../../extensions/slack/api.js"; export { handleSlackHttpRequest } from "../../extensions/slack/api.js"; -export { sendMessageSlack } from "../../extensions/slack/runtime-api.js"; +export { + handleSlackAction, + listSlackDirectoryGroupsLive, + listSlackDirectoryPeersLive, + monitorSlackProvider, + probeSlack, + resolveSlackChannelAllowlist, + resolveSlackUserAllowlist, + sendMessageSlack, +} from "../../extensions/slack/runtime-api.js"; export { deleteSlackMessage, downloadSlackFile, diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 0e5da56d274..ec0f4cb8d79 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -1,3 +1,5 @@ +import * as channelRuntimeSdk from "openclaw/plugin-sdk/channel-runtime"; +import * as channelSendResultSdk from "openclaw/plugin-sdk/channel-send-result"; import * as compatSdk from "openclaw/plugin-sdk/compat"; import * as coreSdk from "openclaw/plugin-sdk/core"; import type { @@ -5,6 +7,7 @@ import type { OpenClawPluginApi as CoreOpenClawPluginApi, PluginRuntime as CorePluginRuntime, } from "openclaw/plugin-sdk/core"; +import * as directoryRuntimeSdk from "openclaw/plugin-sdk/directory-runtime"; import * as discordSdk from "openclaw/plugin-sdk/discord"; import * as imessageSdk from "openclaw/plugin-sdk/imessage"; import * as lazyRuntimeSdk from "openclaw/plugin-sdk/lazy-runtime"; @@ -13,7 +16,9 @@ import * as lineCoreSdk from "openclaw/plugin-sdk/line-core"; import * as msteamsSdk from "openclaw/plugin-sdk/msteams"; import * as nostrSdk from "openclaw/plugin-sdk/nostr"; import * as ollamaSetupSdk from "openclaw/plugin-sdk/ollama-setup"; +import * as providerModelsSdk from "openclaw/plugin-sdk/provider-models"; import * as providerSetupSdk from "openclaw/plugin-sdk/provider-setup"; +import * as replyPayloadSdk from "openclaw/plugin-sdk/reply-payload"; import * as routingSdk from "openclaw/plugin-sdk/routing"; import * as runtimeSdk from "openclaw/plugin-sdk/runtime"; import * as sandboxSdk from "openclaw/plugin-sdk/sandbox"; @@ -25,6 +30,8 @@ import * as telegramSdk from "openclaw/plugin-sdk/telegram"; import * as testingSdk from "openclaw/plugin-sdk/testing"; import * as voiceCallSdk from "openclaw/plugin-sdk/voice-call"; import * as whatsappSdk from "openclaw/plugin-sdk/whatsapp"; +import * as whatsappActionRuntimeSdk from "openclaw/plugin-sdk/whatsapp-action-runtime"; +import * as whatsappLoginQrSdk from "openclaw/plugin-sdk/whatsapp-login-qr"; import { describe, expect, expectTypeOf, it } from "vitest"; import type { ChannelMessageActionContext } from "../channels/plugins/types.js"; import type { PluginRuntime } from "../plugins/runtime/types.js"; @@ -58,6 +65,7 @@ const mattermostSdk = await import("openclaw/plugin-sdk/mattermost"); const nextcloudTalkSdk = await import("openclaw/plugin-sdk/nextcloud-talk"); const twitchSdk = await import("openclaw/plugin-sdk/twitch"); const accountHelpersSdk = await import("openclaw/plugin-sdk/account-helpers"); +const allowlistEditSdk = await import("openclaw/plugin-sdk/allowlist-config-edit"); const lobsterSdk = await import("openclaw/plugin-sdk/lobster"); describe("plugin-sdk subpath exports", () => { @@ -90,14 +98,79 @@ describe("plugin-sdk subpath exports", () => { expect(typeof routingSdk.resolveThreadSessionKeys).toBe("function"); }); + it("exports reply payload helpers from the dedicated subpath", () => { + expect(typeof replyPayloadSdk.countOutboundMedia).toBe("function"); + expect(typeof replyPayloadSdk.deliverFormattedTextWithAttachments).toBe("function"); + expect(typeof replyPayloadSdk.deliverTextOrMediaReply).toBe("function"); + expect(typeof replyPayloadSdk.formatTextWithAttachmentLinks).toBe("function"); + expect(typeof replyPayloadSdk.hasOutboundMedia).toBe("function"); + expect(typeof replyPayloadSdk.hasOutboundReplyContent).toBe("function"); + expect(typeof replyPayloadSdk.hasOutboundText).toBe("function"); + expect(typeof replyPayloadSdk.resolveOutboundMediaUrls).toBe("function"); + expect(typeof replyPayloadSdk.resolveTextChunksWithFallback).toBe("function"); + expect(typeof replyPayloadSdk.sendMediaWithLeadingCaption).toBe("function"); + expect(typeof replyPayloadSdk.sendPayloadWithChunkedTextAndMedia).toBe("function"); + }); + it("exports account helper builders from the dedicated subpath", () => { expect(typeof accountHelpersSdk.createAccountListHelpers).toBe("function"); }); + it("exports allowlist edit helpers from the dedicated subpath", () => { + expect(typeof allowlistEditSdk.buildDmGroupAccountAllowlistAdapter).toBe("function"); + expect(typeof allowlistEditSdk.buildLegacyDmAccountAllowlistAdapter).toBe("function"); + expect(typeof allowlistEditSdk.createAccountScopedAllowlistNameResolver).toBe("function"); + expect(typeof allowlistEditSdk.createFlatAllowlistOverrideResolver).toBe("function"); + expect(typeof allowlistEditSdk.createNestedAllowlistOverrideResolver).toBe("function"); + }); + it("exports runtime helpers from the dedicated subpath", () => { expect(typeof runtimeSdk.createLoggerBackedRuntime).toBe("function"); }); + it("exports directory runtime helpers from the dedicated subpath", () => { + expect(typeof directoryRuntimeSdk.listDirectoryEntriesFromSources).toBe("function"); + expect(typeof directoryRuntimeSdk.listInspectedDirectoryEntriesFromSources).toBe("function"); + expect(typeof directoryRuntimeSdk.listResolvedDirectoryEntriesFromSources).toBe("function"); + expect(typeof directoryRuntimeSdk.listResolvedDirectoryGroupEntriesFromMapKeys).toBe( + "function", + ); + expect(typeof directoryRuntimeSdk.listResolvedDirectoryUserEntriesFromAllowFrom).toBe( + "function", + ); + }); + + it("exports channel runtime helpers from the dedicated subpath", () => { + expect(typeof channelRuntimeSdk.attachChannelToResult).toBe("function"); + expect(typeof channelRuntimeSdk.attachChannelToResults).toBe("function"); + expect(typeof channelRuntimeSdk.buildUnresolvedTargetResults).toBe("function"); + expect(typeof channelRuntimeSdk.createAttachedChannelResultAdapter).toBe("function"); + expect(typeof channelRuntimeSdk.createChannelDirectoryAdapter).toBe("function"); + expect(typeof channelRuntimeSdk.createEmptyChannelResult).toBe("function"); + expect(typeof channelRuntimeSdk.createEmptyChannelDirectoryAdapter).toBe("function"); + expect(typeof channelRuntimeSdk.createRawChannelSendResultAdapter).toBe("function"); + expect(typeof channelRuntimeSdk.createLoggedPairingApprovalNotifier).toBe("function"); + expect(typeof channelRuntimeSdk.createPairingPrefixStripper).toBe("function"); + expect(typeof channelRuntimeSdk.createScopedAccountReplyToModeResolver).toBe("function"); + expect(typeof channelRuntimeSdk.createStaticReplyToModeResolver).toBe("function"); + expect(typeof channelRuntimeSdk.createTopLevelChannelReplyToModeResolver).toBe("function"); + expect(typeof channelRuntimeSdk.createRuntimeDirectoryLiveAdapter).toBe("function"); + expect(typeof channelRuntimeSdk.createRuntimeOutboundDelegates).toBe("function"); + expect(typeof channelRuntimeSdk.sendPayloadMediaSequenceAndFinalize).toBe("function"); + expect(typeof channelRuntimeSdk.sendPayloadMediaSequenceOrFallback).toBe("function"); + expect(typeof channelRuntimeSdk.resolveTargetsWithOptionalToken).toBe("function"); + expect(typeof channelRuntimeSdk.createTextPairingAdapter).toBe("function"); + }); + + it("exports channel send-result helpers from the dedicated subpath", () => { + expect(typeof channelSendResultSdk.attachChannelToResult).toBe("function"); + expect(typeof channelSendResultSdk.attachChannelToResults).toBe("function"); + expect(typeof channelSendResultSdk.buildChannelSendResult).toBe("function"); + expect(typeof channelSendResultSdk.createAttachedChannelResultAdapter).toBe("function"); + expect(typeof channelSendResultSdk.createEmptyChannelResult).toBe("function"); + expect(typeof channelSendResultSdk.createRawChannelSendResultAdapter).toBe("function"); + }); + it("exports provider setup helpers from the dedicated subpath", () => { expect(typeof providerSetupSdk.buildVllmProvider).toBe("function"); expect(typeof providerSetupSdk.discoverOpenAICompatibleSelfHostedProvider).toBe("function"); @@ -106,6 +179,14 @@ describe("plugin-sdk subpath exports", () => { ); }); + it("exports provider model helpers from the dedicated subpath", () => { + expect(typeof providerModelsSdk.buildMinimaxApiModelDefinition).toBe("function"); + expect(typeof providerModelsSdk.buildMinimaxModelDefinition).toBe("function"); + expect(typeof providerModelsSdk.buildMoonshotProvider).toBe("function"); + expect(typeof providerModelsSdk.resolveZaiBaseUrl).toBe("function"); + expect(providerModelsSdk.QIANFAN_BASE_URL).toBe("https://qianfan.baidubce.com/v2"); + }); + it("exports shared setup helpers from the dedicated subpath", () => { expect(typeof setupSdk.DEFAULT_ACCOUNT_ID).toBe("string"); expect(typeof setupSdk.createAccountScopedAllowFromSection).toBe("function"); @@ -231,6 +312,15 @@ describe("plugin-sdk subpath exports", () => { expect("resolveWhatsAppMentionStripPatterns" in whatsappSdk).toBe(false); }); + it("exports WhatsApp QR login helpers from the dedicated subpath", () => { + expect(typeof whatsappLoginQrSdk.startWebLoginWithQr).toBe("function"); + expect(typeof whatsappLoginQrSdk.waitForWebLogin).toBe("function"); + }); + + it("exports WhatsApp action runtime helpers from the dedicated subpath", () => { + expect(typeof whatsappActionRuntimeSdk.handleWhatsAppAction).toBe("function"); + }); + it("exports Feishu helpers", async () => { expect(typeof feishuSdk.feishuSetupWizard).toBe("object"); expect(typeof feishuSdk.feishuSetupAdapter).toBe("object"); diff --git a/src/plugin-sdk/telegram.ts b/src/plugin-sdk/telegram.ts index 47bed87544f..fa06fded55d 100644 --- a/src/plugin-sdk/telegram.ts +++ b/src/plugin-sdk/telegram.ts @@ -86,18 +86,31 @@ export { } from "../../extensions/telegram/api.js"; export { resolveTelegramReactionLevel } from "../../extensions/telegram/api.js"; export { + auditTelegramGroupMembership, + collectTelegramUnmentionedGroupIds, createForumTopicTelegram, deleteMessageTelegram, editForumTopicTelegram, + editMessageReplyMarkupTelegram, editMessageTelegram, + monitorTelegramProvider, + pinMessageTelegram, reactMessageTelegram, + renameForumTopicTelegram, + probeTelegram, sendMessageTelegram, sendPollTelegram, sendStickerTelegram, + sendTypingTelegram, + unpinMessageTelegram, } from "../../extensions/telegram/runtime-api.js"; export { getCacheStats, searchStickers } from "../../extensions/telegram/api.js"; export { resolveTelegramToken } from "../../extensions/telegram/runtime-api.js"; export { telegramMessageActions } from "../../extensions/telegram/runtime-api.js"; +export { + setTelegramThreadBindingIdleTimeoutBySessionKey, + setTelegramThreadBindingMaxAgeBySessionKey, +} from "../../extensions/telegram/runtime-api.js"; export { collectTelegramStatusIssues } from "../../extensions/telegram/api.js"; export { sendTelegramPayloadMessages } from "../../extensions/telegram/api.js"; export { diff --git a/src/plugin-sdk/tlon.ts b/src/plugin-sdk/tlon.ts index 1bcd9078292..cd11ca66545 100644 --- a/src/plugin-sdk/tlon.ts +++ b/src/plugin-sdk/tlon.ts @@ -1,6 +1,11 @@ // Narrow plugin-sdk surface for the bundled tlon plugin. // Keep this list additive and scoped to symbols used under extensions/tlon. +import { + createOptionalChannelSetupAdapter, + createOptionalChannelSetupWizard, +} from "./optional-channel-setup.js"; + export type { ReplyPayload } from "../auto-reply/types.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export { @@ -27,4 +32,17 @@ export type { RuntimeEnv } from "../runtime.js"; export { formatDocsLink } from "../terminal/links.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export { createLoggerBackedRuntime } from "./runtime.js"; -export { tlonSetupAdapter, tlonSetupWizard } from "../../extensions/tlon/setup-api.js"; + +export const tlonSetupAdapter = createOptionalChannelSetupAdapter({ + channel: "tlon", + label: "Tlon", + npmSpec: "@openclaw/tlon", + docsPath: "/channels/tlon", +}); + +export const tlonSetupWizard = createOptionalChannelSetupWizard({ + channel: "tlon", + label: "Tlon", + npmSpec: "@openclaw/tlon", + docsPath: "/channels/tlon", +}); diff --git a/src/plugin-sdk/twitch.ts b/src/plugin-sdk/twitch.ts index 907cdd171fa..77bba58209e 100644 --- a/src/plugin-sdk/twitch.ts +++ b/src/plugin-sdk/twitch.ts @@ -1,6 +1,11 @@ // Narrow plugin-sdk surface for the bundled twitch plugin. // Keep this list additive and scoped to symbols used under extensions/twitch. +import { + createOptionalChannelSetupAdapter, + createOptionalChannelSetupWizard, +} from "./optional-channel-setup.js"; + export type { ReplyPayload } from "../auto-reply/types.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export type { @@ -33,7 +38,15 @@ export type { OpenClawPluginApi } from "../plugins/types.js"; export type { RuntimeEnv } from "../runtime.js"; export { formatDocsLink } from "../terminal/links.js"; export type { WizardPrompter } from "../wizard/prompts.js"; -export { - twitchSetupAdapter, - twitchSetupWizard, -} from "../../extensions/twitch/src/setup-surface.js"; + +export const twitchSetupAdapter = createOptionalChannelSetupAdapter({ + channel: "twitch", + label: "Twitch", + npmSpec: "@openclaw/twitch", +}); + +export const twitchSetupWizard = createOptionalChannelSetupWizard({ + channel: "twitch", + label: "Twitch", + npmSpec: "@openclaw/twitch", +}); diff --git a/src/plugin-sdk/whatsapp-action-runtime.ts b/src/plugin-sdk/whatsapp-action-runtime.ts new file mode 100644 index 00000000000..87e7a29e437 --- /dev/null +++ b/src/plugin-sdk/whatsapp-action-runtime.ts @@ -0,0 +1 @@ +export { handleWhatsAppAction } from "../../extensions/whatsapp/action-runtime-api.js"; diff --git a/src/plugin-sdk/whatsapp-login-qr.ts b/src/plugin-sdk/whatsapp-login-qr.ts new file mode 100644 index 00000000000..bde71742811 --- /dev/null +++ b/src/plugin-sdk/whatsapp-login-qr.ts @@ -0,0 +1 @@ +export { startWebLoginWithQr, waitForWebLogin } from "../../extensions/whatsapp/login-qr-api.js"; diff --git a/src/plugin-sdk/whatsapp.ts b/src/plugin-sdk/whatsapp.ts index 3e16da46d80..d5182f9004c 100644 --- a/src/plugin-sdk/whatsapp.ts +++ b/src/plugin-sdk/whatsapp.ts @@ -71,10 +71,13 @@ export { resolveWhatsAppAccount, } from "../../extensions/whatsapp/api.js"; export { + getActiveWebListener, + getWebAuthAgeMs, WA_WEB_AUTH_DIR, logWebSelfId, logoutWeb, pickWebChannel, + readWebSelfId, webAuthExists, } from "../../extensions/whatsapp/runtime-api.js"; export { diff --git a/src/plugin-sdk/zalo.ts b/src/plugin-sdk/zalo.ts index 2655e26e18f..21a5dd09b89 100644 --- a/src/plugin-sdk/zalo.ts +++ b/src/plugin-sdk/zalo.ts @@ -77,6 +77,7 @@ export { issuePairingChallenge } from "../pairing/pairing-challenge.js"; export { buildChannelSendResult } from "./channel-send-result.js"; export type { OutboundReplyPayload } from "./reply-payload.js"; export { + deliverTextOrMediaReply, isNumericTargetId, resolveOutboundMediaUrls, sendMediaWithLeadingCaption, diff --git a/src/plugin-sdk/zalouser.ts b/src/plugin-sdk/zalouser.ts index ed66e31754e..e7fb506f227 100644 --- a/src/plugin-sdk/zalouser.ts +++ b/src/plugin-sdk/zalouser.ts @@ -1,6 +1,11 @@ // Narrow plugin-sdk surface for the bundled zalouser plugin. // Keep this list additive and scoped to symbols used under extensions/zalouser. +import { + createOptionalChannelSetupAdapter, + createOptionalChannelSetupWizard, +} from "./optional-channel-setup.js"; + export type { ReplyPayload } from "../auto-reply/types.js"; export { mergeAllowlist, summarizeMapping } from "../channels/allowlists/resolve-utils.js"; export { resolveMentionGatingWithBypass } from "../channels/mention-gating.js"; @@ -53,8 +58,6 @@ export type { WizardPrompter } from "../wizard/prompts.js"; export { formatAllowFromLowercase } from "./allow-from.js"; export { resolveSenderCommandAuthorization } from "./command-auth.js"; export { resolveChannelAccountConfigBasePath } from "./config-paths.js"; -export { zalouserSetupAdapter } from "../../extensions/zalouser/api.js"; -export { zalouserSetupWizard } from "../../extensions/zalouser/api.js"; export { evaluateGroupRouteAccessForPolicy, resolveSenderScopedGroupPolicy, @@ -65,11 +68,27 @@ export { issuePairingChallenge } from "../pairing/pairing-challenge.js"; export { buildChannelSendResult } from "./channel-send-result.js"; export type { OutboundReplyPayload } from "./reply-payload.js"; export { + deliverTextOrMediaReply, isNumericTargetId, resolveOutboundMediaUrls, + resolveSendableOutboundReplyParts, sendMediaWithLeadingCaption, sendPayloadWithChunkedTextAndMedia, } from "./reply-payload.js"; export { formatResolvedUnresolvedNote } from "./resolution-notes.js"; export { buildBaseAccountStatusSnapshot } from "./status-helpers.js"; export { chunkTextForOutbound } from "./text-chunking.js"; + +export const zalouserSetupAdapter = createOptionalChannelSetupAdapter({ + channel: "zalouser", + label: "Zalo Personal", + npmSpec: "@openclaw/zalouser", + docsPath: "/channels/zalouser", +}); + +export const zalouserSetupWizard = createOptionalChannelSetupWizard({ + channel: "zalouser", + label: "Zalo Personal", + npmSpec: "@openclaw/zalouser", + docsPath: "/channels/zalouser", +}); diff --git a/src/plugins/bundled-provider-auth-env-vars.generated.ts b/src/plugins/bundled-provider-auth-env-vars.generated.ts new file mode 100644 index 00000000000..416036b28ea --- /dev/null +++ b/src/plugins/bundled-provider-auth-env-vars.generated.ts @@ -0,0 +1,38 @@ +// Auto-generated by scripts/generate-bundled-provider-auth-env-vars.mjs. Do not edit directly. + +export const BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES = { + anthropic: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"], + byteplus: ["BYTEPLUS_API_KEY"], + chutes: ["CHUTES_API_KEY", "CHUTES_OAUTH_TOKEN"], + "cloudflare-ai-gateway": ["CLOUDFLARE_AI_GATEWAY_API_KEY"], + fal: ["FAL_KEY"], + "github-copilot": ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"], + google: ["GEMINI_API_KEY", "GOOGLE_API_KEY"], + huggingface: ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"], + kilocode: ["KILOCODE_API_KEY"], + kimi: ["KIMI_API_KEY", "KIMICODE_API_KEY"], + "kimi-coding": ["KIMI_API_KEY", "KIMICODE_API_KEY"], + minimax: ["MINIMAX_API_KEY"], + "minimax-portal": ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"], + mistral: ["MISTRAL_API_KEY"], + modelstudio: ["MODELSTUDIO_API_KEY"], + moonshot: ["MOONSHOT_API_KEY"], + nvidia: ["NVIDIA_API_KEY"], + ollama: ["OLLAMA_API_KEY"], + openai: ["OPENAI_API_KEY"], + opencode: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"], + "opencode-go": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"], + openrouter: ["OPENROUTER_API_KEY"], + qianfan: ["QIANFAN_API_KEY"], + "qwen-portal": ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"], + sglang: ["SGLANG_API_KEY"], + synthetic: ["SYNTHETIC_API_KEY"], + together: ["TOGETHER_API_KEY"], + venice: ["VENICE_API_KEY"], + "vercel-ai-gateway": ["AI_GATEWAY_API_KEY"], + vllm: ["VLLM_API_KEY"], + volcengine: ["VOLCANO_ENGINE_API_KEY"], + xai: ["XAI_API_KEY"], + xiaomi: ["XIAOMI_API_KEY"], + zai: ["ZAI_API_KEY", "Z_AI_API_KEY"], +} as const satisfies Record; diff --git a/src/plugins/bundled-provider-auth-env-vars.test.ts b/src/plugins/bundled-provider-auth-env-vars.test.ts index 81523392e7a..a41b60d7b6d 100644 --- a/src/plugins/bundled-provider-auth-env-vars.test.ts +++ b/src/plugins/bundled-provider-auth-env-vars.test.ts @@ -1,7 +1,35 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { describe, expect, it } from "vitest"; +import { afterEach } from "vitest"; +import { + collectBundledProviderAuthEnvVars, + writeBundledProviderAuthEnvVarModule, +} from "../../scripts/generate-bundled-provider-auth-env-vars.mjs"; import { BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES } from "./bundled-provider-auth-env-vars.js"; +const repoRoot = path.resolve(import.meta.dirname, "../.."); +const tempDirs: string[] = []; + +function writeJson(filePath: string, value: unknown): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +afterEach(() => { + for (const dir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + describe("bundled provider auth env vars", () => { + it("matches the generated manifest snapshot", () => { + expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES).toEqual( + collectBundledProviderAuthEnvVars({ repoRoot }), + ); + }); + it("reads bundled provider auth env vars from plugin manifests", () => { expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES["github-copilot"]).toEqual([ "COPILOT_GITHUB_TOKEN", @@ -17,6 +45,47 @@ describe("bundled provider auth env vars", () => { "MINIMAX_API_KEY", ]); expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES.openai).toEqual(["OPENAI_API_KEY"]); - expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES["openai-codex"]).toBeUndefined(); + expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES.fal).toEqual(["FAL_KEY"]); + expect("openai-codex" in BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES).toBe(false); + }); + + it("supports check mode for stale generated artifacts", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-provider-auth-env-vars-")); + tempDirs.push(tempRoot); + + writeJson(path.join(tempRoot, "extensions", "alpha", "openclaw.plugin.json"), { + id: "alpha", + providerAuthEnvVars: { + alpha: ["ALPHA_TOKEN"], + }, + }); + + const initial = writeBundledProviderAuthEnvVarModule({ + repoRoot: tempRoot, + outputPath: "src/plugins/bundled-provider-auth-env-vars.generated.ts", + }); + expect(initial.wrote).toBe(true); + + const current = writeBundledProviderAuthEnvVarModule({ + repoRoot: tempRoot, + outputPath: "src/plugins/bundled-provider-auth-env-vars.generated.ts", + check: true, + }); + expect(current.changed).toBe(false); + expect(current.wrote).toBe(false); + + fs.writeFileSync( + path.join(tempRoot, "src/plugins/bundled-provider-auth-env-vars.generated.ts"), + "// stale\n", + "utf8", + ); + + const stale = writeBundledProviderAuthEnvVarModule({ + repoRoot: tempRoot, + outputPath: "src/plugins/bundled-provider-auth-env-vars.generated.ts", + check: true, + }); + expect(stale.changed).toBe(true); + expect(stale.wrote).toBe(false); }); }); diff --git a/src/plugins/bundled-provider-auth-env-vars.ts b/src/plugins/bundled-provider-auth-env-vars.ts index 42ca376959d..3df3d5c9d36 100644 --- a/src/plugins/bundled-provider-auth-env-vars.ts +++ b/src/plugins/bundled-provider-auth-env-vars.ts @@ -1,93 +1,3 @@ -import ANTHROPIC_MANIFEST from "../../extensions/anthropic/openclaw.plugin.json" with { type: "json" }; -import BYTEPLUS_MANIFEST from "../../extensions/byteplus/openclaw.plugin.json" with { type: "json" }; -import CLOUDFLARE_AI_GATEWAY_MANIFEST from "../../extensions/cloudflare-ai-gateway/openclaw.plugin.json" with { type: "json" }; -import COPILOT_PROXY_MANIFEST from "../../extensions/copilot-proxy/openclaw.plugin.json" with { type: "json" }; -import GITHUB_COPILOT_MANIFEST from "../../extensions/github-copilot/openclaw.plugin.json" with { type: "json" }; -import GOOGLE_MANIFEST from "../../extensions/google/openclaw.plugin.json" with { type: "json" }; -import HUGGINGFACE_MANIFEST from "../../extensions/huggingface/openclaw.plugin.json" with { type: "json" }; -import KILOCODE_MANIFEST from "../../extensions/kilocode/openclaw.plugin.json" with { type: "json" }; -import KIMI_CODING_MANIFEST from "../../extensions/kimi-coding/openclaw.plugin.json" with { type: "json" }; -import MINIMAX_MANIFEST from "../../extensions/minimax/openclaw.plugin.json" with { type: "json" }; -import MISTRAL_MANIFEST from "../../extensions/mistral/openclaw.plugin.json" with { type: "json" }; -import MODELSTUDIO_MANIFEST from "../../extensions/modelstudio/openclaw.plugin.json" with { type: "json" }; -import MOONSHOT_MANIFEST from "../../extensions/moonshot/openclaw.plugin.json" with { type: "json" }; -import NVIDIA_MANIFEST from "../../extensions/nvidia/openclaw.plugin.json" with { type: "json" }; -import OLLAMA_MANIFEST from "../../extensions/ollama/openclaw.plugin.json" with { type: "json" }; -import OPENAI_MANIFEST from "../../extensions/openai/openclaw.plugin.json" with { type: "json" }; -import OPENCODE_GO_MANIFEST from "../../extensions/opencode-go/openclaw.plugin.json" with { type: "json" }; -import OPENCODE_MANIFEST from "../../extensions/opencode/openclaw.plugin.json" with { type: "json" }; -import OPENROUTER_MANIFEST from "../../extensions/openrouter/openclaw.plugin.json" with { type: "json" }; -import QIANFAN_MANIFEST from "../../extensions/qianfan/openclaw.plugin.json" with { type: "json" }; -import QWEN_PORTAL_AUTH_MANIFEST from "../../extensions/qwen-portal-auth/openclaw.plugin.json" with { type: "json" }; -import SGLANG_MANIFEST from "../../extensions/sglang/openclaw.plugin.json" with { type: "json" }; -import SYNTHETIC_MANIFEST from "../../extensions/synthetic/openclaw.plugin.json" with { type: "json" }; -import TOGETHER_MANIFEST from "../../extensions/together/openclaw.plugin.json" with { type: "json" }; -import VENICE_MANIFEST from "../../extensions/venice/openclaw.plugin.json" with { type: "json" }; -import VERCEL_AI_GATEWAY_MANIFEST from "../../extensions/vercel-ai-gateway/openclaw.plugin.json" with { type: "json" }; -import VLLM_MANIFEST from "../../extensions/vllm/openclaw.plugin.json" with { type: "json" }; -import VOLCENGINE_MANIFEST from "../../extensions/volcengine/openclaw.plugin.json" with { type: "json" }; -import XAI_MANIFEST from "../../extensions/xai/openclaw.plugin.json" with { type: "json" }; -import XIAOMI_MANIFEST from "../../extensions/xiaomi/openclaw.plugin.json" with { type: "json" }; -import ZAI_MANIFEST from "../../extensions/zai/openclaw.plugin.json" with { type: "json" }; - -type ProviderAuthEnvVarManifest = { - id?: string; - providerAuthEnvVars?: Record; -}; - -function collectBundledProviderAuthEnvVars( - manifests: readonly ProviderAuthEnvVarManifest[], -): Record { - const entries: Record = {}; - for (const manifest of manifests) { - const providerAuthEnvVars = manifest.providerAuthEnvVars; - if (!providerAuthEnvVars) { - continue; - } - for (const [providerId, envVars] of Object.entries(providerAuthEnvVars)) { - const normalizedProviderId = providerId.trim(); - const normalizedEnvVars = envVars.map((value) => value.trim()).filter(Boolean); - if (!normalizedProviderId || normalizedEnvVars.length === 0) { - continue; - } - entries[normalizedProviderId] = normalizedEnvVars; - } - } - return entries; -} - -// Read bundled provider auth env metadata from manifests so env-based auth -// lookup stays cheap and does not need to boot plugin runtime code. -export const BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES = collectBundledProviderAuthEnvVars([ - ANTHROPIC_MANIFEST, - BYTEPLUS_MANIFEST, - CLOUDFLARE_AI_GATEWAY_MANIFEST, - COPILOT_PROXY_MANIFEST, - GITHUB_COPILOT_MANIFEST, - GOOGLE_MANIFEST, - HUGGINGFACE_MANIFEST, - KILOCODE_MANIFEST, - KIMI_CODING_MANIFEST, - MINIMAX_MANIFEST, - MISTRAL_MANIFEST, - MODELSTUDIO_MANIFEST, - MOONSHOT_MANIFEST, - NVIDIA_MANIFEST, - OLLAMA_MANIFEST, - OPENAI_MANIFEST, - OPENCODE_GO_MANIFEST, - OPENCODE_MANIFEST, - OPENROUTER_MANIFEST, - QIANFAN_MANIFEST, - QWEN_PORTAL_AUTH_MANIFEST, - SGLANG_MANIFEST, - SYNTHETIC_MANIFEST, - TOGETHER_MANIFEST, - VENICE_MANIFEST, - VERCEL_AI_GATEWAY_MANIFEST, - VLLM_MANIFEST, - VOLCENGINE_MANIFEST, - XAI_MANIFEST, - XIAOMI_MANIFEST, - ZAI_MANIFEST, -]); +// Generated from extension manifests so core secrets/auth code does not need +// static imports into extension source trees. +export { BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES } from "./bundled-provider-auth-env-vars.generated.js"; diff --git a/src/plugins/contracts/discovery.contract.test.ts b/src/plugins/contracts/discovery.contract.test.ts index 123933e194c..77606c8dcf9 100644 --- a/src/plugins/contracts/discovery.contract.test.ts +++ b/src/plugins/contracts/discovery.contract.test.ts @@ -458,7 +458,7 @@ describe("provider discovery contract", () => { authHeader: true, apiKey: "minimax-key", models: expect.arrayContaining([ - expect.objectContaining({ id: "MiniMax-M2.5" }), + expect.objectContaining({ id: "MiniMax-M2.7" }), expect.objectContaining({ id: "MiniMax-VL-01" }), ]), }, @@ -499,7 +499,7 @@ describe("provider discovery contract", () => { api: "anthropic-messages", authHeader: true, apiKey: "minimax-oauth", - models: expect.arrayContaining([expect.objectContaining({ id: "MiniMax-M2.5" })]), + models: expect.arrayContaining([expect.objectContaining({ id: "MiniMax-M2.7" })]), }, }); }); diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 60673ffa67f..194fcdae1d1 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { pathToFileURL } from "node:url"; +import { createJiti } from "jiti"; import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; import { withEnv } from "../test-utils/env.js"; async function importFreshPluginTestModules() { @@ -3341,6 +3342,82 @@ module.exports = { expect("alias" in options).toBe(false); }); + it("uses transpiled Jiti loads for source TypeScript plugin entries", () => { + expect(__testing.shouldPreferNativeJiti("/repo/dist/plugins/runtime/index.js")).toBe(true); + expect( + __testing.shouldPreferNativeJiti("/repo/extensions/discord/src/channel.runtime.ts"), + ).toBe(false); + }); + + it("loads source runtime shims through the non-native Jiti boundary", async () => { + const jiti = createJiti(import.meta.url, { + ...__testing.buildPluginLoaderJitiOptions({}), + tryNative: false, + }); + const discordChannelRuntime = path.join( + process.cwd(), + "extensions", + "discord", + "src", + "channel.runtime.ts", + ); + const discordVoiceRuntime = path.join( + process.cwd(), + "extensions", + "discord", + "src", + "voice", + "manager.runtime.ts", + ); + + await expect(jiti.import(discordChannelRuntime)).resolves.toMatchObject({ + discordSetupWizard: expect.any(Object), + }); + await expect(jiti.import(discordVoiceRuntime)).resolves.toMatchObject({ + DiscordVoiceManager: expect.any(Function), + DiscordVoiceReadyListener: expect.any(Function), + }); + }); + + it("loads source TypeScript plugins that route through local runtime shims", () => { + const plugin = writePlugin({ + id: "source-runtime-shim", + filename: "source-runtime-shim.ts", + body: `import "./runtime-shim.ts"; + +export default { + id: "source-runtime-shim", + register() {}, +};`, + }); + fs.writeFileSync( + path.join(plugin.dir, "runtime-shim.ts"), + `import { helperValue } from "./helper.js"; + +export const runtimeValue = helperValue;`, + "utf-8", + ); + fs.writeFileSync( + path.join(plugin.dir, "helper.ts"), + `export const helperValue = "ok";`, + "utf-8", + ); + + const registry = loadOpenClawPlugins({ + cache: false, + workspaceDir: plugin.dir, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["source-runtime-shim"], + }, + }, + }); + + const record = registry.plugins.find((entry) => entry.id === "source-runtime-shim"); + expect(record?.status).toBe("loaded"); + }); + it.each([ { name: "prefers dist plugin runtime module when loader runs from dist", diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index c39a64e5f30..7be252d68e6 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -288,6 +288,18 @@ const resolvePluginSdkScopedAliasMap = (): Record => { return aliasMap; }; +function shouldPreferNativeJiti(modulePath: string): boolean { + switch (path.extname(modulePath).toLowerCase()) { + case ".js": + case ".mjs": + case ".cjs": + case ".json": + return true; + default: + return false; + } +} + export const __testing = { buildPluginLoaderJitiOptions, listPluginSdkAliasCandidates, @@ -295,6 +307,7 @@ export const __testing = { resolvePluginSdkAliasCandidateOrder, resolvePluginSdkAliasFile, resolvePluginRuntimeModulePath, + shouldPreferNativeJiti, maxPluginRegistryCacheEntries: MAX_PLUGIN_REGISTRY_CACHE_ENTRIES, }; @@ -849,18 +862,28 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi } // Lazy: avoid creating the Jiti loader when all plugins are disabled (common in unit tests). - let jitiLoader: ReturnType | null = null; - const getJiti = () => { - if (jitiLoader) { - return jitiLoader; + const jitiLoaders = new Map>(); + const getJiti = (modulePath: string) => { + const tryNative = shouldPreferNativeJiti(modulePath); + const cached = jitiLoaders.get(tryNative); + if (cached) { + return cached; } const pluginSdkAlias = resolvePluginSdkAlias(); const aliasMap = { ...(pluginSdkAlias ? { "openclaw/plugin-sdk": pluginSdkAlias } : {}), ...resolvePluginSdkScopedAliasMap(), }; - jitiLoader = createJiti(import.meta.url, buildPluginLoaderJitiOptions(aliasMap)); - return jitiLoader; + const loader = createJiti(import.meta.url, { + ...buildPluginLoaderJitiOptions(aliasMap), + // Source .ts runtime shims import sibling ".js" specifiers that only exist + // after build. Disable native loading for source entries so Jiti rewrites + // those imports against the source graph, while keeping native dist/*.js + // loading for the canonical built module graph. + tryNative, + }); + jitiLoaders.set(tryNative, loader); + return loader; }; let createPluginRuntimeFactory: ((options?: CreatePluginRuntimeOptions) => PluginRuntime) | null = @@ -875,7 +898,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi if (!runtimeModulePath) { throw new Error("Unable to resolve plugin runtime module"); } - const runtimeModule = getJiti()(runtimeModulePath) as { + const runtimeModule = getJiti(runtimeModulePath)(runtimeModulePath) as { createPluginRuntime?: (options?: CreatePluginRuntimeOptions) => PluginRuntime; }; if (typeof runtimeModule.createPluginRuntime !== "function") { @@ -1208,7 +1231,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi let mod: OpenClawPluginModule | null = null; try { - mod = getJiti()(safeSource) as OpenClawPluginModule; + mod = getJiti(safeSource)(safeSource) as OpenClawPluginModule; } catch (err) { recordPluginError({ logger, diff --git a/src/plugins/provider-model-definitions.ts b/src/plugins/provider-model-definitions.ts index 5788d0ad2ca..5eebcb204db 100644 --- a/src/plugins/provider-model-definitions.ts +++ b/src/plugins/provider-model-definitions.ts @@ -1,9 +1,14 @@ -import { KIMI_CODING_MODEL_REF } from "../../extensions/kimi-coding/onboard.js"; import { - KIMI_DEFAULT_MODEL_ID as KIMI_CODING_MODEL_ID, KIMI_CODING_BASE_URL, -} from "../../extensions/kimi-coding/provider-catalog.js"; -import { + KIMI_CODING_DEFAULT_MODEL_ID as KIMI_CODING_MODEL_ID, + buildMinimaxApiModelDefinition, + buildMinimaxModelDefinition, + buildMistralModelDefinition, + buildModelStudioDefaultModelDefinition, + buildModelStudioModelDefinition, + buildMoonshotProvider, + buildXaiModelDefinition, + buildZaiModelDefinition, DEFAULT_MINIMAX_BASE_URL, MINIMAX_API_BASE_URL, MINIMAX_API_COST, @@ -12,48 +17,24 @@ import { MINIMAX_HOSTED_MODEL_ID, MINIMAX_HOSTED_MODEL_REF, MINIMAX_LM_STUDIO_COST, - buildMinimaxApiModelDefinition, - buildMinimaxModelDefinition, -} from "../../extensions/minimax/model-definitions.js"; -import { - buildMistralModelDefinition, MISTRAL_BASE_URL, MISTRAL_DEFAULT_COST, MISTRAL_DEFAULT_MODEL_ID, MISTRAL_DEFAULT_MODEL_REF, -} from "../../extensions/mistral/model-definitions.js"; -import { MODELSTUDIO_CN_BASE_URL, MODELSTUDIO_DEFAULT_COST, MODELSTUDIO_DEFAULT_MODEL_ID, MODELSTUDIO_DEFAULT_MODEL_REF, MODELSTUDIO_GLOBAL_BASE_URL, - buildModelStudioDefaultModelDefinition, - buildModelStudioModelDefinition, -} from "../../extensions/modelstudio/model-definitions.js"; -import { - MOONSHOT_CN_BASE_URL, - MOONSHOT_DEFAULT_MODEL_REF, -} from "../../extensions/moonshot/onboard.js"; -import { - buildMoonshotProvider, MOONSHOT_BASE_URL, + MOONSHOT_CN_BASE_URL, MOONSHOT_DEFAULT_MODEL_ID, -} from "../../extensions/moonshot/provider-catalog.js"; -import { QIANFAN_DEFAULT_MODEL_REF } from "../../extensions/qianfan/onboard.js"; -import { QIANFAN_BASE_URL, QIANFAN_DEFAULT_MODEL_ID, -} from "../../extensions/qianfan/provider-catalog.js"; -import { XAI_BASE_URL, XAI_DEFAULT_COST, XAI_DEFAULT_MODEL_ID, XAI_DEFAULT_MODEL_REF, - buildXaiModelDefinition, -} from "../../extensions/xai/model-definitions.js"; -import { - buildZaiModelDefinition, resolveZaiBaseUrl, ZAI_CN_BASE_URL, ZAI_CODING_CN_BASE_URL, @@ -61,7 +42,7 @@ import { ZAI_DEFAULT_COST, ZAI_DEFAULT_MODEL_ID, ZAI_GLOBAL_BASE_URL, -} from "../../extensions/zai/model-definitions.js"; +} from "openclaw/plugin-sdk/provider-models"; import type { ModelDefinitionConfig } from "../config/types.models.js"; import { KILOCODE_DEFAULT_CONTEXT_WINDOW, @@ -71,6 +52,10 @@ import { KILOCODE_DEFAULT_MODEL_NAME, } from "../providers/kilocode-shared.js"; +const KIMI_CODING_MODEL_REF = `kimi/${KIMI_CODING_MODEL_ID}`; +const MOONSHOT_DEFAULT_MODEL_REF = `moonshot/${MOONSHOT_DEFAULT_MODEL_ID}`; +const QIANFAN_DEFAULT_MODEL_REF = `qianfan/${QIANFAN_DEFAULT_MODEL_ID}`; + export { DEFAULT_MINIMAX_BASE_URL, MINIMAX_API_BASE_URL, diff --git a/src/plugins/provider-zai-endpoint.ts b/src/plugins/provider-zai-endpoint.ts index 4426b1065fe..5e76755c969 100644 --- a/src/plugins/provider-zai-endpoint.ts +++ b/src/plugins/provider-zai-endpoint.ts @@ -3,7 +3,7 @@ import { ZAI_CODING_CN_BASE_URL, ZAI_CODING_GLOBAL_BASE_URL, ZAI_GLOBAL_BASE_URL, -} from "../../extensions/zai/model-definitions.js"; +} from "openclaw/plugin-sdk/provider-models"; import { fetchWithTimeout } from "../utils/fetch-timeout.js"; export type ZaiEndpointId = "global" | "cn" | "coding-global" | "coding-cn"; diff --git a/src/plugins/runtime/runtime-discord-ops.runtime.ts b/src/plugins/runtime/runtime-discord-ops.runtime.ts index e1bc99166af..02a4cc22eb0 100644 --- a/src/plugins/runtime/runtime-discord-ops.runtime.ts +++ b/src/plugins/runtime/runtime-discord-ops.runtime.ts @@ -1,12 +1,12 @@ -import { auditDiscordChannelPermissions as auditDiscordChannelPermissionsImpl } from "../../../extensions/discord/runtime-api.js"; +import { auditDiscordChannelPermissions as auditDiscordChannelPermissionsImpl } from "openclaw/plugin-sdk/discord"; import { listDiscordDirectoryGroupsLive as listDiscordDirectoryGroupsLiveImpl, listDiscordDirectoryPeersLive as listDiscordDirectoryPeersLiveImpl, -} from "../../../extensions/discord/runtime-api.js"; -import { monitorDiscordProvider as monitorDiscordProviderImpl } from "../../../extensions/discord/runtime-api.js"; -import { probeDiscord as probeDiscordImpl } from "../../../extensions/discord/runtime-api.js"; -import { resolveDiscordChannelAllowlist as resolveDiscordChannelAllowlistImpl } from "../../../extensions/discord/runtime-api.js"; -import { resolveDiscordUserAllowlist as resolveDiscordUserAllowlistImpl } from "../../../extensions/discord/runtime-api.js"; +} from "openclaw/plugin-sdk/discord"; +import { monitorDiscordProvider as monitorDiscordProviderImpl } from "openclaw/plugin-sdk/discord"; +import { probeDiscord as probeDiscordImpl } from "openclaw/plugin-sdk/discord"; +import { resolveDiscordChannelAllowlist as resolveDiscordChannelAllowlistImpl } from "openclaw/plugin-sdk/discord"; +import { resolveDiscordUserAllowlist as resolveDiscordUserAllowlistImpl } from "openclaw/plugin-sdk/discord"; import { createThreadDiscord as createThreadDiscordImpl, deleteMessageDiscord as deleteMessageDiscordImpl, @@ -18,7 +18,7 @@ import { sendPollDiscord as sendPollDiscordImpl, sendTypingDiscord as sendTypingDiscordImpl, unpinMessageDiscord as unpinMessageDiscordImpl, -} from "../../../extensions/discord/runtime-api.js"; +} from "openclaw/plugin-sdk/discord"; import type { PluginRuntimeChannel } from "./types-channel.js"; type RuntimeDiscordOps = Pick< diff --git a/src/plugins/runtime/runtime-discord.ts b/src/plugins/runtime/runtime-discord.ts index 8264a7f04df..354d205a66d 100644 --- a/src/plugins/runtime/runtime-discord.ts +++ b/src/plugins/runtime/runtime-discord.ts @@ -1,4 +1,4 @@ -import { discordMessageActions } from "../../../extensions/discord/runtime-api.js"; +import { discordMessageActions } from "openclaw/plugin-sdk/discord"; import { getThreadBindingManager, resolveThreadBindingIdleTimeoutMs, @@ -8,7 +8,7 @@ import { setThreadBindingIdleTimeoutBySessionKey, setThreadBindingMaxAgeBySessionKey, unbindThreadBindingsBySessionKey, -} from "../../../extensions/discord/runtime-api.js"; +} from "openclaw/plugin-sdk/discord"; import { createLazyRuntimeMethodBinder, createLazyRuntimeSurface, diff --git a/src/plugins/runtime/runtime-imessage.ts b/src/plugins/runtime/runtime-imessage.ts index 56136197626..7740b6bdfa3 100644 --- a/src/plugins/runtime/runtime-imessage.ts +++ b/src/plugins/runtime/runtime-imessage.ts @@ -2,7 +2,7 @@ import { monitorIMessageProvider, probeIMessage, sendMessageIMessage, -} from "../../../extensions/imessage/runtime-api.js"; +} from "openclaw/plugin-sdk/imessage"; import type { PluginRuntimeChannel } from "./types-channel.js"; export function createRuntimeIMessage(): PluginRuntimeChannel["imessage"] { diff --git a/src/plugins/runtime/runtime-signal.ts b/src/plugins/runtime/runtime-signal.ts index 5eade131012..e0b3c244e39 100644 --- a/src/plugins/runtime/runtime-signal.ts +++ b/src/plugins/runtime/runtime-signal.ts @@ -3,7 +3,7 @@ import { probeSignal, signalMessageActions, sendMessageSignal, -} from "../../../extensions/signal/runtime-api.js"; +} from "openclaw/plugin-sdk/signal"; import type { PluginRuntimeChannel } from "./types-channel.js"; export function createRuntimeSignal(): PluginRuntimeChannel["signal"] { diff --git a/src/plugins/runtime/runtime-slack-ops.runtime.ts b/src/plugins/runtime/runtime-slack-ops.runtime.ts index 8c06f2dda34..89411fafc00 100644 --- a/src/plugins/runtime/runtime-slack-ops.runtime.ts +++ b/src/plugins/runtime/runtime-slack-ops.runtime.ts @@ -1,13 +1,13 @@ import { listSlackDirectoryGroupsLive as listSlackDirectoryGroupsLiveImpl, listSlackDirectoryPeersLive as listSlackDirectoryPeersLiveImpl, -} from "../../../extensions/slack/runtime-api.js"; -import { monitorSlackProvider as monitorSlackProviderImpl } from "../../../extensions/slack/runtime-api.js"; -import { probeSlack as probeSlackImpl } from "../../../extensions/slack/runtime-api.js"; -import { resolveSlackChannelAllowlist as resolveSlackChannelAllowlistImpl } from "../../../extensions/slack/runtime-api.js"; -import { resolveSlackUserAllowlist as resolveSlackUserAllowlistImpl } from "../../../extensions/slack/runtime-api.js"; -import { sendMessageSlack as sendMessageSlackImpl } from "../../../extensions/slack/runtime-api.js"; -import { handleSlackAction as handleSlackActionImpl } from "../../../extensions/slack/runtime-api.js"; +} from "openclaw/plugin-sdk/slack"; +import { monitorSlackProvider as monitorSlackProviderImpl } from "openclaw/plugin-sdk/slack"; +import { probeSlack as probeSlackImpl } from "openclaw/plugin-sdk/slack"; +import { resolveSlackChannelAllowlist as resolveSlackChannelAllowlistImpl } from "openclaw/plugin-sdk/slack"; +import { resolveSlackUserAllowlist as resolveSlackUserAllowlistImpl } from "openclaw/plugin-sdk/slack"; +import { sendMessageSlack as sendMessageSlackImpl } from "openclaw/plugin-sdk/slack"; +import { handleSlackAction as handleSlackActionImpl } from "openclaw/plugin-sdk/slack"; import type { PluginRuntimeChannel } from "./types-channel.js"; type RuntimeSlackOps = Pick< diff --git a/src/plugins/runtime/runtime-telegram-ops.runtime.ts b/src/plugins/runtime/runtime-telegram-ops.runtime.ts index dcd3fa05dec..5b49e854651 100644 --- a/src/plugins/runtime/runtime-telegram-ops.runtime.ts +++ b/src/plugins/runtime/runtime-telegram-ops.runtime.ts @@ -1,6 +1,6 @@ -import { auditTelegramGroupMembership as auditTelegramGroupMembershipImpl } from "../../../extensions/telegram/runtime-api.js"; -import { monitorTelegramProvider as monitorTelegramProviderImpl } from "../../../extensions/telegram/runtime-api.js"; -import { probeTelegram as probeTelegramImpl } from "../../../extensions/telegram/runtime-api.js"; +import { auditTelegramGroupMembership as auditTelegramGroupMembershipImpl } from "openclaw/plugin-sdk/telegram"; +import { monitorTelegramProvider as monitorTelegramProviderImpl } from "openclaw/plugin-sdk/telegram"; +import { probeTelegram as probeTelegramImpl } from "openclaw/plugin-sdk/telegram"; import { deleteMessageTelegram as deleteMessageTelegramImpl, editMessageReplyMarkupTelegram as editMessageReplyMarkupTelegramImpl, @@ -11,7 +11,7 @@ import { sendPollTelegram as sendPollTelegramImpl, sendTypingTelegram as sendTypingTelegramImpl, unpinMessageTelegram as unpinMessageTelegramImpl, -} from "../../../extensions/telegram/runtime-api.js"; +} from "openclaw/plugin-sdk/telegram"; import type { PluginRuntimeChannel } from "./types-channel.js"; type RuntimeTelegramOps = Pick< diff --git a/src/plugins/runtime/runtime-telegram.ts b/src/plugins/runtime/runtime-telegram.ts index 74b4de7e48e..fd01f964f2a 100644 --- a/src/plugins/runtime/runtime-telegram.ts +++ b/src/plugins/runtime/runtime-telegram.ts @@ -1,10 +1,10 @@ -import { collectTelegramUnmentionedGroupIds } from "../../../extensions/telegram/runtime-api.js"; -import { telegramMessageActions } from "../../../extensions/telegram/runtime-api.js"; +import { collectTelegramUnmentionedGroupIds } from "openclaw/plugin-sdk/telegram"; +import { telegramMessageActions } from "openclaw/plugin-sdk/telegram"; import { setTelegramThreadBindingIdleTimeoutBySessionKey, setTelegramThreadBindingMaxAgeBySessionKey, -} from "../../../extensions/telegram/runtime-api.js"; -import { resolveTelegramToken } from "../../../extensions/telegram/runtime-api.js"; +} from "openclaw/plugin-sdk/telegram"; +import { resolveTelegramToken } from "openclaw/plugin-sdk/telegram"; import { createLazyRuntimeMethodBinder, createLazyRuntimeSurface, diff --git a/src/plugins/runtime/runtime-whatsapp-login-tool.ts b/src/plugins/runtime/runtime-whatsapp-login-tool.ts index 094e47c9a1d..33c2355cda1 100644 --- a/src/plugins/runtime/runtime-whatsapp-login-tool.ts +++ b/src/plugins/runtime/runtime-whatsapp-login-tool.ts @@ -1 +1 @@ -export { createWhatsAppLoginTool as createRuntimeWhatsAppLoginTool } from "../../../extensions/whatsapp/runtime-api.js"; +export { createWhatsAppLoginTool as createRuntimeWhatsAppLoginTool } from "openclaw/plugin-sdk/whatsapp"; diff --git a/src/plugins/runtime/runtime-whatsapp-login.runtime.ts b/src/plugins/runtime/runtime-whatsapp-login.runtime.ts index baef795d478..c0e89600bde 100644 --- a/src/plugins/runtime/runtime-whatsapp-login.runtime.ts +++ b/src/plugins/runtime/runtime-whatsapp-login.runtime.ts @@ -1,4 +1,4 @@ -import { loginWeb as loginWebImpl } from "../../../extensions/whatsapp/runtime-api.js"; +import { loginWeb as loginWebImpl } from "openclaw/plugin-sdk/whatsapp"; import type { PluginRuntime } from "./types.js"; type RuntimeWhatsAppLogin = Pick; diff --git a/src/plugins/runtime/runtime-whatsapp-outbound.runtime.ts b/src/plugins/runtime/runtime-whatsapp-outbound.runtime.ts index 91fcba6fd39..c213afe141e 100644 --- a/src/plugins/runtime/runtime-whatsapp-outbound.runtime.ts +++ b/src/plugins/runtime/runtime-whatsapp-outbound.runtime.ts @@ -1,7 +1,7 @@ import { sendMessageWhatsApp as sendMessageWhatsAppImpl, sendPollWhatsApp as sendPollWhatsAppImpl, -} from "../../../extensions/whatsapp/runtime-api.js"; +} from "openclaw/plugin-sdk/whatsapp"; import type { PluginRuntime } from "./types.js"; type RuntimeWhatsAppOutbound = Pick< diff --git a/src/plugins/runtime/runtime-whatsapp.ts b/src/plugins/runtime/runtime-whatsapp.ts index 796bc80bb5a..ca266581d21 100644 --- a/src/plugins/runtime/runtime-whatsapp.ts +++ b/src/plugins/runtime/runtime-whatsapp.ts @@ -1,11 +1,11 @@ -import { getActiveWebListener } from "../../../extensions/whatsapp/runtime-api.js"; +import { getActiveWebListener } from "openclaw/plugin-sdk/whatsapp"; import { getWebAuthAgeMs, - logoutWeb, logWebSelfId, + logoutWeb, readWebSelfId, webAuthExists, -} from "../../../extensions/whatsapp/runtime-api.js"; +} from "openclaw/plugin-sdk/whatsapp"; import { createLazyRuntimeMethodBinder, createLazyRuntimeSurface, @@ -63,16 +63,15 @@ const handleWhatsAppActionLazy: PluginRuntime["channel"]["whatsapp"]["handleWhat return handleWhatsAppAction(...args); }; -let webLoginQrPromise: Promise< - typeof import("../../../extensions/whatsapp/login-qr-api.js") -> | null = null; +let webLoginQrPromise: Promise | null = + null; let webChannelPromise: Promise | null = null; let whatsappActionsPromise: Promise< - typeof import("../../../extensions/whatsapp/action-runtime-api.js") + typeof import("openclaw/plugin-sdk/whatsapp-action-runtime") > | null = null; function loadWebLoginQr() { - webLoginQrPromise ??= import("../../../extensions/whatsapp/login-qr-api.js"); + webLoginQrPromise ??= import("openclaw/plugin-sdk/whatsapp-login-qr"); return webLoginQrPromise; } @@ -82,7 +81,7 @@ function loadWebChannel() { } function loadWhatsAppActions() { - whatsappActionsPromise ??= import("../../../extensions/whatsapp/action-runtime-api.js"); + whatsappActionsPromise ??= import("openclaw/plugin-sdk/whatsapp-action-runtime"); return whatsappActionsPromise; } diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index 7b53a0e0025..b5f9a8e8e7a 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -205,19 +205,19 @@ export type PluginRuntimeChannel = { sendMessageIMessage: typeof import("../../../extensions/imessage/runtime-api.js").sendMessageIMessage; }; whatsapp: { - getActiveWebListener: typeof import("../../../extensions/whatsapp/runtime-api.js").getActiveWebListener; - getWebAuthAgeMs: typeof import("../../../extensions/whatsapp/runtime-api.js").getWebAuthAgeMs; - logoutWeb: typeof import("../../../extensions/whatsapp/runtime-api.js").logoutWeb; - logWebSelfId: typeof import("../../../extensions/whatsapp/runtime-api.js").logWebSelfId; - readWebSelfId: typeof import("../../../extensions/whatsapp/runtime-api.js").readWebSelfId; - webAuthExists: typeof import("../../../extensions/whatsapp/runtime-api.js").webAuthExists; - sendMessageWhatsApp: typeof import("../../../extensions/whatsapp/runtime-api.js").sendMessageWhatsApp; - sendPollWhatsApp: typeof import("../../../extensions/whatsapp/runtime-api.js").sendPollWhatsApp; - loginWeb: typeof import("../../../extensions/whatsapp/runtime-api.js").loginWeb; - startWebLoginWithQr: typeof import("../../../extensions/whatsapp/login-qr-api.js").startWebLoginWithQr; - waitForWebLogin: typeof import("../../../extensions/whatsapp/login-qr-api.js").waitForWebLogin; + getActiveWebListener: typeof import("openclaw/plugin-sdk/whatsapp").getActiveWebListener; + getWebAuthAgeMs: typeof import("openclaw/plugin-sdk/whatsapp").getWebAuthAgeMs; + logoutWeb: typeof import("openclaw/plugin-sdk/whatsapp").logoutWeb; + logWebSelfId: typeof import("openclaw/plugin-sdk/whatsapp").logWebSelfId; + readWebSelfId: typeof import("openclaw/plugin-sdk/whatsapp").readWebSelfId; + webAuthExists: typeof import("openclaw/plugin-sdk/whatsapp").webAuthExists; + sendMessageWhatsApp: typeof import("openclaw/plugin-sdk/whatsapp").sendMessageWhatsApp; + sendPollWhatsApp: typeof import("openclaw/plugin-sdk/whatsapp").sendPollWhatsApp; + loginWeb: typeof import("openclaw/plugin-sdk/whatsapp").loginWeb; + startWebLoginWithQr: typeof import("openclaw/plugin-sdk/whatsapp-login-qr").startWebLoginWithQr; + waitForWebLogin: typeof import("openclaw/plugin-sdk/whatsapp-login-qr").waitForWebLogin; monitorWebChannel: typeof import("../../channels/web/index.js").monitorWebChannel; - handleWhatsAppAction: typeof import("../../../extensions/whatsapp/action-runtime-api.js").handleWhatsAppAction; + handleWhatsAppAction: typeof import("openclaw/plugin-sdk/whatsapp-action-runtime").handleWhatsAppAction; createLoginTool: typeof import("./runtime-whatsapp-login-tool.js").createRuntimeWhatsAppLoginTool; }; line: { diff --git a/src/security/audit-channel.runtime.ts b/src/security/audit-channel.runtime.ts index de2d666cb87..e53c1c19391 100644 --- a/src/security/audit-channel.runtime.ts +++ b/src/security/audit-channel.runtime.ts @@ -1,8 +1,8 @@ -import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; import { isNumericTelegramUserId, normalizeTelegramAllowFromEntry, -} from "../plugin-sdk/telegram.js"; +} from "openclaw/plugin-sdk/telegram"; +import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; import { isDiscordMutableAllowEntry, isZalouserMutableGroupEntry, diff --git a/src/security/dm-policy-shared.ts b/src/security/dm-policy-shared.ts index 7f42f02519e..fdab6636009 100644 --- a/src/security/dm-policy-shared.ts +++ b/src/security/dm-policy-shared.ts @@ -1,9 +1,9 @@ +import { evaluateMatchedGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access"; import { mergeDmAllowFromSources, resolveGroupAllowFromSources } from "../channels/allow-from.js"; import { resolveControlCommandGate } from "../channels/command-gating.js"; import type { ChannelId } from "../channels/plugins/types.js"; import type { GroupPolicy } from "../config/types.base.js"; import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; -import { evaluateMatchedGroupAccessForPolicy } from "../plugin-sdk/group-access.js"; import { normalizeStringEntries } from "../shared/string-normalization.js"; export function resolvePinnedMainDmOwnerFromAllowlist(params: { diff --git a/src/tts/tts.ts b/src/tts/tts.ts index 7d48dfb8e07..0a5aa81126e 100644 --- a/src/tts/tts.ts +++ b/src/tts/tts.ts @@ -9,6 +9,7 @@ import { unlinkSync, } from "node:fs"; import path from "node:path"; +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import type { ReplyPayload } from "../auto-reply/types.js"; import { normalizeChannelId } from "../channels/plugins/index.js"; import type { ChannelId } from "../channels/plugins/types.js"; @@ -793,7 +794,8 @@ export async function maybeApplyTtsToPayload(params: { return params.payload; } - const text = params.payload.text ?? ""; + const reply = resolveSendableOutboundReplyParts(params.payload); + const text = reply.text; const directives = parseTtsDirectives(text, config.modelOverrides, config.openai.baseUrl); if (directives.warnings.length > 0) { logVerbose(`TTS: ignored directive overrides (${directives.warnings.join("; ")})`); @@ -827,7 +829,7 @@ export async function maybeApplyTtsToPayload(params: { if (!ttsText.trim()) { return nextPayload; } - if (params.payload.mediaUrl || (params.payload.mediaUrls?.length ?? 0) > 0) { + if (reply.hasMedia) { return nextPayload; } if (text.includes("MEDIA:")) { diff --git a/src/tui/tui-session-actions.test.ts b/src/tui/tui-session-actions.test.ts index 67f5e4d8798..68065a25607 100644 --- a/src/tui/tui-session-actions.test.ts +++ b/src/tui/tui-session-actions.test.ts @@ -104,7 +104,7 @@ describe("tui session actions", () => { sessions: [ { key: "agent:main:main", - model: "Minimax-M2.5", + model: "Minimax-M2.7", modelProvider: "minimax", }, ], @@ -112,7 +112,7 @@ describe("tui session actions", () => { await second; - expect(state.sessionInfo.model).toBe("Minimax-M2.5"); + expect(state.sessionInfo.model).toBe("Minimax-M2.7"); expect(updateAutocompleteProvider).toHaveBeenCalledTimes(2); expect(updateFooter).toHaveBeenCalledTimes(2); expect(requestRender).toHaveBeenCalledTimes(2); diff --git a/test/architecture-smells.test.ts b/test/architecture-smells.test.ts new file mode 100644 index 00000000000..ebc9c5bf7b4 --- /dev/null +++ b/test/architecture-smells.test.ts @@ -0,0 +1,36 @@ +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { collectArchitectureSmells } from "../scripts/check-architecture-smells.mjs"; + +const repoRoot = process.cwd(); +const scriptPath = path.join(repoRoot, "scripts", "check-architecture-smells.mjs"); + +describe("architecture smell inventory", () => { + it("produces stable sorted output", async () => { + const first = await collectArchitectureSmells(); + const second = await collectArchitectureSmells(); + + expect(second).toEqual(first); + expect( + [...first].toSorted( + (left, right) => + left.category.localeCompare(right.category) || + left.file.localeCompare(right.file) || + left.line - right.line || + left.kind.localeCompare(right.kind) || + left.specifier.localeCompare(right.specifier) || + left.reason.localeCompare(right.reason), + ), + ).toEqual(first); + }); + + it("script json output matches the collector", async () => { + const stdout = execFileSync(process.execPath, [scriptPath, "--json"], { + cwd: repoRoot, + encoding: "utf8", + }); + + expect(JSON.parse(stdout)).toEqual(await collectArchitectureSmells()); + }); +}); diff --git a/test/fixtures/plugin-extension-import-boundary-inventory.json b/test/fixtures/plugin-extension-import-boundary-inventory.json index 2e1e1fb4156..fe51488c706 100644 --- a/test/fixtures/plugin-extension-import-boundary-inventory.json +++ b/test/fixtures/plugin-extension-import-boundary-inventory.json @@ -1,610 +1 @@ -[ - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 1, - "kind": "import", - "specifier": "../../extensions/anthropic/openclaw.plugin.json", - "resolvedPath": "extensions/anthropic/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 2, - "kind": "import", - "specifier": "../../extensions/byteplus/openclaw.plugin.json", - "resolvedPath": "extensions/byteplus/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 3, - "kind": "import", - "specifier": "../../extensions/cloudflare-ai-gateway/openclaw.plugin.json", - "resolvedPath": "extensions/cloudflare-ai-gateway/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 4, - "kind": "import", - "specifier": "../../extensions/copilot-proxy/openclaw.plugin.json", - "resolvedPath": "extensions/copilot-proxy/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 5, - "kind": "import", - "specifier": "../../extensions/github-copilot/openclaw.plugin.json", - "resolvedPath": "extensions/github-copilot/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 6, - "kind": "import", - "specifier": "../../extensions/google/openclaw.plugin.json", - "resolvedPath": "extensions/google/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 7, - "kind": "import", - "specifier": "../../extensions/huggingface/openclaw.plugin.json", - "resolvedPath": "extensions/huggingface/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 8, - "kind": "import", - "specifier": "../../extensions/kilocode/openclaw.plugin.json", - "resolvedPath": "extensions/kilocode/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 9, - "kind": "import", - "specifier": "../../extensions/kimi-coding/openclaw.plugin.json", - "resolvedPath": "extensions/kimi-coding/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 10, - "kind": "import", - "specifier": "../../extensions/minimax/openclaw.plugin.json", - "resolvedPath": "extensions/minimax/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 11, - "kind": "import", - "specifier": "../../extensions/mistral/openclaw.plugin.json", - "resolvedPath": "extensions/mistral/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 12, - "kind": "import", - "specifier": "../../extensions/modelstudio/openclaw.plugin.json", - "resolvedPath": "extensions/modelstudio/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 13, - "kind": "import", - "specifier": "../../extensions/moonshot/openclaw.plugin.json", - "resolvedPath": "extensions/moonshot/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 14, - "kind": "import", - "specifier": "../../extensions/nvidia/openclaw.plugin.json", - "resolvedPath": "extensions/nvidia/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 15, - "kind": "import", - "specifier": "../../extensions/ollama/openclaw.plugin.json", - "resolvedPath": "extensions/ollama/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 16, - "kind": "import", - "specifier": "../../extensions/openai/openclaw.plugin.json", - "resolvedPath": "extensions/openai/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 17, - "kind": "import", - "specifier": "../../extensions/opencode-go/openclaw.plugin.json", - "resolvedPath": "extensions/opencode-go/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 18, - "kind": "import", - "specifier": "../../extensions/opencode/openclaw.plugin.json", - "resolvedPath": "extensions/opencode/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 19, - "kind": "import", - "specifier": "../../extensions/openrouter/openclaw.plugin.json", - "resolvedPath": "extensions/openrouter/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 20, - "kind": "import", - "specifier": "../../extensions/qianfan/openclaw.plugin.json", - "resolvedPath": "extensions/qianfan/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 21, - "kind": "import", - "specifier": "../../extensions/qwen-portal-auth/openclaw.plugin.json", - "resolvedPath": "extensions/qwen-portal-auth/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 22, - "kind": "import", - "specifier": "../../extensions/sglang/openclaw.plugin.json", - "resolvedPath": "extensions/sglang/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 23, - "kind": "import", - "specifier": "../../extensions/synthetic/openclaw.plugin.json", - "resolvedPath": "extensions/synthetic/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 24, - "kind": "import", - "specifier": "../../extensions/together/openclaw.plugin.json", - "resolvedPath": "extensions/together/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 25, - "kind": "import", - "specifier": "../../extensions/venice/openclaw.plugin.json", - "resolvedPath": "extensions/venice/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 26, - "kind": "import", - "specifier": "../../extensions/vercel-ai-gateway/openclaw.plugin.json", - "resolvedPath": "extensions/vercel-ai-gateway/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 27, - "kind": "import", - "specifier": "../../extensions/vllm/openclaw.plugin.json", - "resolvedPath": "extensions/vllm/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 28, - "kind": "import", - "specifier": "../../extensions/volcengine/openclaw.plugin.json", - "resolvedPath": "extensions/volcengine/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 29, - "kind": "import", - "specifier": "../../extensions/xai/openclaw.plugin.json", - "resolvedPath": "extensions/xai/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 30, - "kind": "import", - "specifier": "../../extensions/xiaomi/openclaw.plugin.json", - "resolvedPath": "extensions/xiaomi/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/bundled-provider-auth-env-vars.ts", - "line": 31, - "kind": "import", - "specifier": "../../extensions/zai/openclaw.plugin.json", - "resolvedPath": "extensions/zai/openclaw.plugin.json", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/provider-model-definitions.ts", - "line": 1, - "kind": "import", - "specifier": "../../extensions/kimi-coding/onboard.js", - "resolvedPath": "extensions/kimi-coding/onboard.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/provider-model-definitions.ts", - "line": 5, - "kind": "import", - "specifier": "../../extensions/kimi-coding/provider-catalog.js", - "resolvedPath": "extensions/kimi-coding/provider-catalog.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/provider-model-definitions.ts", - "line": 17, - "kind": "import", - "specifier": "../../extensions/minimax/model-definitions.js", - "resolvedPath": "extensions/minimax/model-definitions.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/provider-model-definitions.ts", - "line": 24, - "kind": "import", - "specifier": "../../extensions/mistral/model-definitions.js", - "resolvedPath": "extensions/mistral/model-definitions.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/provider-model-definitions.ts", - "line": 33, - "kind": "import", - "specifier": "../../extensions/modelstudio/model-definitions.js", - "resolvedPath": "extensions/modelstudio/model-definitions.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/provider-model-definitions.ts", - "line": 37, - "kind": "import", - "specifier": "../../extensions/moonshot/onboard.js", - "resolvedPath": "extensions/moonshot/onboard.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/provider-model-definitions.ts", - "line": 42, - "kind": "import", - "specifier": "../../extensions/moonshot/provider-catalog.js", - "resolvedPath": "extensions/moonshot/provider-catalog.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/provider-model-definitions.ts", - "line": 43, - "kind": "import", - "specifier": "../../extensions/qianfan/onboard.js", - "resolvedPath": "extensions/qianfan/onboard.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/provider-model-definitions.ts", - "line": 47, - "kind": "import", - "specifier": "../../extensions/qianfan/provider-catalog.js", - "resolvedPath": "extensions/qianfan/provider-catalog.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/provider-model-definitions.ts", - "line": 54, - "kind": "import", - "specifier": "../../extensions/xai/model-definitions.js", - "resolvedPath": "extensions/xai/model-definitions.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/provider-model-definitions.ts", - "line": 64, - "kind": "import", - "specifier": "../../extensions/zai/model-definitions.js", - "resolvedPath": "extensions/zai/model-definitions.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/provider-zai-endpoint.ts", - "line": 6, - "kind": "import", - "specifier": "../../extensions/zai/model-definitions.js", - "resolvedPath": "extensions/zai/model-definitions.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-discord-ops.runtime.ts", - "line": 1, - "kind": "import", - "specifier": "../../../extensions/discord/runtime-api.js", - "resolvedPath": "extensions/discord/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-discord-ops.runtime.ts", - "line": 5, - "kind": "import", - "specifier": "../../../extensions/discord/runtime-api.js", - "resolvedPath": "extensions/discord/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-discord-ops.runtime.ts", - "line": 6, - "kind": "import", - "specifier": "../../../extensions/discord/runtime-api.js", - "resolvedPath": "extensions/discord/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-discord-ops.runtime.ts", - "line": 7, - "kind": "import", - "specifier": "../../../extensions/discord/runtime-api.js", - "resolvedPath": "extensions/discord/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-discord-ops.runtime.ts", - "line": 8, - "kind": "import", - "specifier": "../../../extensions/discord/runtime-api.js", - "resolvedPath": "extensions/discord/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-discord-ops.runtime.ts", - "line": 9, - "kind": "import", - "specifier": "../../../extensions/discord/runtime-api.js", - "resolvedPath": "extensions/discord/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-discord-ops.runtime.ts", - "line": 21, - "kind": "import", - "specifier": "../../../extensions/discord/runtime-api.js", - "resolvedPath": "extensions/discord/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-discord.ts", - "line": 1, - "kind": "import", - "specifier": "../../../extensions/discord/runtime-api.js", - "resolvedPath": "extensions/discord/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-discord.ts", - "line": 11, - "kind": "import", - "specifier": "../../../extensions/discord/runtime-api.js", - "resolvedPath": "extensions/discord/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-imessage.ts", - "line": 5, - "kind": "import", - "specifier": "../../../extensions/imessage/runtime-api.js", - "resolvedPath": "extensions/imessage/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-signal.ts", - "line": 6, - "kind": "import", - "specifier": "../../../extensions/signal/runtime-api.js", - "resolvedPath": "extensions/signal/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-slack-ops.runtime.ts", - "line": 4, - "kind": "import", - "specifier": "../../../extensions/slack/runtime-api.js", - "resolvedPath": "extensions/slack/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-slack-ops.runtime.ts", - "line": 5, - "kind": "import", - "specifier": "../../../extensions/slack/runtime-api.js", - "resolvedPath": "extensions/slack/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-slack-ops.runtime.ts", - "line": 6, - "kind": "import", - "specifier": "../../../extensions/slack/runtime-api.js", - "resolvedPath": "extensions/slack/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-slack-ops.runtime.ts", - "line": 7, - "kind": "import", - "specifier": "../../../extensions/slack/runtime-api.js", - "resolvedPath": "extensions/slack/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-slack-ops.runtime.ts", - "line": 8, - "kind": "import", - "specifier": "../../../extensions/slack/runtime-api.js", - "resolvedPath": "extensions/slack/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-slack-ops.runtime.ts", - "line": 9, - "kind": "import", - "specifier": "../../../extensions/slack/runtime-api.js", - "resolvedPath": "extensions/slack/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-slack-ops.runtime.ts", - "line": 10, - "kind": "import", - "specifier": "../../../extensions/slack/runtime-api.js", - "resolvedPath": "extensions/slack/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-telegram-ops.runtime.ts", - "line": 1, - "kind": "import", - "specifier": "../../../extensions/telegram/runtime-api.js", - "resolvedPath": "extensions/telegram/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-telegram-ops.runtime.ts", - "line": 2, - "kind": "import", - "specifier": "../../../extensions/telegram/runtime-api.js", - "resolvedPath": "extensions/telegram/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-telegram-ops.runtime.ts", - "line": 3, - "kind": "import", - "specifier": "../../../extensions/telegram/runtime-api.js", - "resolvedPath": "extensions/telegram/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-telegram-ops.runtime.ts", - "line": 14, - "kind": "import", - "specifier": "../../../extensions/telegram/runtime-api.js", - "resolvedPath": "extensions/telegram/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-telegram.ts", - "line": 1, - "kind": "import", - "specifier": "../../../extensions/telegram/runtime-api.js", - "resolvedPath": "extensions/telegram/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-telegram.ts", - "line": 2, - "kind": "import", - "specifier": "../../../extensions/telegram/runtime-api.js", - "resolvedPath": "extensions/telegram/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-telegram.ts", - "line": 6, - "kind": "import", - "specifier": "../../../extensions/telegram/runtime-api.js", - "resolvedPath": "extensions/telegram/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-telegram.ts", - "line": 7, - "kind": "import", - "specifier": "../../../extensions/telegram/runtime-api.js", - "resolvedPath": "extensions/telegram/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-whatsapp-login-tool.ts", - "line": 1, - "kind": "export", - "specifier": "../../../extensions/whatsapp/runtime-api.js", - "resolvedPath": "extensions/whatsapp/runtime-api.js", - "reason": "re-exports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-whatsapp-login.runtime.ts", - "line": 1, - "kind": "import", - "specifier": "../../../extensions/whatsapp/runtime-api.js", - "resolvedPath": "extensions/whatsapp/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-whatsapp-outbound.runtime.ts", - "line": 4, - "kind": "import", - "specifier": "../../../extensions/whatsapp/runtime-api.js", - "resolvedPath": "extensions/whatsapp/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-whatsapp.ts", - "line": 1, - "kind": "import", - "specifier": "../../../extensions/whatsapp/runtime-api.js", - "resolvedPath": "extensions/whatsapp/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-whatsapp.ts", - "line": 8, - "kind": "import", - "specifier": "../../../extensions/whatsapp/runtime-api.js", - "resolvedPath": "extensions/whatsapp/runtime-api.js", - "reason": "imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-whatsapp.ts", - "line": 75, - "kind": "dynamic-import", - "specifier": "../../../extensions/whatsapp/login-qr-api.js", - "resolvedPath": "extensions/whatsapp/login-qr-api.js", - "reason": "dynamically imports extension-owned file from src/plugins" - }, - { - "file": "src/plugins/runtime/runtime-whatsapp.ts", - "line": 85, - "kind": "dynamic-import", - "specifier": "../../../extensions/whatsapp/action-runtime-api.js", - "resolvedPath": "extensions/whatsapp/action-runtime-api.js", - "reason": "dynamically imports extension-owned file from src/plugins" - } -] +[] diff --git a/test/fixtures/test-parallel.behavior.json b/test/fixtures/test-parallel.behavior.json new file mode 100644 index 00000000000..b1ed463612e --- /dev/null +++ b/test/fixtures/test-parallel.behavior.json @@ -0,0 +1,60 @@ +{ + "unit": { + "isolated": [ + { + "file": "src/plugins/contracts/runtime.contract.test.ts", + "reason": "Async runtime imports + provider refresh seams; keep out of the shared lane." + }, + { + "file": "src/security/temp-path-guard.test.ts", + "reason": "Filesystem guard scans are sensitive to contention." + }, + { + "file": "src/infra/git-commit.test.ts", + "reason": "Mutates process.cwd() and core loader seams." + }, + { + "file": "extensions/imessage/src/monitor.shutdown.unhandled-rejection.test.ts", + "reason": "Touches process-level unhandledRejection listeners." + } + ], + "singletonIsolated": [ + { + "file": "src/cli/command-secret-gateway.test.ts", + "reason": "Clean in isolation, but can hang after sharing the broad lane." + } + ], + "threadSingleton": [ + { + "file": "src/channels/plugins/actions/actions.test.ts", + "reason": "Terminates cleanly under threads, but not process forks on this host." + }, + { + "file": "src/infra/outbound/deliver.test.ts", + "reason": "Terminates cleanly under threads, but not process forks on this host." + }, + { + "file": "src/infra/outbound/deliver.lifecycle.test.ts", + "reason": "Terminates cleanly under threads, but not process forks on this host." + }, + { + "file": "src/infra/outbound/message.channels.test.ts", + "reason": "Terminates cleanly under threads, but not process forks on this host." + }, + { + "file": "src/infra/outbound/message-action-runner.poll.test.ts", + "reason": "Terminates cleanly under threads, but not process forks on this host." + }, + { + "file": "src/tts/tts.test.ts", + "reason": "Terminates cleanly under threads, but not process forks on this host." + } + ], + "vmForkSingleton": [ + { + "file": "src/channels/plugins/contracts/inbound.telegram.contract.test.ts", + "reason": "Needs the vmForks lane when targeted." + } + ] + } +} diff --git a/test/fixtures/test-timings.unit.json b/test/fixtures/test-timings.unit.json new file mode 100644 index 00000000000..cdb2505d881 --- /dev/null +++ b/test/fixtures/test-timings.unit.json @@ -0,0 +1,227 @@ +{ + "config": "vitest.unit.config.ts", + "generatedAt": "2026-03-18T17:10:00.000Z", + "defaultDurationMs": 250, + "files": { + "src/security/audit.test.ts": { + "durationMs": 6200, + "testCount": 380 + }, + "src/plugins/loader.test.ts": { + "durationMs": 6100, + "testCount": 260 + }, + "src/cli/update-cli.test.ts": { + "durationMs": 5400, + "testCount": 210 + }, + "src/agents/pi-embedded-runner.test.ts": { + "durationMs": 5200, + "testCount": 140 + }, + "src/process/supervisor/supervisor.test.ts": { + "durationMs": 5000, + "testCount": 120 + }, + "src/agents/bash-tools.test.ts": { + "durationMs": 4700, + "testCount": 150 + }, + "src/cli/program.smoke.test.ts": { + "durationMs": 4500, + "testCount": 95 + }, + "src/hooks/install.test.ts": { + "durationMs": 4300, + "testCount": 95 + }, + "src/agents/skills.test.ts": { + "durationMs": 4200, + "testCount": 135 + }, + "src/config/schema.test.ts": { + "durationMs": 4000, + "testCount": 110 + }, + "src/media/store.test.ts": { + "durationMs": 3900, + "testCount": 120 + }, + "src/commands/agent.test.ts": { + "durationMs": 3700, + "testCount": 110 + }, + "extensions/telegram/src/bot.create-telegram-bot.test.ts": { + "durationMs": 3600, + "testCount": 80 + }, + "extensions/telegram/src/bot.test.ts": { + "durationMs": 3400, + "testCount": 95 + }, + "src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts": { + "durationMs": 3300, + "testCount": 85 + }, + "src/infra/archive.test.ts": { + "durationMs": 3200, + "testCount": 75 + }, + "src/auto-reply/reply.block-streaming.test.ts": { + "durationMs": 3100, + "testCount": 60 + }, + "src/agents/pi-embedded-runner.run-embedded-pi-agent.auth-profile-rotation.test.ts": { + "durationMs": 3000, + "testCount": 55 + }, + "src/agents/skills.buildworkspaceskillsnapshot.test.ts": { + "durationMs": 2900, + "testCount": 70 + }, + "src/docker-setup.test.ts": { + "durationMs": 2800, + "testCount": 65 + }, + "src/agents/skills-install.download.test.ts": { + "durationMs": 2700, + "testCount": 60 + }, + "src/config/schema.tags.test.ts": { + "durationMs": 2600, + "testCount": 70 + }, + "src/cli/daemon-cli.coverage.test.ts": { + "durationMs": 2500, + "testCount": 50 + }, + "extensions/slack/src/monitor/slash.test.ts": { + "durationMs": 2400, + "testCount": 55 + }, + "test/git-hooks-pre-commit.test.ts": { + "durationMs": 2300, + "testCount": 20 + }, + "src/commands/doctor.warns-state-directory-is-missing.test.ts": { + "durationMs": 2200, + "testCount": 35 + }, + "src/commands/doctor.warns-per-agent-sandbox-docker-browser-prune.test.ts": { + "durationMs": 2100, + "testCount": 30 + }, + "src/commands/doctor.runs-legacy-state-migrations-yes-mode-without.test.ts": { + "durationMs": 2000, + "testCount": 28 + }, + "src/browser/server.agent-contract-snapshot-endpoints.test.ts": { + "durationMs": 1900, + "testCount": 45 + }, + "src/browser/server.agent-contract-form-layout-act-commands.test.ts": { + "durationMs": 1800, + "testCount": 40 + }, + "src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts": { + "durationMs": 1700, + "testCount": 25 + }, + "src/agents/session-tool-result-guard.tool-result-persist-hook.test.ts": { + "durationMs": 1600, + "testCount": 22 + }, + "src/plugins/tools.optional.test.ts": { + "durationMs": 1590, + "testCount": 18 + }, + "src/security/fix.test.ts": { + "durationMs": 1580, + "testCount": 24 + }, + "src/utils.test.ts": { + "durationMs": 1570, + "testCount": 34 + }, + "src/auto-reply/tool-meta.test.ts": { + "durationMs": 1560, + "testCount": 26 + }, + "src/auto-reply/envelope.test.ts": { + "durationMs": 1550, + "testCount": 20 + }, + "src/commands/auth-choice.test.ts": { + "durationMs": 1540, + "testCount": 18 + }, + "src/agents/skills.build-workspace-skills-prompt.syncs-merged-skills-into-target-workspace.test.ts": { + "durationMs": 1530, + "testCount": 14 + }, + "src/media/store.header-ext.test.ts": { + "durationMs": 1520, + "testCount": 16 + }, + "extensions/whatsapp/src/media.test.ts": { + "durationMs": 1510, + "testCount": 16 + }, + "extensions/whatsapp/src/auto-reply.web-auto-reply.falls-back-text-media-send-fails.test.ts": { + "durationMs": 1500, + "testCount": 10 + }, + "src/browser/server.covers-additional-endpoint-branches.test.ts": { + "durationMs": 1490, + "testCount": 18 + }, + "src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts": { + "durationMs": 1480, + "testCount": 12 + }, + "src/browser/server.skips-default-maxchars-explicitly-set-zero.test.ts": { + "durationMs": 1470, + "testCount": 10 + }, + "src/browser/server.auth-token-gates-http.test.ts": { + "durationMs": 1460, + "testCount": 15 + }, + "extensions/acpx/src/runtime.test.ts": { + "durationMs": 1450, + "testCount": 12 + }, + "test/scripts/ios-team-id.test.ts": { + "durationMs": 1440, + "testCount": 12 + }, + "src/agents/bash-tools.exec.background-abort.test.ts": { + "durationMs": 1430, + "testCount": 10 + }, + "src/agents/subagent-announce.format.test.ts": { + "durationMs": 1420, + "testCount": 12 + }, + "src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.test.ts": { + "durationMs": 1410, + "testCount": 14 + }, + "src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts": { + "durationMs": 1400, + "testCount": 10 + }, + "src/auto-reply/reply.triggers.group-intro-prompts.test.ts": { + "durationMs": 1390, + "testCount": 12 + }, + "src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts": { + "durationMs": 1380, + "testCount": 10 + }, + "extensions/whatsapp/src/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts": { + "durationMs": 1370, + "testCount": 10 + } + } +} diff --git a/tsdown.config.ts b/tsdown.config.ts index 0d643b046ac..aafa874a041 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { defineConfig, type UserConfig } from "tsdown"; +import { shouldBuildBundledCluster } from "./scripts/lib/optional-bundled-clusters.mjs"; import { buildPluginSdkEntrySources } from "./scripts/lib/plugin-sdk-entries.mjs"; type InputOptionsFactory = Extract, Function>; @@ -81,6 +82,9 @@ function listBundledPluginBuildEntries(): Record { if (!dirent.isDirectory()) { continue; } + if (!shouldBuildBundledCluster(dirent.name, process.env)) { + continue; + } const pluginDir = path.join(extensionsRoot, dirent.name); const manifestPath = path.join(pluginDir, "openclaw.plugin.json");