Merge branch 'main' into fix/sandbox-fakeowner-write-data-loss
This commit is contained in:
commit
031cd958af
67
.github/workflows/ci.yml
vendored
67
.github/workflows/ci.yml
vendored
@ -78,6 +78,50 @@ jobs:
|
|||||||
|
|
||||||
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD
|
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD
|
||||||
|
|
||||||
|
changed-extensions:
|
||||||
|
needs: [docs-scope, changed-scope]
|
||||||
|
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
|
||||||
|
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||||
|
outputs:
|
||||||
|
has_changed_extensions: ${{ steps.changed.outputs.has_changed_extensions }}
|
||||||
|
changed_extensions_matrix: ${{ steps.changed.outputs.changed_extensions_matrix }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
fetch-tags: false
|
||||||
|
submodules: false
|
||||||
|
|
||||||
|
- name: Ensure changed-extensions base commit
|
||||||
|
uses: ./.github/actions/ensure-base-commit
|
||||||
|
with:
|
||||||
|
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
|
||||||
|
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
|
||||||
|
|
||||||
|
- name: Setup Node environment
|
||||||
|
uses: ./.github/actions/setup-node-env
|
||||||
|
with:
|
||||||
|
install-bun: "false"
|
||||||
|
install-deps: "false"
|
||||||
|
use-sticky-disk: "false"
|
||||||
|
|
||||||
|
- name: Detect changed extensions
|
||||||
|
id: changed
|
||||||
|
env:
|
||||||
|
BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
|
||||||
|
run: |
|
||||||
|
node --input-type=module <<'EOF'
|
||||||
|
import { appendFileSync } from "node:fs";
|
||||||
|
import { listChangedExtensionIds } from "./scripts/test-extension.mjs";
|
||||||
|
|
||||||
|
const extensionIds = listChangedExtensionIds({ base: process.env.BASE_SHA, head: "HEAD" });
|
||||||
|
const matrix = JSON.stringify({ include: extensionIds.map((extension) => ({ extension })) });
|
||||||
|
|
||||||
|
appendFileSync(process.env.GITHUB_OUTPUT, `has_changed_extensions=${extensionIds.length > 0}\n`, "utf8");
|
||||||
|
appendFileSync(process.env.GITHUB_OUTPUT, `changed_extensions_matrix=${matrix}\n`, "utf8");
|
||||||
|
EOF
|
||||||
|
|
||||||
# Build dist once for Node-relevant changes and share it with downstream jobs.
|
# Build dist once for Node-relevant changes and share it with downstream jobs.
|
||||||
build-artifacts:
|
build-artifacts:
|
||||||
needs: [docs-scope, changed-scope]
|
needs: [docs-scope, changed-scope]
|
||||||
@ -205,6 +249,29 @@ jobs:
|
|||||||
if: matrix.runtime != 'bun' || github.event_name != 'pull_request'
|
if: matrix.runtime != 'bun' || github.event_name != 'pull_request'
|
||||||
run: ${{ matrix.command }}
|
run: ${{ matrix.command }}
|
||||||
|
|
||||||
|
extension-fast:
|
||||||
|
name: "extension-fast (${{ matrix.extension }})"
|
||||||
|
needs: [docs-scope, changed-scope, changed-extensions]
|
||||||
|
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' && needs.changed-extensions.outputs.has_changed_extensions == 'true'
|
||||||
|
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix: ${{ fromJson(needs.changed-extensions.outputs.changed_extensions_matrix) }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
submodules: false
|
||||||
|
|
||||||
|
- name: Setup Node environment
|
||||||
|
uses: ./.github/actions/setup-node-env
|
||||||
|
with:
|
||||||
|
install-bun: "false"
|
||||||
|
use-sticky-disk: "false"
|
||||||
|
|
||||||
|
- name: Run changed extension tests
|
||||||
|
run: pnpm test:extension ${{ matrix.extension }}
|
||||||
|
|
||||||
# Types, lint, and format check.
|
# Types, lint, and format check.
|
||||||
check:
|
check:
|
||||||
name: "check"
|
name: "check"
|
||||||
|
|||||||
@ -1 +1,2 @@
|
|||||||
**/node_modules/
|
**/node_modules/
|
||||||
|
docs/.generated/
|
||||||
|
|||||||
69
CHANGELOG.md
69
CHANGELOG.md
@ -13,49 +13,52 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Plugins/bundles: add compatible Codex, Claude, and Cursor bundle discovery/install support, map bundle skills into OpenClaw skills, and apply Claude bundle `settings.json` defaults to embedded Pi with shell overrides sanitized.
|
- Plugins/bundles: add compatible Codex, Claude, and Cursor bundle discovery/install support, map bundle skills into OpenClaw skills, and apply Claude bundle `settings.json` defaults to embedded Pi with shell overrides sanitized.
|
||||||
- Plugins/providers: move OpenRouter, GitHub Copilot, and OpenAI Codex provider/runtime logic into bundled plugins, including dynamic model fallback, runtime auth exchange, stream wrappers, capability hints, and cache-TTL policy.
|
- Plugins/providers: move OpenRouter, GitHub Copilot, and OpenAI Codex provider/runtime logic into bundled plugins, including dynamic model fallback, runtime auth exchange, stream wrappers, capability hints, and cache-TTL policy.
|
||||||
- Plugins/agent integrations: broaden the plugin surface for app-server integrations with channel-aware commands, interactive callbacks, inbound claims, and Discord/Telegram conversation binding support. (#45318) Thanks @huntharo and @vincentkoc.
|
- Plugins/agent integrations: broaden the plugin surface for app-server integrations with channel-aware commands, interactive callbacks, inbound claims, and Discord/Telegram conversation binding support. (#45318) Thanks @huntharo and @vincentkoc.
|
||||||
- Install/update: allow package-manager installs from GitHub `main` via `openclaw update --tag main`, installer `--version main`, or direct npm/pnpm git specs.
|
- Install/update: allow package-manager installs from GitHub `main` via `openclaw update --tag main`, installer `--version main`, or direct npm/pnpm git specs. (#47630) Thanks @vincentkoc.
|
||||||
- Gateway/health monitor: add configurable stale-event thresholds and restart limits, plus per-channel and per-account `healthMonitor.enabled` overrides, while keeping the existing global disable path on `gateway.channelHealthCheckMinutes=0`. (#42107) Thanks @rstar327.
|
- Gateway/health monitor: add configurable stale-event thresholds and restart limits, plus per-channel and per-account `healthMonitor.enabled` overrides, while keeping the existing global disable path on `gateway.channelHealthCheckMinutes=0`. (#42107) Thanks @rstar327.
|
||||||
- Android/mobile: add a system-aware dark theme across onboarding and post-onboarding screens so the app follows the device theme through setup, chat, and voice flows. (#46249) Thanks @sibbl.
|
- Android/mobile: add a system-aware dark theme across onboarding and post-onboarding screens so the app follows the device theme through setup, chat, and voice flows. (#46249) Thanks @sibbl.
|
||||||
- Feishu/ACP: add current-conversation ACP and subagent session binding for supported DMs and topic conversations, including completion delivery back to the originating Feishu conversation. (#46819)
|
- Feishu/ACP: add current-conversation ACP and subagent session binding for supported DMs and topic conversations, including completion delivery back to the originating Feishu conversation. (#46819) Thanks @Takhoffman.
|
||||||
- Plugins/marketplaces: add Claude marketplace registry resolution, `plugin@marketplace` installs, marketplace listing, and update support, plus Docker E2E coverage for local and official marketplace flows. Thanks @vincentkoc.
|
- Plugins/marketplaces: add Claude marketplace registry resolution, `plugin@marketplace` installs, marketplace listing, and update support, plus Docker E2E coverage for local and official marketplace flows. (#48058) Thanks @vincentkoc.
|
||||||
- Feishu/cards: add structured interactive approval and quick-action launcher cards, preserve callback user and conversation context through routing, and keep legacy card-action fallback behavior so common actions can run without typing raw commands. (#47873)
|
- Feishu/cards: add structured interactive approval and quick-action launcher cards, preserve callback user and conversation context through routing, and keep legacy card-action fallback behavior so common actions can run without typing raw commands. (#47873) Thanks @Takhoffman.
|
||||||
- Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior. (#46029)
|
- Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior. (#46029) Thanks @day253.
|
||||||
- Feishu/cards: add identity-aware structured card headers and note footers for Feishu replies and direct sends, while keeping that presentation wired through the shared outbound identity path. (#29938) Thanks @nszhsl.
|
- Feishu/cards: add identity-aware structured card headers and note footers for Feishu replies and direct sends, while keeping that presentation wired through the shared outbound identity path. (#29938) Thanks @nszhsl.
|
||||||
- Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lxk7280.
|
- Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lxk7280.
|
||||||
- Plugins/MiniMax: merge the bundled MiniMax API and MiniMax OAuth plugin surfaces into a single default-on `minimax` plugin, while keeping legacy `minimax-portal-auth` config ids aliased for compatibility.
|
- Plugins/MiniMax: merge the bundled MiniMax API and MiniMax OAuth plugin surfaces into a single default-on `minimax` plugin, while keeping legacy `minimax-portal-auth` config ids aliased for compatibility.
|
||||||
- Telegram/actions: add `topic-edit` for forum-topic renames and icon updates while sharing the same Telegram topic-edit transport used by the plugin runtime. (#47798) Thanks @obviyus.
|
- Telegram/actions: add `topic-edit` for forum-topic renames and icon updates while sharing the same Telegram topic-edit transport used by the plugin runtime. (#47798) Thanks @obviyus.
|
||||||
- Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob.
|
- Telegram/error replies: add a default-off `channels.telegram.silentErrorReplies` setting so bot error replies can be delivered silently across regular replies, native commands, and fallback sends. (#19776) Thanks @ImLukeF.
|
||||||
|
- Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) Thanks @scoootscooob.
|
||||||
- Docs/Zalo: clarify the Marketplace-bot support matrix and config guidance so the Zalo channel docs match current Bot Creator behavior more closely. (#47552) Thanks @No898.
|
- Docs/Zalo: clarify the Marketplace-bot support matrix and config guidance so the Zalo channel docs match current Bot Creator behavior more closely. (#47552) Thanks @No898.
|
||||||
- secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant.
|
- secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant.
|
||||||
|
- Browser/existing-session: support `browser.profiles.<name>.userDataDir` so Chrome DevTools MCP can attach to Brave, Edge, and other Chromium-based browsers through their own user data directories. (#48170) Thanks @velvet-shark.
|
||||||
|
- Skills/prompt budget: preserve all registered skills via a compact catalog fallback before dropping entries when the full prompt format exceeds `maxSkillsPromptChars`. (#47553) Thanks @snese.
|
||||||
|
|
||||||
### Breaking
|
### Breaking
|
||||||
|
|
||||||
- Browser/Chrome MCP: remove the legacy Chrome extension relay path, bundled extension assets, `driver: "extension"`, and `browser.relayBindHost`. Run `openclaw doctor --fix` to migrate host-local browser config to `existing-session` / `user`; Docker, headless, sandbox, and remote browser flows still use raw CDP. Thanks @vincentkoc.
|
- Browser/Chrome MCP: remove the legacy Chrome extension relay path, bundled extension assets, `driver: "extension"`, and `browser.relayBindHost`. Run `openclaw doctor --fix` to migrate host-local browser config to `existing-session` / `user`; Docker, headless, sandbox, and remote browser flows still use raw CDP. (#47893) Thanks @vincentkoc.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
||||||
- Google auth/Node 25: patch `gaxios` to use native fetch without injecting `globalThis.window`, while translating proxy and mTLS transport settings so Google Vertex and Google Chat auth keep working on Node 25. (#47914) Thanks @pdd-cli.
|
- Google auth/Node 25: patch `gaxios` to use native fetch without injecting `globalThis.window`, while translating proxy and mTLS transport settings so Google Vertex and Google Chat auth keep working on Node 25. (#47914) Thanks @pdd-cli.
|
||||||
- Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse.
|
- Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse. (#47560) Thanks @ngutman.
|
||||||
- Plugins/context engines: enforce owner-aware context-engine registration on both loader and public SDK paths so plugins cannot spoof privileged ownership, claim the core `legacy` engine id, or overwrite an existing engine id through direct SDK imports. (#47595) Thanks @vincentkoc.
|
- Plugins/context engines: enforce owner-aware context-engine registration on both loader and public SDK paths so plugins cannot spoof privileged ownership, claim the core `legacy` engine id, or overwrite an existing engine id through direct SDK imports. (#47595) Thanks @vincentkoc.
|
||||||
- Browser/remote CDP: honor strict browser SSRF policy during remote CDP reachability and `/json/version` discovery checks, redact sensitive `cdpUrl` tokens from status output, and warn when remote CDP targets private/internal hosts.
|
- Browser/remote CDP: honor strict browser SSRF policy during remote CDP reachability and `/json/version` discovery checks, redact sensitive `cdpUrl` tokens from status output, and warn when remote CDP targets private/internal hosts.
|
||||||
- Gateway/plugins: pin runtime webhook routes to the gateway startup registry so channel webhooks keep working across plugin-registry churn, and make plugin auth + dispatch resolve routes from the same live HTTP-route registry. Fixes #46924 and #47041.
|
- Gateway/plugins: pin runtime webhook routes to the gateway startup registry so channel webhooks keep working across plugin-registry churn, and make plugin auth + dispatch resolve routes from the same live HTTP-route registry. (#47902) Fixes #46924 and #47041. Thanks @steipete.
|
||||||
- Gateway/auth: ignore spoofed loopback hops in trusted forwarding chains and block device approvals that request scopes above the caller session. Thanks @vincentkoc.
|
- Gateway/auth: ignore spoofed loopback hops in trusted forwarding chains and block device approvals that request scopes above the caller session. (#46800) Thanks @vincentkoc.
|
||||||
- Gateway/restart: defer externally signaled unmanaged restarts through the in-process idle drain, and preserve the restored subagent run as remap fallback during orphan recovery so resumed sessions do not duplicate work. (#47719) Thanks @joeykrug.
|
- Gateway/restart: defer externally signaled unmanaged restarts through the in-process idle drain, and preserve the restored subagent run as remap fallback during orphan recovery so resumed sessions do not duplicate work. (#47719) Thanks @joeykrug.
|
||||||
- Control UI/session routing: preserve established external delivery routes when webchat views or sends in externally originated sessions, so subagent completions still return to the original channel instead of the dashboard. (#47797) Thanks @brokemac79.
|
- Control UI/session routing: preserve established external delivery routes when webchat views or sends in externally originated sessions, so subagent completions still return to the original channel instead of the dashboard. (#47797) Thanks @brokemac79.
|
||||||
- Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob.
|
- Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) Thanks @scoootscooob.
|
||||||
- CLI/startup: lazy-load channel add and root help startup paths to trim avoidable RSS and help latency on constrained hosts. (#46784) Thanks @vincentkoc.
|
- CLI/startup: lazy-load channel add and root help startup paths to trim avoidable RSS and help latency on constrained hosts. (#46784) Thanks @vincentkoc.
|
||||||
- CLI/onboarding: import static provider definitions directly for onboarding model/config helpers so those paths no longer pull provider discovery just for built-in defaults. (#47467) Thanks @vincentkoc.
|
- CLI/onboarding: import static provider definitions directly for onboarding model/config helpers so those paths no longer pull provider discovery just for built-in defaults. (#47467) Thanks @vincentkoc.
|
||||||
- CLI/auth choice: lazy-load plugin/provider fallback resolution so mapped auth choices stay on the static path and only unknown choices pay the heavy provider load. (#47495) Thanks @vincentkoc.
|
- CLI/auth choice: lazy-load plugin/provider fallback resolution so mapped auth choices stay on the static path and only unknown choices pay the heavy provider load. (#47495) Thanks @vincentkoc.
|
||||||
- CLI: avoid loading provider discovery during startup model normalization. (#46522) Thanks @ItsAditya-xyz and @vincentkoc.
|
- CLI: avoid loading provider discovery during startup model normalization. (#46522) Thanks @ItsAditya-xyz and @vincentkoc.
|
||||||
- Security/device pairing: harden `device.token.rotate` deny handling by keeping public failures generic while logging internal deny reasons and preserving approved-baseline enforcement. (`GHSA-7jrw-x62h-64p8`)
|
- Security/device pairing: harden `device.token.rotate` deny handling by keeping public failures generic while logging internal deny reasons and preserving approved-baseline enforcement. (`GHSA-7jrw-x62h-64p8`)
|
||||||
- Inbound policy hardening: tighten callback and webhook sender checks across Mattermost and Google Chat, match Nextcloud Talk rooms by stable room token, and treat explicit empty Twitch allowlists as deny-all. (#46787) Thanks @zpbrent, @ijxpwastaken and @vincentkoc.
|
- Inbound policy hardening: tighten callback and webhook sender checks across Mattermost and Google Chat, match Nextcloud Talk rooms by stable room token, and treat explicit empty Twitch allowlists as deny-all. (#46787) Thanks @zpbrent, @ijxpwastaken and @vincentkoc.
|
||||||
- Webhooks/runtime: move auth earlier and tighten pre-auth body limits and timeouts across bundled webhook handlers, including slow-body handling for Mattermost slash commands. Thanks @vincentkoc.
|
- Webhooks/runtime: move auth earlier and tighten pre-auth body limits and timeouts across bundled webhook handlers, including slow-body handling for Mattermost slash commands. (#46802) Thanks @vincentkoc.
|
||||||
- Email/webhook wrapping: sanitize sender and subject metadata before external-content wrapping so metadata fields cannot break the wrapper structure. Thanks @vincentkoc.
|
- Email/webhook wrapping: sanitize sender and subject metadata before external-content wrapping so metadata fields cannot break the wrapper structure. (#46816) Thanks @vincentkoc.
|
||||||
- Tools/apply-patch: revalidate workspace-only delete and directory targets immediately before mutating host paths. Thanks @vincentkoc.
|
- Tools/apply-patch: revalidate workspace-only delete and directory targets immediately before mutating host paths. (#46803) Thanks @vincentkoc.
|
||||||
- Gateway/config views: strip embedded credentials from URL-based endpoint fields before returning read-only account and config snapshots. Thanks @vincentkoc.
|
- Gateway/config views: strip embedded credentials from URL-based endpoint fields before returning read-only account and config snapshots. (#46799) Thanks @vincentkoc.
|
||||||
- ACP/approvals: use canonical tool identity for prompting decisions and fail closed when conflicting tool identity hints are present. (#46817) Thanks @zpbrent and @vincentkoc.
|
- ACP/approvals: use canonical tool identity for prompting decisions and fail closed when conflicting tool identity hints are present. (#46817) Thanks @zpbrent and @vincentkoc.
|
||||||
- ACP: require admin scope for mutating internal actions. (#46789) Thanks @tdjackey and @vincentkoc.
|
- ACP: require admin scope for mutating internal actions. (#46789) Thanks @tdjackey and @vincentkoc.
|
||||||
- Subagents/follow-ups: require the same controller ownership checks for `/subagents send` as other control actions, so leaf sessions cannot message nested child runs they do not control. Thanks @vincentkoc.
|
- Subagents/follow-ups: require the same controller ownership checks for `/subagents send` as other control actions, so leaf sessions cannot message nested child runs they do not control. (#46801) Thanks @vincentkoc.
|
||||||
- macOS/canvas actions: keep unattended local agent actions on trusted in-app canvas surfaces only, and stop exposing the deep-link fallback key to arbitrary page scripts. (#46790) Thanks @vincentkoc.
|
- macOS/canvas actions: keep unattended local agent actions on trusted in-app canvas surfaces only, and stop exposing the deep-link fallback key to arbitrary page scripts. (#46790) Thanks @vincentkoc.
|
||||||
- Agents/compaction: extend the enclosing run deadline once while compaction is actively in flight, and abort the underlying SDK compaction on timeout/cancel so large-session compactions stop freezing mid-run. (#46889) Thanks @asyncjason.
|
- Agents/compaction: extend the enclosing run deadline once while compaction is actively in flight, and abort the underlying SDK compaction on timeout/cancel so large-session compactions stop freezing mid-run. (#46889) Thanks @asyncjason.
|
||||||
- Agents/openai-compatible tool calls: deduplicate repeated tool call ids across live assistant messages and replayed history so OpenAI-compatible backends no longer reject duplicate `tool_call_id` values with HTTP 400. (#40996) Thanks @xaeon2026.
|
- Agents/openai-compatible tool calls: deduplicate repeated tool call ids across live assistant messages and replayed history so OpenAI-compatible backends no longer reject duplicate `tool_call_id` values with HTTP 400. (#40996) Thanks @xaeon2026.
|
||||||
@ -63,35 +66,41 @@ Docs: https://docs.openclaw.ai
|
|||||||
- Models/OpenRouter runtime capabilities: fetch uncatalogued OpenRouter model metadata on first use so newly added vision models keep image input instead of silently degrading to text-only, with top-level capability field fallbacks for `/api/v1/models`. (#45824) Thanks @DJjjjhao.
|
- Models/OpenRouter runtime capabilities: fetch uncatalogued OpenRouter model metadata on first use so newly added vision models keep image input instead of silently degrading to text-only, with top-level capability field fallbacks for `/api/v1/models`. (#45824) Thanks @DJjjjhao.
|
||||||
- Channels/plugins: keep shared interactive payloads merge-ready by fixing Slack custom callback routing and repeat-click dedupe, allowing interactive-only sends, and preserving ordered Discord shared text blocks. (#47715) Thanks @vincentkoc.
|
- Channels/plugins: keep shared interactive payloads merge-ready by fixing Slack custom callback routing and repeat-click dedupe, allowing interactive-only sends, and preserving ordered Discord shared text blocks. (#47715) Thanks @vincentkoc.
|
||||||
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. (#45890) Thanks @vincentkoc.
|
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. (#45890) Thanks @vincentkoc.
|
||||||
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. (#45890) Thanks @vincentkoc.
|
- Feishu/actions: expand the runtime action surface with message read/edit, explicit thread replies, pinning, and operator-facing chat/member inspection so Feishu can operate more of the workspace directly. (#47968) Thanks @Takhoffman.
|
||||||
- Feishu/actions: expand the runtime action surface with message read/edit, explicit thread replies, pinning, and operator-facing chat/member inspection so Feishu can operate more of the workspace directly.
|
|
||||||
- Feishu/topic threads: fetch full thread context, including prior bot replies, when starting a topic-thread session so follow-up turns in Feishu topics keep the right conversation state. (#45254) Thanks @Coobiw.
|
- Feishu/topic threads: fetch full thread context, including prior bot replies, when starting a topic-thread session so follow-up turns in Feishu topics keep the right conversation state. (#45254) Thanks @Coobiw.
|
||||||
- Feishu/media: keep native image, file, audio, and video/media handling aligned across outbound sends, inbound downloads, thread replies, directory/action aliases, and capability docs so unsupported areas are explicit instead of implied.
|
- Feishu/media: keep native image, file, audio, and video/media handling aligned across outbound sends, inbound downloads, thread replies, directory/action aliases, and capability docs so unsupported areas are explicit instead of implied. (#47968) Thanks @Takhoffman.
|
||||||
- WhatsApp/reconnect: restore the append recency filter in the extension inbox monitor and handle protobuf `Long` timestamps correctly, so fresh post-reconnect append messages are processed while stale history sync stays suppressed. (#42588) thanks @MonkeyLeeT.
|
- WhatsApp/reconnect: restore the append recency filter in the extension inbox monitor and handle protobuf `Long` timestamps correctly, so fresh post-reconnect append messages are processed while stale history sync stays suppressed. (#42588) Thanks @MonkeyLeeT.
|
||||||
- WhatsApp/login: wait for pending creds writes before reopening after Baileys `515` pairing restarts in both QR login and `channels login` flows, and keep the restart coverage pinned to the real wrapped error shape plus per-account creds queues. (#27910) Thanks @asyncjason.
|
- WhatsApp/login: wait for pending creds writes before reopening after Baileys `515` pairing restarts in both QR login and `channels login` flows, and keep the restart coverage pinned to the real wrapped error shape plus per-account creds queues. (#27910) Thanks @asyncjason.
|
||||||
- Telegram/message send: forward `--force-document` through the `sendPayload` path as well as `sendMedia`, so Telegram payload sends with `channelData` keep uploading images as documents instead of silently falling back to compressed photo sends. (#47119) Thanks @thepagent.
|
- Telegram/message send: forward `--force-document` through the `sendPayload` path as well as `sendMedia`, so Telegram payload sends with `channelData` keep uploading images as documents instead of silently falling back to compressed photo sends. (#47119) Thanks @thepagent.
|
||||||
- Telegram/message chunking: preserve spaces, paragraph separators, and word boundaries when HTML overflow rechunking splits formatted replies. (#47274)
|
- Telegram/message chunking: preserve spaces, paragraph separators, and word boundaries when HTML overflow rechunking splits formatted replies. (#47274) Thanks @obviyus.
|
||||||
- Z.AI/onboarding: detect a working default model even for explicit `zai-coding-*` endpoint choices, so Coding Plan setup can keep the selected endpoint while defaulting to `glm-5` when available or `glm-4.7` as fallback. (#45969)
|
- Z.AI/onboarding: detect a working default model even for explicit `zai-coding-*` endpoint choices, so Coding Plan setup can keep the selected endpoint while defaulting to `glm-5` when available or `glm-4.7` as fallback. (#45969) Thanks @obviyus.
|
||||||
- Z.AI/onboarding: add `glm-5-turbo` to the default Z.AI provider catalog so onboarding-generated configs expose the new model alongside the existing GLM defaults. (#46670) Thanks @tomsun28.
|
- Z.AI/onboarding: add `glm-5-turbo` to the default Z.AI provider catalog so onboarding-generated configs expose the new model alongside the existing GLM defaults. (#46670) Thanks @tomsun28.
|
||||||
- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146)
|
- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#46663) Fixes #40146. Thanks @Takhoffman.
|
||||||
- Zalo/plugin runtime: export `resolveClientIp` from `openclaw/plugin-sdk/zalo` so installed builds no longer crash on startup when the webhook monitor loads from the packaged extension instead of the monorepo source tree. (#46549) Thanks @No898.
|
- Zalo/plugin runtime: export `resolveClientIp` from `openclaw/plugin-sdk/zalo` so installed builds no longer crash on startup when the webhook monitor loads from the packaged extension instead of the monorepo source tree. (#46549) Thanks @No898.
|
||||||
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`.
|
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`. (#46722) Thanks @Takhoffman.
|
||||||
- Plugins/scoped ids: preserve scoped plugin ids during install and config keying, and keep bundled plugins ahead of discovered duplicate ids by default so `@scope/name` plugins no longer collide with unscoped installs. Thanks @vincentkoc.
|
- Plugins/scoped ids: preserve scoped plugin ids during install and config keying, and keep bundled plugins ahead of discovered duplicate ids by default so `@scope/name` plugins no longer collide with unscoped installs. (#47413) Thanks @vincentkoc.
|
||||||
- Gateway/watch mode: restart on bundled-plugin package and manifest metadata changes, rebuild `dist` for extension source and `tsdown.config.ts` changes, and still ignore extension docs. (#47571) thanks @gumadeiras.
|
- Gateway/watch mode: restart on bundled-plugin package and manifest metadata changes, rebuild `dist` for extension source and `tsdown.config.ts` changes, and still ignore extension docs. (#47571) Thanks @gumadeiras.
|
||||||
- Gateway/watch mode: recreate bundled plugin runtime metadata after clean or stale `dist` states, so `pnpm gateway:watch` no longer fails on missing `dist/extensions/*/openclaw.plugin.json` manifests after a rebuild. Thanks @gumadeiras.
|
- Gateway/watch mode: recreate bundled plugin runtime metadata after clean or stale `dist` states, so `pnpm gateway:watch` no longer fails on missing `dist/extensions/*/openclaw.plugin.json` manifests after a rebuild. Thanks @gumadeiras.
|
||||||
- Control UI/chat sessions: show human-readable labels in the grouped session dropdown again, keep unique scoped fallbacks when metadata is missing, and disambiguate duplicate labels only when needed. (#45130) thanks @luzhidong.
|
- Control UI/chat sessions: show human-readable labels in the grouped session dropdown again, keep unique scoped fallbacks when metadata is missing, and disambiguate duplicate labels only when needed. (#45130) Thanks @luzhidong.
|
||||||
- Control UI: scope persisted session selection per gateway, prevent stale session bleed across tokenized gateway opens, and cap stored gateway session history. (#47453) Thanks @sallyom.
|
- Control UI: scope persisted session selection per gateway, prevent stale session bleed across tokenized gateway opens, and cap stored gateway session history. (#47453) Thanks @sallyom.
|
||||||
- Control UI/dashboard: preserve structured gateway shutdown reasons across restart disconnects so config-triggered restarts no longer fall back to `disconnected (1006): no reason`. (#46532) Thanks @vincentkoc.
|
- Control UI/dashboard: preserve structured gateway shutdown reasons across restart disconnects so config-triggered restarts no longer fall back to `disconnected (1006): no reason`. (#46580) Fixes #46532. Thanks @vincentkoc.
|
||||||
- Android/chat: theme the thinking dropdown and TLS trust dialogs explicitly so popup surfaces match the active app theme instead of falling back to mismatched Material defaults.
|
- Android/chat: theme the thinking dropdown and TLS trust dialogs explicitly so popup surfaces match the active app theme instead of falling back to mismatched Material defaults.
|
||||||
- Group mention gating: reject invalid and unsafe nested-repetition `mentionPatterns`, reuse the shared safe config-regex compiler across mention stripping and detection, and cache strip-time regex compilation so noisy groups avoid repeated recompiles.
|
- Group mention gating: reject invalid and unsafe nested-repetition `mentionPatterns`, reuse the shared safe config-regex compiler across mention stripping and detection, and cache strip-time regex compilation so noisy groups avoid repeated recompiles.
|
||||||
- Browser/profiles: drop the auto-created `chrome-relay` browser profile; users who need the Chrome extension relay must now create their own profile via `openclaw browser create-profile`. (#45777) Thanks @odysseus0.
|
- Browser/profiles: drop the auto-created `chrome-relay` browser profile; users who need the Chrome extension relay must now create their own profile via `openclaw browser create-profile`. (#46596) Fixes #45777. Thanks @odysseus0.
|
||||||
- CI/channel test routing: move the built-in channel suites into `test:channels` and keep them out of `test:extensions`, so extension CI no longer fails after the channel migration while targeted test routing still sends Slack, Signal, and iMessage suites to the right lane. (#46066) Thanks @scoootscooob.
|
- CI/channel test routing: move the built-in channel suites into `test:channels` and keep them out of `test:extensions`, so extension CI no longer fails after the channel migration while targeted test routing still sends Slack, Signal, and iMessage suites to the right lane. (#46066) Thanks @scoootscooob.
|
||||||
- Docs/Mintlify: fix MDX marker syntax on Perplexity, Model Providers, Moonshot, and exec approvals pages so local docs preview no longer breaks rendering or leaves stale pages unpublished. (#46695) Thanks @velvet-shark.
|
- Docs/Mintlify: fix MDX marker syntax on Perplexity, Model Providers, Moonshot, and exec approvals pages so local docs preview no longer breaks rendering or leaves stale pages unpublished. (#46695) Thanks @velvet-shark.
|
||||||
- Gateway/config validation: stop treating the implicit default memory slot as a required explicit plugin config, so startup no longer fails with `plugins.slots.memory: plugin not found: memory-core` when `memory-core` was only inferred. (#47494) Thanks @ngutman.
|
- Gateway/config validation: stop treating the implicit default memory slot as a required explicit plugin config, so startup no longer fails with `plugins.slots.memory: plugin not found: memory-core` when `memory-core` was only inferred. (#47494) Thanks @ngutman.
|
||||||
- Tlon: honor explicit empty allowlists and defer cite expansion. (#46788) Thanks @zpbrent and @vincentkoc.
|
- Tlon: honor explicit empty allowlists and defer cite expansion. (#46788) Thanks @zpbrent and @vincentkoc.
|
||||||
- Nodes/pending actions: re-check queued foreground actions against the current node command policy before returning them to the node. (#46815) Thanks @zpbrent and @vincentkoc.
|
- Nodes/pending actions: re-check queued foreground actions against the current node command policy before returning them to the node. (#46815) Thanks @zpbrent and @vincentkoc.
|
||||||
- Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46411)
|
- Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46515) Fixes #46411. Thanks @ademczuk.
|
||||||
- CLI/completion: reduce recursive completion-script string churn and fix nested PowerShell command-path matching so generated nested completions resolve on PowerShell too. (#45537) Thanks @yiShanXin and @vincentkoc.
|
- CLI/completion: reduce recursive completion-script string churn and fix nested PowerShell command-path matching so generated nested completions resolve on PowerShell too. (#45537) Thanks @yiShanXin and @vincentkoc.
|
||||||
|
- Slack/startup: harden `@slack/bolt` import interop across current bundled runtime shapes so Slack monitors no longer crash with `App is not a constructor` after plugin-sdk bundling changes. (#45953) Thanks @merc1305.
|
||||||
|
- Windows/gateway status: accept `schtasks` `Last Result` output as an alias for `Last Run Result`, so running scheduled-task installs no longer show `Runtime: unknown`. (#47844) Thanks @MoerAI.
|
||||||
|
- ACP/acpx: resolve the bundled plugin root from the actual plugin directory so plugin-local installs stay under `dist/extensions/acpx` instead of escaping to `dist/extensions` and failing runtime setup. (#47601) Thanks @ngutman.
|
||||||
|
- Gateway/websocket pairing bypass for disabled auth: skip device-pairing enforcement for Control UI operator sessions when `gateway.auth.mode=none`, so reverse-proxied dashboards no longer get stuck on `pairing required` despite auth being explicitly disabled. (#47148) Thanks @ademczuk.
|
||||||
|
- Control UI/model switching: preserve the selected provider prefix when switching models from the chat dropdown, so multi-provider setups no longer send `anthropic/gpt-5.2`-style mismatches when the user picked `openai/gpt-5.2`. (#47581) Thanks @chrishham.
|
||||||
|
- Control UI/storage: scope persisted settings keys by gateway base path, with migration from the legacy shared key, so multiple gateways under one domain stop overwriting each other's dashboard preferences. (#47932) Thanks @bobBot-claw.
|
||||||
|
- Agents/usage tracking: stop forcing `supportsUsageInStreaming: false` on non-native OpenAI-completions providers so compatible backends report token usage and cost again instead of showing all zeros. (#46500) Fixes #46142. Thanks @ademczuk.
|
||||||
|
|
||||||
## 2026.3.13
|
## 2026.3.13
|
||||||
|
|
||||||
|
|||||||
@ -89,6 +89,9 @@ Welcome to the lobster tank! 🦞
|
|||||||
|
|
||||||
- Test locally with your OpenClaw instance
|
- Test locally with your OpenClaw instance
|
||||||
- Run tests: `pnpm build && pnpm check && pnpm test`
|
- Run tests: `pnpm build && pnpm check && pnpm test`
|
||||||
|
- For extension/plugin changes, run the fast local lane first:
|
||||||
|
- `pnpm test:extension <extension-name>`
|
||||||
|
- If you changed shared plugin or channel surfaces, still run the broader relevant 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.
|
- 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.
|
||||||
- Ensure CI checks pass
|
- Ensure CI checks pass
|
||||||
- Keep PRs focused (one thing per PR; do not mix unrelated concerns)
|
- Keep PRs focused (one thing per PR; do not mix unrelated concerns)
|
||||||
|
|||||||
@ -18,14 +18,13 @@ import kotlinx.coroutines.launch
|
|||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private val viewModel: MainViewModel by viewModels()
|
private val viewModel: MainViewModel by viewModels()
|
||||||
private lateinit var permissionRequester: PermissionRequester
|
private lateinit var permissionRequester: PermissionRequester
|
||||||
|
private var didAttachRuntimeUi = false
|
||||||
|
private var didStartNodeService = false
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
permissionRequester = PermissionRequester(this)
|
permissionRequester = PermissionRequester(this)
|
||||||
viewModel.camera.attachLifecycleOwner(this)
|
|
||||||
viewModel.camera.attachPermissionRequester(permissionRequester)
|
|
||||||
viewModel.sms.attachPermissionRequester(permissionRequester)
|
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
@ -39,6 +38,20 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
|
viewModel.runtimeInitialized.collect { ready ->
|
||||||
|
if (!ready || didAttachRuntimeUi) return@collect
|
||||||
|
viewModel.attachRuntimeUi(owner = this@MainActivity, permissionRequester = permissionRequester)
|
||||||
|
didAttachRuntimeUi = true
|
||||||
|
if (!didStartNodeService) {
|
||||||
|
NodeForegroundService.start(this@MainActivity)
|
||||||
|
didStartNodeService = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
OpenClawTheme {
|
OpenClawTheme {
|
||||||
Surface(modifier = Modifier) {
|
Surface(modifier = Modifier) {
|
||||||
@ -46,9 +59,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep startup path lean: start foreground service after first frame.
|
|
||||||
window.decorView.post { NodeForegroundService.start(this) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onStart() {
|
override fun onStart() {
|
||||||
|
|||||||
@ -2,209 +2,268 @@ package ai.openclaw.app
|
|||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import ai.openclaw.app.gateway.GatewayEndpoint
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import ai.openclaw.app.chat.ChatMessage
|
||||||
|
import ai.openclaw.app.chat.ChatPendingToolCall
|
||||||
|
import ai.openclaw.app.chat.ChatSessionEntry
|
||||||
import ai.openclaw.app.chat.OutgoingAttachment
|
import ai.openclaw.app.chat.OutgoingAttachment
|
||||||
|
import ai.openclaw.app.gateway.GatewayEndpoint
|
||||||
import ai.openclaw.app.node.CameraCaptureManager
|
import ai.openclaw.app.node.CameraCaptureManager
|
||||||
import ai.openclaw.app.node.CanvasController
|
import ai.openclaw.app.node.CanvasController
|
||||||
import ai.openclaw.app.node.SmsManager
|
import ai.openclaw.app.node.SmsManager
|
||||||
import ai.openclaw.app.voice.VoiceConversationEntry
|
import ai.openclaw.app.voice.VoiceConversationEntry
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class MainViewModel(app: Application) : AndroidViewModel(app) {
|
class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
private val runtime: NodeRuntime = (app as NodeApp).runtime
|
private val nodeApp = app as NodeApp
|
||||||
|
private val prefs = nodeApp.prefs
|
||||||
|
private val runtimeRef = MutableStateFlow<NodeRuntime?>(null)
|
||||||
|
private var foreground = true
|
||||||
|
|
||||||
val canvas: CanvasController = runtime.canvas
|
private fun ensureRuntime(): NodeRuntime {
|
||||||
val canvasCurrentUrl: StateFlow<String?> = runtime.canvas.currentUrl
|
runtimeRef.value?.let { return it }
|
||||||
val canvasA2uiHydrated: StateFlow<Boolean> = runtime.canvasA2uiHydrated
|
val runtime = nodeApp.ensureRuntime()
|
||||||
val canvasRehydratePending: StateFlow<Boolean> = runtime.canvasRehydratePending
|
runtime.setForeground(foreground)
|
||||||
val canvasRehydrateErrorText: StateFlow<String?> = runtime.canvasRehydrateErrorText
|
runtimeRef.value = runtime
|
||||||
val camera: CameraCaptureManager = runtime.camera
|
return runtime
|
||||||
val sms: SmsManager = runtime.sms
|
}
|
||||||
|
|
||||||
val gateways: StateFlow<List<GatewayEndpoint>> = runtime.gateways
|
private fun <T> runtimeState(
|
||||||
val discoveryStatusText: StateFlow<String> = runtime.discoveryStatusText
|
initial: T,
|
||||||
|
selector: (NodeRuntime) -> StateFlow<T>,
|
||||||
|
): StateFlow<T> =
|
||||||
|
runtimeRef
|
||||||
|
.flatMapLatest { runtime -> runtime?.let(selector) ?: flowOf(initial) }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.Eagerly, initial)
|
||||||
|
|
||||||
val isConnected: StateFlow<Boolean> = runtime.isConnected
|
val runtimeInitialized: StateFlow<Boolean> =
|
||||||
val isNodeConnected: StateFlow<Boolean> = runtime.nodeConnected
|
runtimeRef
|
||||||
val statusText: StateFlow<String> = runtime.statusText
|
.flatMapLatest { runtime -> flowOf(runtime != null) }
|
||||||
val serverName: StateFlow<String?> = runtime.serverName
|
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
|
||||||
val remoteAddress: StateFlow<String?> = runtime.remoteAddress
|
|
||||||
val pendingGatewayTrust: StateFlow<NodeRuntime.GatewayTrustPrompt?> = runtime.pendingGatewayTrust
|
|
||||||
val isForeground: StateFlow<Boolean> = runtime.isForeground
|
|
||||||
val seamColorArgb: StateFlow<Long> = runtime.seamColorArgb
|
|
||||||
val mainSessionKey: StateFlow<String> = runtime.mainSessionKey
|
|
||||||
|
|
||||||
val cameraHud: StateFlow<CameraHudState?> = runtime.cameraHud
|
val canvasCurrentUrl: StateFlow<String?> = runtimeState(initial = null) { it.canvas.currentUrl }
|
||||||
val cameraFlashToken: StateFlow<Long> = runtime.cameraFlashToken
|
val canvasA2uiHydrated: StateFlow<Boolean> = runtimeState(initial = false) { it.canvasA2uiHydrated }
|
||||||
|
val canvasRehydratePending: StateFlow<Boolean> = runtimeState(initial = false) { it.canvasRehydratePending }
|
||||||
|
val canvasRehydrateErrorText: StateFlow<String?> = runtimeState(initial = null) { it.canvasRehydrateErrorText }
|
||||||
|
|
||||||
val instanceId: StateFlow<String> = runtime.instanceId
|
val gateways: StateFlow<List<GatewayEndpoint>> = runtimeState(initial = emptyList()) { it.gateways }
|
||||||
val displayName: StateFlow<String> = runtime.displayName
|
val discoveryStatusText: StateFlow<String> = runtimeState(initial = "Searching…") { it.discoveryStatusText }
|
||||||
val cameraEnabled: StateFlow<Boolean> = runtime.cameraEnabled
|
|
||||||
val locationMode: StateFlow<LocationMode> = runtime.locationMode
|
|
||||||
val locationPreciseEnabled: StateFlow<Boolean> = runtime.locationPreciseEnabled
|
|
||||||
val preventSleep: StateFlow<Boolean> = runtime.preventSleep
|
|
||||||
val micEnabled: StateFlow<Boolean> = runtime.micEnabled
|
|
||||||
val micCooldown: StateFlow<Boolean> = runtime.micCooldown
|
|
||||||
val micStatusText: StateFlow<String> = runtime.micStatusText
|
|
||||||
val micLiveTranscript: StateFlow<String?> = runtime.micLiveTranscript
|
|
||||||
val micIsListening: StateFlow<Boolean> = runtime.micIsListening
|
|
||||||
val micQueuedMessages: StateFlow<List<String>> = runtime.micQueuedMessages
|
|
||||||
val micConversation: StateFlow<List<VoiceConversationEntry>> = runtime.micConversation
|
|
||||||
val micInputLevel: StateFlow<Float> = runtime.micInputLevel
|
|
||||||
val micIsSending: StateFlow<Boolean> = runtime.micIsSending
|
|
||||||
val speakerEnabled: StateFlow<Boolean> = runtime.speakerEnabled
|
|
||||||
val manualEnabled: StateFlow<Boolean> = runtime.manualEnabled
|
|
||||||
val manualHost: StateFlow<String> = runtime.manualHost
|
|
||||||
val manualPort: StateFlow<Int> = runtime.manualPort
|
|
||||||
val manualTls: StateFlow<Boolean> = runtime.manualTls
|
|
||||||
val gatewayToken: StateFlow<String> = runtime.gatewayToken
|
|
||||||
val onboardingCompleted: StateFlow<Boolean> = runtime.onboardingCompleted
|
|
||||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = runtime.canvasDebugStatusEnabled
|
|
||||||
|
|
||||||
val chatSessionKey: StateFlow<String> = runtime.chatSessionKey
|
val isConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.isConnected }
|
||||||
val chatSessionId: StateFlow<String?> = runtime.chatSessionId
|
val isNodeConnected: StateFlow<Boolean> = runtimeState(initial = false) { it.nodeConnected }
|
||||||
val chatMessages = runtime.chatMessages
|
val statusText: StateFlow<String> = runtimeState(initial = "Offline") { it.statusText }
|
||||||
val chatError: StateFlow<String?> = runtime.chatError
|
val serverName: StateFlow<String?> = runtimeState(initial = null) { it.serverName }
|
||||||
val chatHealthOk: StateFlow<Boolean> = runtime.chatHealthOk
|
val remoteAddress: StateFlow<String?> = runtimeState(initial = null) { it.remoteAddress }
|
||||||
val chatThinkingLevel: StateFlow<String> = runtime.chatThinkingLevel
|
val pendingGatewayTrust: StateFlow<NodeRuntime.GatewayTrustPrompt?> = runtimeState(initial = null) { it.pendingGatewayTrust }
|
||||||
val chatStreamingAssistantText: StateFlow<String?> = runtime.chatStreamingAssistantText
|
val seamColorArgb: StateFlow<Long> = runtimeState(initial = 0xFF0EA5E9) { it.seamColorArgb }
|
||||||
val chatPendingToolCalls = runtime.chatPendingToolCalls
|
val mainSessionKey: StateFlow<String> = runtimeState(initial = "main") { it.mainSessionKey }
|
||||||
val chatSessions = runtime.chatSessions
|
|
||||||
val pendingRunCount: StateFlow<Int> = runtime.pendingRunCount
|
val cameraHud: StateFlow<CameraHudState?> = runtimeState(initial = null) { it.cameraHud }
|
||||||
|
val cameraFlashToken: StateFlow<Long> = runtimeState(initial = 0L) { it.cameraFlashToken }
|
||||||
|
|
||||||
|
val instanceId: StateFlow<String> = prefs.instanceId
|
||||||
|
val displayName: StateFlow<String> = prefs.displayName
|
||||||
|
val cameraEnabled: StateFlow<Boolean> = prefs.cameraEnabled
|
||||||
|
val locationMode: StateFlow<LocationMode> = prefs.locationMode
|
||||||
|
val locationPreciseEnabled: StateFlow<Boolean> = prefs.locationPreciseEnabled
|
||||||
|
val preventSleep: StateFlow<Boolean> = prefs.preventSleep
|
||||||
|
val manualEnabled: StateFlow<Boolean> = prefs.manualEnabled
|
||||||
|
val manualHost: StateFlow<String> = prefs.manualHost
|
||||||
|
val manualPort: StateFlow<Int> = prefs.manualPort
|
||||||
|
val manualTls: StateFlow<Boolean> = prefs.manualTls
|
||||||
|
val gatewayToken: StateFlow<String> = prefs.gatewayToken
|
||||||
|
val onboardingCompleted: StateFlow<Boolean> = prefs.onboardingCompleted
|
||||||
|
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
|
||||||
|
val speakerEnabled: StateFlow<Boolean> = prefs.speakerEnabled
|
||||||
|
val micEnabled: StateFlow<Boolean> = prefs.talkEnabled
|
||||||
|
|
||||||
|
val micCooldown: StateFlow<Boolean> = runtimeState(initial = false) { it.micCooldown }
|
||||||
|
val micStatusText: StateFlow<String> = runtimeState(initial = "Mic off") { it.micStatusText }
|
||||||
|
val micLiveTranscript: StateFlow<String?> = runtimeState(initial = null) { it.micLiveTranscript }
|
||||||
|
val micIsListening: StateFlow<Boolean> = runtimeState(initial = false) { it.micIsListening }
|
||||||
|
val micQueuedMessages: StateFlow<List<String>> = runtimeState(initial = emptyList()) { it.micQueuedMessages }
|
||||||
|
val micConversation: StateFlow<List<VoiceConversationEntry>> = runtimeState(initial = emptyList()) { it.micConversation }
|
||||||
|
val micInputLevel: StateFlow<Float> = runtimeState(initial = 0f) { it.micInputLevel }
|
||||||
|
val micIsSending: StateFlow<Boolean> = runtimeState(initial = false) { it.micIsSending }
|
||||||
|
|
||||||
|
val chatSessionKey: StateFlow<String> = runtimeState(initial = "main") { it.chatSessionKey }
|
||||||
|
val chatSessionId: StateFlow<String?> = runtimeState(initial = null) { it.chatSessionId }
|
||||||
|
val chatMessages: StateFlow<List<ChatMessage>> = runtimeState(initial = emptyList()) { it.chatMessages }
|
||||||
|
val chatError: StateFlow<String?> = runtimeState(initial = null) { it.chatError }
|
||||||
|
val chatHealthOk: StateFlow<Boolean> = runtimeState(initial = false) { it.chatHealthOk }
|
||||||
|
val chatThinkingLevel: StateFlow<String> = runtimeState(initial = "off") { it.chatThinkingLevel }
|
||||||
|
val chatStreamingAssistantText: StateFlow<String?> = runtimeState(initial = null) { it.chatStreamingAssistantText }
|
||||||
|
val chatPendingToolCalls: StateFlow<List<ChatPendingToolCall>> = runtimeState(initial = emptyList()) { it.chatPendingToolCalls }
|
||||||
|
val chatSessions: StateFlow<List<ChatSessionEntry>> = runtimeState(initial = emptyList()) { it.chatSessions }
|
||||||
|
val pendingRunCount: StateFlow<Int> = runtimeState(initial = 0) { it.pendingRunCount }
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (prefs.onboardingCompleted.value) {
|
||||||
|
ensureRuntime()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val canvas: CanvasController
|
||||||
|
get() = ensureRuntime().canvas
|
||||||
|
|
||||||
|
val camera: CameraCaptureManager
|
||||||
|
get() = ensureRuntime().camera
|
||||||
|
|
||||||
|
val sms: SmsManager
|
||||||
|
get() = ensureRuntime().sms
|
||||||
|
|
||||||
|
fun attachRuntimeUi(owner: LifecycleOwner, permissionRequester: PermissionRequester) {
|
||||||
|
val runtime = runtimeRef.value ?: return
|
||||||
|
runtime.camera.attachLifecycleOwner(owner)
|
||||||
|
runtime.camera.attachPermissionRequester(permissionRequester)
|
||||||
|
runtime.sms.attachPermissionRequester(permissionRequester)
|
||||||
|
}
|
||||||
|
|
||||||
fun setForeground(value: Boolean) {
|
fun setForeground(value: Boolean) {
|
||||||
runtime.setForeground(value)
|
foreground = value
|
||||||
|
runtimeRef.value?.setForeground(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setDisplayName(value: String) {
|
fun setDisplayName(value: String) {
|
||||||
runtime.setDisplayName(value)
|
prefs.setDisplayName(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setCameraEnabled(value: Boolean) {
|
fun setCameraEnabled(value: Boolean) {
|
||||||
runtime.setCameraEnabled(value)
|
prefs.setCameraEnabled(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setLocationMode(mode: LocationMode) {
|
fun setLocationMode(mode: LocationMode) {
|
||||||
runtime.setLocationMode(mode)
|
prefs.setLocationMode(mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setLocationPreciseEnabled(value: Boolean) {
|
fun setLocationPreciseEnabled(value: Boolean) {
|
||||||
runtime.setLocationPreciseEnabled(value)
|
prefs.setLocationPreciseEnabled(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setPreventSleep(value: Boolean) {
|
fun setPreventSleep(value: Boolean) {
|
||||||
runtime.setPreventSleep(value)
|
prefs.setPreventSleep(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setManualEnabled(value: Boolean) {
|
fun setManualEnabled(value: Boolean) {
|
||||||
runtime.setManualEnabled(value)
|
prefs.setManualEnabled(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setManualHost(value: String) {
|
fun setManualHost(value: String) {
|
||||||
runtime.setManualHost(value)
|
prefs.setManualHost(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setManualPort(value: Int) {
|
fun setManualPort(value: Int) {
|
||||||
runtime.setManualPort(value)
|
prefs.setManualPort(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setManualTls(value: Boolean) {
|
fun setManualTls(value: Boolean) {
|
||||||
runtime.setManualTls(value)
|
prefs.setManualTls(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setGatewayToken(value: String) {
|
fun setGatewayToken(value: String) {
|
||||||
runtime.setGatewayToken(value)
|
prefs.setGatewayToken(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setGatewayBootstrapToken(value: String) {
|
fun setGatewayBootstrapToken(value: String) {
|
||||||
runtime.setGatewayBootstrapToken(value)
|
prefs.setGatewayBootstrapToken(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setGatewayPassword(value: String) {
|
fun setGatewayPassword(value: String) {
|
||||||
runtime.setGatewayPassword(value)
|
prefs.setGatewayPassword(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setOnboardingCompleted(value: Boolean) {
|
fun setOnboardingCompleted(value: Boolean) {
|
||||||
runtime.setOnboardingCompleted(value)
|
if (value) {
|
||||||
|
ensureRuntime()
|
||||||
|
}
|
||||||
|
prefs.setOnboardingCompleted(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||||
runtime.setCanvasDebugStatusEnabled(value)
|
prefs.setCanvasDebugStatusEnabled(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setVoiceScreenActive(active: Boolean) {
|
fun setVoiceScreenActive(active: Boolean) {
|
||||||
runtime.setVoiceScreenActive(active)
|
ensureRuntime().setVoiceScreenActive(active)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setMicEnabled(enabled: Boolean) {
|
fun setMicEnabled(enabled: Boolean) {
|
||||||
runtime.setMicEnabled(enabled)
|
ensureRuntime().setMicEnabled(enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSpeakerEnabled(enabled: Boolean) {
|
fun setSpeakerEnabled(enabled: Boolean) {
|
||||||
runtime.setSpeakerEnabled(enabled)
|
ensureRuntime().setSpeakerEnabled(enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshGatewayConnection() {
|
fun refreshGatewayConnection() {
|
||||||
runtime.refreshGatewayConnection()
|
ensureRuntime().refreshGatewayConnection()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun connect(endpoint: GatewayEndpoint) {
|
fun connect(endpoint: GatewayEndpoint) {
|
||||||
runtime.connect(endpoint)
|
ensureRuntime().connect(endpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun connectManual() {
|
fun connectManual() {
|
||||||
runtime.connectManual()
|
ensureRuntime().connectManual()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun disconnect() {
|
fun disconnect() {
|
||||||
runtime.disconnect()
|
runtimeRef.value?.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun acceptGatewayTrustPrompt() {
|
fun acceptGatewayTrustPrompt() {
|
||||||
runtime.acceptGatewayTrustPrompt()
|
runtimeRef.value?.acceptGatewayTrustPrompt()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun declineGatewayTrustPrompt() {
|
fun declineGatewayTrustPrompt() {
|
||||||
runtime.declineGatewayTrustPrompt()
|
runtimeRef.value?.declineGatewayTrustPrompt()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
||||||
runtime.handleCanvasA2UIActionFromWebView(payloadJson)
|
ensureRuntime().handleCanvasA2UIActionFromWebView(payloadJson)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun requestCanvasRehydrate(source: String = "screen_tab") {
|
fun requestCanvasRehydrate(source: String = "screen_tab") {
|
||||||
runtime.requestCanvasRehydrate(source = source, force = true)
|
ensureRuntime().requestCanvasRehydrate(source = source, force = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshHomeCanvasOverviewIfConnected() {
|
fun refreshHomeCanvasOverviewIfConnected() {
|
||||||
runtime.refreshHomeCanvasOverviewIfConnected()
|
ensureRuntime().refreshHomeCanvasOverviewIfConnected()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadChat(sessionKey: String) {
|
fun loadChat(sessionKey: String) {
|
||||||
runtime.loadChat(sessionKey)
|
ensureRuntime().loadChat(sessionKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshChat() {
|
fun refreshChat() {
|
||||||
runtime.refreshChat()
|
ensureRuntime().refreshChat()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refreshChatSessions(limit: Int? = null) {
|
fun refreshChatSessions(limit: Int? = null) {
|
||||||
runtime.refreshChatSessions(limit = limit)
|
ensureRuntime().refreshChatSessions(limit = limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setChatThinkingLevel(level: String) {
|
fun setChatThinkingLevel(level: String) {
|
||||||
runtime.setChatThinkingLevel(level)
|
ensureRuntime().setChatThinkingLevel(level)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun switchChatSession(sessionKey: String) {
|
fun switchChatSession(sessionKey: String) {
|
||||||
runtime.switchChatSession(sessionKey)
|
ensureRuntime().switchChatSession(sessionKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun abortChat() {
|
fun abortChat() {
|
||||||
runtime.abortChat()
|
ensureRuntime().abortChat()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendChat(message: String, thinking: String, attachments: List<OutgoingAttachment>) {
|
fun sendChat(message: String, thinking: String, attachments: List<OutgoingAttachment>) {
|
||||||
runtime.sendChat(message = message, thinking = thinking, attachments = attachments)
|
ensureRuntime().sendChat(message = message, thinking = thinking, attachments = attachments)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,18 @@ import android.app.Application
|
|||||||
import android.os.StrictMode
|
import android.os.StrictMode
|
||||||
|
|
||||||
class NodeApp : Application() {
|
class NodeApp : Application() {
|
||||||
val runtime: NodeRuntime by lazy { NodeRuntime(this) }
|
val prefs: SecurePrefs by lazy { SecurePrefs(this) }
|
||||||
|
|
||||||
|
@Volatile private var runtimeInstance: NodeRuntime? = null
|
||||||
|
|
||||||
|
fun ensureRuntime(): NodeRuntime {
|
||||||
|
runtimeInstance?.let { return it }
|
||||||
|
return synchronized(this) {
|
||||||
|
runtimeInstance ?: NodeRuntime(this, prefs).also { runtimeInstance = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun peekRuntime(): NodeRuntime? = runtimeInstance
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|||||||
@ -28,7 +28,11 @@ class NodeForegroundService : Service() {
|
|||||||
val initial = buildNotification(title = "OpenClaw Node", text = "Starting…")
|
val initial = buildNotification(title = "OpenClaw Node", text = "Starting…")
|
||||||
startForegroundWithTypes(notification = initial)
|
startForegroundWithTypes(notification = initial)
|
||||||
|
|
||||||
val runtime = (application as NodeApp).runtime
|
val runtime = (application as NodeApp).peekRuntime()
|
||||||
|
if (runtime == null) {
|
||||||
|
stopSelf()
|
||||||
|
return
|
||||||
|
}
|
||||||
notificationJob =
|
notificationJob =
|
||||||
scope.launch {
|
scope.launch {
|
||||||
combine(
|
combine(
|
||||||
@ -59,7 +63,7 @@ class NodeForegroundService : Service() {
|
|||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
when (intent?.action) {
|
when (intent?.action) {
|
||||||
ACTION_STOP -> {
|
ACTION_STOP -> {
|
||||||
(application as NodeApp).runtime.disconnect()
|
(application as NodeApp).peekRuntime()?.disconnect()
|
||||||
stopSelf()
|
stopSelf()
|
||||||
return START_NOT_STICKY
|
return START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,11 +43,12 @@ import kotlinx.serialization.json.buildJsonObject
|
|||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
|
||||||
class NodeRuntime(context: Context) {
|
class NodeRuntime(
|
||||||
|
context: Context,
|
||||||
|
val prefs: SecurePrefs = SecurePrefs(context.applicationContext),
|
||||||
|
) {
|
||||||
private val appContext = context.applicationContext
|
private val appContext = context.applicationContext
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
val prefs = SecurePrefs(appContext)
|
|
||||||
private val deviceAuthStore = DeviceAuthStore(prefs)
|
private val deviceAuthStore = DeviceAuthStore(prefs)
|
||||||
val canvas = CanvasController()
|
val canvas = CanvasController()
|
||||||
val camera = CameraCaptureManager(appContext)
|
val camera = CameraCaptureManager(appContext)
|
||||||
|
|||||||
@ -265,7 +265,7 @@ class ChatController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""")
|
val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""")
|
||||||
val history = parseHistory(historyJson, sessionKey = key)
|
val history = parseHistory(historyJson, sessionKey = key, previousMessages = _messages.value)
|
||||||
_messages.value = history.messages
|
_messages.value = history.messages
|
||||||
_sessionId.value = history.sessionId
|
_sessionId.value = history.sessionId
|
||||||
history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it }
|
history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it }
|
||||||
@ -336,7 +336,7 @@ class ChatController(
|
|||||||
try {
|
try {
|
||||||
val historyJson =
|
val historyJson =
|
||||||
session.request("chat.history", """{"sessionKey":"${_sessionKey.value}"}""")
|
session.request("chat.history", """{"sessionKey":"${_sessionKey.value}"}""")
|
||||||
val history = parseHistory(historyJson, sessionKey = _sessionKey.value)
|
val history = parseHistory(historyJson, sessionKey = _sessionKey.value, previousMessages = _messages.value)
|
||||||
_messages.value = history.messages
|
_messages.value = history.messages
|
||||||
_sessionId.value = history.sessionId
|
_sessionId.value = history.sessionId
|
||||||
history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it }
|
history.thinkingLevel?.trim()?.takeIf { it.isNotEmpty() }?.let { _thinkingLevel.value = it }
|
||||||
@ -450,7 +450,11 @@ class ChatController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseHistory(historyJson: String, sessionKey: String): ChatHistory {
|
private fun parseHistory(
|
||||||
|
historyJson: String,
|
||||||
|
sessionKey: String,
|
||||||
|
previousMessages: List<ChatMessage>,
|
||||||
|
): ChatHistory {
|
||||||
val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return ChatHistory(sessionKey, null, null, emptyList())
|
val root = json.parseToJsonElement(historyJson).asObjectOrNull() ?: return ChatHistory(sessionKey, null, null, emptyList())
|
||||||
val sid = root["sessionId"].asStringOrNull()
|
val sid = root["sessionId"].asStringOrNull()
|
||||||
val thinkingLevel = root["thinkingLevel"].asStringOrNull()
|
val thinkingLevel = root["thinkingLevel"].asStringOrNull()
|
||||||
@ -470,7 +474,12 @@ class ChatController(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return ChatHistory(sessionKey = sessionKey, sessionId = sid, thinkingLevel = thinkingLevel, messages = messages)
|
return ChatHistory(
|
||||||
|
sessionKey = sessionKey,
|
||||||
|
sessionId = sid,
|
||||||
|
thinkingLevel = thinkingLevel,
|
||||||
|
messages = reconcileMessageIds(previous = previousMessages, incoming = messages),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseMessageContent(el: JsonElement): ChatMessageContent? {
|
private fun parseMessageContent(el: JsonElement): ChatMessageContent? {
|
||||||
@ -519,6 +528,47 @@ class ChatController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun reconcileMessageIds(previous: List<ChatMessage>, incoming: List<ChatMessage>): List<ChatMessage> {
|
||||||
|
if (previous.isEmpty() || incoming.isEmpty()) return incoming
|
||||||
|
|
||||||
|
val idsByKey = LinkedHashMap<String, ArrayDeque<String>>()
|
||||||
|
for (message in previous) {
|
||||||
|
val key = messageIdentityKey(message) ?: continue
|
||||||
|
idsByKey.getOrPut(key) { ArrayDeque() }.addLast(message.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return incoming.map { message ->
|
||||||
|
val key = messageIdentityKey(message) ?: return@map message
|
||||||
|
val ids = idsByKey[key] ?: return@map message
|
||||||
|
val reusedId = ids.removeFirstOrNull() ?: return@map message
|
||||||
|
if (ids.isEmpty()) {
|
||||||
|
idsByKey.remove(key)
|
||||||
|
}
|
||||||
|
if (reusedId == message.id) return@map message
|
||||||
|
message.copy(id = reusedId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun messageIdentityKey(message: ChatMessage): String? {
|
||||||
|
val role = message.role.trim().lowercase()
|
||||||
|
if (role.isEmpty()) return null
|
||||||
|
|
||||||
|
val timestamp = message.timestampMs?.toString().orEmpty()
|
||||||
|
val contentFingerprint =
|
||||||
|
message.content.joinToString(separator = "\u001E") { part ->
|
||||||
|
listOf(
|
||||||
|
part.type.trim().lowercase(),
|
||||||
|
part.text?.trim().orEmpty(),
|
||||||
|
part.mimeType?.trim()?.lowercase().orEmpty(),
|
||||||
|
part.fileName?.trim().orEmpty(),
|
||||||
|
part.base64?.hashCode()?.toString().orEmpty(),
|
||||||
|
).joinToString(separator = "\u001F")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timestamp.isEmpty() && contentFingerprint.isEmpty()) return null
|
||||||
|
return listOf(role, timestamp, contentFingerprint).joinToString(separator = "|")
|
||||||
|
}
|
||||||
|
|
||||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||||
|
|
||||||
private fun JsonElement?.asArrayOrNull(): JsonArray? = this as? JsonArray
|
private fun JsonElement?.asArrayOrNull(): JsonArray? = this as? JsonArray
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
package ai.openclaw.app.ui.chat
|
package ai.openclaw.app.ui.chat
|
||||||
|
|
||||||
import android.graphics.BitmapFactory
|
|
||||||
import android.util.Base64
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@ -28,8 +26,7 @@ internal fun rememberBase64ImageState(base64: String): Base64ImageState {
|
|||||||
image =
|
image =
|
||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
try {
|
try {
|
||||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
val bitmap = decodeBase64Bitmap(base64) ?: return@withContext null
|
||||||
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null
|
|
||||||
bitmap.asImageBitmap()
|
bitmap.asImageBitmap()
|
||||||
} catch (_: Throwable) {
|
} catch (_: Throwable) {
|
||||||
null
|
null
|
||||||
|
|||||||
@ -0,0 +1,150 @@
|
|||||||
|
package ai.openclaw.app.ui.chat
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Base64
|
||||||
|
import android.util.LruCache
|
||||||
|
import androidx.core.graphics.scale
|
||||||
|
import ai.openclaw.app.node.JpegSizeLimiter
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
private const val CHAT_ATTACHMENT_MAX_WIDTH = 1600
|
||||||
|
private const val CHAT_ATTACHMENT_MAX_BASE64_CHARS = 300 * 1024
|
||||||
|
private const val CHAT_ATTACHMENT_START_QUALITY = 85
|
||||||
|
private const val CHAT_DECODE_MAX_DIMENSION = 1600
|
||||||
|
private const val CHAT_IMAGE_CACHE_BYTES = 16 * 1024 * 1024
|
||||||
|
|
||||||
|
private val decodedBitmapCache =
|
||||||
|
object : LruCache<String, Bitmap>(CHAT_IMAGE_CACHE_BYTES) {
|
||||||
|
override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount.coerceAtLeast(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun loadSizedImageAttachment(resolver: ContentResolver, uri: Uri): PendingImageAttachment {
|
||||||
|
val fileName = normalizeAttachmentFileName((uri.lastPathSegment ?: "image").substringAfterLast('/'))
|
||||||
|
val bitmap = decodeScaledBitmap(resolver, uri, maxDimension = CHAT_ATTACHMENT_MAX_WIDTH)
|
||||||
|
if (bitmap == null) {
|
||||||
|
throw IllegalStateException("unsupported attachment")
|
||||||
|
}
|
||||||
|
val maxBytes = (CHAT_ATTACHMENT_MAX_BASE64_CHARS / 4) * 3
|
||||||
|
val encoded =
|
||||||
|
JpegSizeLimiter.compressToLimit(
|
||||||
|
initialWidth = bitmap.width,
|
||||||
|
initialHeight = bitmap.height,
|
||||||
|
startQuality = CHAT_ATTACHMENT_START_QUALITY,
|
||||||
|
maxBytes = maxBytes,
|
||||||
|
minSize = 240,
|
||||||
|
encode = { width, height, quality ->
|
||||||
|
val working =
|
||||||
|
if (width == bitmap.width && height == bitmap.height) {
|
||||||
|
bitmap
|
||||||
|
} else {
|
||||||
|
bitmap.scale(width, height, true)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
if (!working.compress(Bitmap.CompressFormat.JPEG, quality, out)) {
|
||||||
|
throw IllegalStateException("attachment encode failed")
|
||||||
|
}
|
||||||
|
out.toByteArray()
|
||||||
|
} finally {
|
||||||
|
if (working !== bitmap) {
|
||||||
|
working.recycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val base64 = Base64.encodeToString(encoded.bytes, Base64.NO_WRAP)
|
||||||
|
return PendingImageAttachment(
|
||||||
|
id = uri.toString() + "#" + System.currentTimeMillis().toString(),
|
||||||
|
fileName = fileName,
|
||||||
|
mimeType = "image/jpeg",
|
||||||
|
base64 = base64,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun decodeBase64Bitmap(base64: String, maxDimension: Int = CHAT_DECODE_MAX_DIMENSION): Bitmap? {
|
||||||
|
val cacheKey = "$maxDimension:${base64.length}:${base64.hashCode()}"
|
||||||
|
decodedBitmapCache.get(cacheKey)?.let { return it }
|
||||||
|
|
||||||
|
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||||
|
if (bytes.isEmpty()) return null
|
||||||
|
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||||
|
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||||
|
|
||||||
|
val bitmap =
|
||||||
|
BitmapFactory.decodeByteArray(
|
||||||
|
bytes,
|
||||||
|
0,
|
||||||
|
bytes.size,
|
||||||
|
BitmapFactory.Options().apply {
|
||||||
|
inSampleSize = computeInSampleSize(bounds.outWidth, bounds.outHeight, maxDimension)
|
||||||
|
inPreferredConfig = Bitmap.Config.RGB_565
|
||||||
|
},
|
||||||
|
) ?: return null
|
||||||
|
|
||||||
|
decodedBitmapCache.put(cacheKey, bitmap)
|
||||||
|
return bitmap
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun computeInSampleSize(width: Int, height: Int, maxDimension: Int): Int {
|
||||||
|
if (width <= 0 || height <= 0 || maxDimension <= 0) return 1
|
||||||
|
|
||||||
|
var sample = 1
|
||||||
|
var longestEdge = max(width, height)
|
||||||
|
while (longestEdge > maxDimension && sample < 64) {
|
||||||
|
sample *= 2
|
||||||
|
longestEdge = max(width / sample, height / sample)
|
||||||
|
}
|
||||||
|
return sample.coerceAtLeast(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun normalizeAttachmentFileName(raw: String): String {
|
||||||
|
val trimmed = raw.trim()
|
||||||
|
if (trimmed.isEmpty()) return "image.jpg"
|
||||||
|
val stem = trimmed.substringBeforeLast('.', missingDelimiterValue = trimmed).ifEmpty { "image" }
|
||||||
|
return "$stem.jpg"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeScaledBitmap(
|
||||||
|
resolver: ContentResolver,
|
||||||
|
uri: Uri,
|
||||||
|
maxDimension: Int,
|
||||||
|
): Bitmap? {
|
||||||
|
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||||
|
resolver.openInputStream(uri).use { input ->
|
||||||
|
if (input == null) return null
|
||||||
|
BitmapFactory.decodeStream(input, null, bounds)
|
||||||
|
}
|
||||||
|
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||||
|
|
||||||
|
val decoded =
|
||||||
|
resolver.openInputStream(uri).use { input ->
|
||||||
|
if (input == null) return null
|
||||||
|
BitmapFactory.decodeStream(
|
||||||
|
input,
|
||||||
|
null,
|
||||||
|
BitmapFactory.Options().apply {
|
||||||
|
inSampleSize = computeInSampleSize(bounds.outWidth, bounds.outHeight, maxDimension)
|
||||||
|
inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} ?: return null
|
||||||
|
|
||||||
|
val longestEdge = max(decoded.width, decoded.height)
|
||||||
|
if (longestEdge <= maxDimension) return decoded
|
||||||
|
|
||||||
|
val scale = maxDimension.toDouble() / longestEdge.toDouble()
|
||||||
|
val targetWidth = max(1, (decoded.width * scale).roundToInt())
|
||||||
|
val targetHeight = max(1, (decoded.height * scale).roundToInt())
|
||||||
|
val scaled = decoded.scale(targetWidth, targetHeight, true)
|
||||||
|
if (scaled !== decoded) {
|
||||||
|
decoded.recycle()
|
||||||
|
}
|
||||||
|
return scaled
|
||||||
|
}
|
||||||
@ -6,12 +6,14 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@ -34,11 +36,19 @@ fun ChatMessageListCard(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
val displayMessages = remember(messages) { messages.asReversed() }
|
||||||
|
val stream = streamingAssistantText?.trim()
|
||||||
|
|
||||||
// With reverseLayout the newest item is at index 0 (bottom of screen).
|
// New list items/tool rows should animate into view, but token streaming should not restart
|
||||||
LaunchedEffect(messages.size, pendingRunCount, pendingToolCalls.size, streamingAssistantText) {
|
// that animation on every delta.
|
||||||
|
LaunchedEffect(messages.size, pendingRunCount, pendingToolCalls.size) {
|
||||||
listState.animateScrollToItem(index = 0)
|
listState.animateScrollToItem(index = 0)
|
||||||
}
|
}
|
||||||
|
LaunchedEffect(stream) {
|
||||||
|
if (!stream.isNullOrEmpty()) {
|
||||||
|
listState.scrollToItem(index = 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Box(modifier = modifier.fillMaxWidth()) {
|
Box(modifier = modifier.fillMaxWidth()) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
@ -50,8 +60,6 @@ fun ChatMessageListCard(
|
|||||||
) {
|
) {
|
||||||
// With reverseLayout = true, index 0 renders at the BOTTOM.
|
// With reverseLayout = true, index 0 renders at the BOTTOM.
|
||||||
// So we emit newest items first: streaming → tools → typing → messages (newest→oldest).
|
// So we emit newest items first: streaming → tools → typing → messages (newest→oldest).
|
||||||
|
|
||||||
val stream = streamingAssistantText?.trim()
|
|
||||||
if (!stream.isNullOrEmpty()) {
|
if (!stream.isNullOrEmpty()) {
|
||||||
item(key = "stream") {
|
item(key = "stream") {
|
||||||
ChatStreamingAssistantBubble(text = stream)
|
ChatStreamingAssistantBubble(text = stream)
|
||||||
@ -70,8 +78,8 @@ fun ChatMessageListCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
items(count = messages.size, key = { idx -> messages[messages.size - 1 - idx].id }) { idx ->
|
items(items = displayMessages, key = { it.id }) { message ->
|
||||||
ChatMessageBubble(message = messages[messages.size - 1 - idx])
|
ChatMessageBubble(message = message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,5 @@
|
|||||||
package ai.openclaw.app.ui.chat
|
package ai.openclaw.app.ui.chat
|
||||||
|
|
||||||
import android.content.ContentResolver
|
|
||||||
import android.net.Uri
|
|
||||||
import android.util.Base64
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
@ -47,7 +44,6 @@ import ai.openclaw.app.ui.mobileDanger
|
|||||||
import ai.openclaw.app.ui.mobileDangerSoft
|
import ai.openclaw.app.ui.mobileDangerSoft
|
||||||
import ai.openclaw.app.ui.mobileText
|
import ai.openclaw.app.ui.mobileText
|
||||||
import ai.openclaw.app.ui.mobileTextSecondary
|
import ai.openclaw.app.ui.mobileTextSecondary
|
||||||
import java.io.ByteArrayOutputStream
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@ -83,7 +79,7 @@ fun ChatSheetContent(viewModel: MainViewModel) {
|
|||||||
val next =
|
val next =
|
||||||
uris.take(8).mapNotNull { uri ->
|
uris.take(8).mapNotNull { uri ->
|
||||||
try {
|
try {
|
||||||
loadImageAttachment(resolver, uri)
|
loadSizedImageAttachment(resolver, uri)
|
||||||
} catch (_: Throwable) {
|
} catch (_: Throwable) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
@ -160,7 +156,10 @@ private fun ChatThreadSelector(
|
|||||||
mainSessionKey: String,
|
mainSessionKey: String,
|
||||||
onSelectSession: (String) -> Unit,
|
onSelectSession: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey)
|
val sessionOptions =
|
||||||
|
remember(sessionKey, sessions, mainSessionKey) {
|
||||||
|
resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey)
|
||||||
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||||
@ -214,24 +213,3 @@ data class PendingImageAttachment(
|
|||||||
val mimeType: String,
|
val mimeType: String,
|
||||||
val base64: String,
|
val base64: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
private suspend fun loadImageAttachment(resolver: ContentResolver, uri: Uri): PendingImageAttachment {
|
|
||||||
val mimeType = resolver.getType(uri) ?: "image/*"
|
|
||||||
val fileName = (uri.lastPathSegment ?: "image").substringAfterLast('/')
|
|
||||||
val bytes =
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
resolver.openInputStream(uri)?.use { input ->
|
|
||||||
val out = ByteArrayOutputStream()
|
|
||||||
input.copyTo(out)
|
|
||||||
out.toByteArray()
|
|
||||||
} ?: ByteArray(0)
|
|
||||||
}
|
|
||||||
if (bytes.isEmpty()) throw IllegalStateException("empty attachment")
|
|
||||||
val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
|
|
||||||
return PendingImageAttachment(
|
|
||||||
id = uri.toString() + "#" + System.currentTimeMillis().toString(),
|
|
||||||
fileName = fileName,
|
|
||||||
mimeType = mimeType,
|
|
||||||
base64 = base64,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -0,0 +1,81 @@
|
|||||||
|
package ai.openclaw.app.chat
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ChatControllerMessageIdentityTest {
|
||||||
|
@Test
|
||||||
|
fun reconcileMessageIdsReusesMatchingIdsAcrossHistoryReload() {
|
||||||
|
val previous =
|
||||||
|
listOf(
|
||||||
|
ChatMessage(
|
||||||
|
id = "msg-1",
|
||||||
|
role = "assistant",
|
||||||
|
content = listOf(ChatMessageContent(type = "text", text = "hello")),
|
||||||
|
timestampMs = 1000L,
|
||||||
|
),
|
||||||
|
ChatMessage(
|
||||||
|
id = "msg-2",
|
||||||
|
role = "user",
|
||||||
|
content = listOf(ChatMessageContent(type = "text", text = "hi")),
|
||||||
|
timestampMs = 2000L,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val incoming =
|
||||||
|
listOf(
|
||||||
|
ChatMessage(
|
||||||
|
id = "new-1",
|
||||||
|
role = "assistant",
|
||||||
|
content = listOf(ChatMessageContent(type = "text", text = "hello")),
|
||||||
|
timestampMs = 1000L,
|
||||||
|
),
|
||||||
|
ChatMessage(
|
||||||
|
id = "new-2",
|
||||||
|
role = "user",
|
||||||
|
content = listOf(ChatMessageContent(type = "text", text = "hi")),
|
||||||
|
timestampMs = 2000L,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val reconciled = reconcileMessageIds(previous = previous, incoming = incoming)
|
||||||
|
|
||||||
|
assertEquals(listOf("msg-1", "msg-2"), reconciled.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reconcileMessageIdsLeavesNewMessagesUntouched() {
|
||||||
|
val previous =
|
||||||
|
listOf(
|
||||||
|
ChatMessage(
|
||||||
|
id = "msg-1",
|
||||||
|
role = "assistant",
|
||||||
|
content = listOf(ChatMessageContent(type = "text", text = "hello")),
|
||||||
|
timestampMs = 1000L,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val incoming =
|
||||||
|
listOf(
|
||||||
|
ChatMessage(
|
||||||
|
id = "new-1",
|
||||||
|
role = "assistant",
|
||||||
|
content = listOf(ChatMessageContent(type = "text", text = "hello")),
|
||||||
|
timestampMs = 1000L,
|
||||||
|
),
|
||||||
|
ChatMessage(
|
||||||
|
id = "new-2",
|
||||||
|
role = "assistant",
|
||||||
|
content = listOf(ChatMessageContent(type = "text", text = "new reply")),
|
||||||
|
timestampMs = 3000L,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val reconciled = reconcileMessageIds(previous = previous, incoming = incoming)
|
||||||
|
|
||||||
|
assertEquals("msg-1", reconciled[0].id)
|
||||||
|
assertEquals("new-2", reconciled[1].id)
|
||||||
|
assertNotEquals(reconciled[0].id, reconciled[1].id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
package ai.openclaw.app.ui.chat
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ChatImageCodecTest {
|
||||||
|
@Test
|
||||||
|
fun computeInSampleSizeCapsLongestEdge() {
|
||||||
|
assertEquals(4, computeInSampleSize(width = 4032, height = 3024, maxDimension = 1600))
|
||||||
|
assertEquals(1, computeInSampleSize(width = 800, height = 600, maxDimension = 1600))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun normalizeAttachmentFileNameForcesJpegExtension() {
|
||||||
|
assertEquals("photo.jpg", normalizeAttachmentFileName("photo.png"))
|
||||||
|
assertEquals("image.jpg", normalizeAttachmentFileName(""))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8035,7 +8035,21 @@
|
|||||||
"storage"
|
"storage"
|
||||||
],
|
],
|
||||||
"label": "Browser Profile Driver",
|
"label": "Browser Profile Driver",
|
||||||
"help": "Per-profile browser driver mode. Use \"openclaw\" (or legacy \"clawd\") for CDP-based profiles, or use \"existing-session\" for host-local Chrome MCP attachment.",
|
"help": "Per-profile browser driver mode. Use \"openclaw\" (or legacy \"clawd\") for CDP-based profiles, or use \"existing-session\" for host-local Chrome DevTools MCP attachment.",
|
||||||
|
"hasChildren": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "browser.profiles.*.userDataDir",
|
||||||
|
"kind": "core",
|
||||||
|
"type": "string",
|
||||||
|
"required": false,
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [
|
||||||
|
"storage"
|
||||||
|
],
|
||||||
|
"label": "Browser Profile User Data Dir",
|
||||||
|
"help": "Per-profile Chromium user data directory for existing-session attachment through Chrome DevTools MCP. Use this for host-local Brave, Edge, Chromium, or non-default Chrome profiles when the built-in auto-connect path would pick the wrong browser data directory.",
|
||||||
"hasChildren": false
|
"hasChildren": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -32140,6 +32154,16 @@
|
|||||||
"tags": [],
|
"tags": [],
|
||||||
"hasChildren": false
|
"hasChildren": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "channels.telegram.accounts.*.silentErrorReplies",
|
||||||
|
"kind": "channel",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": false,
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [],
|
||||||
|
"hasChildren": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "channels.telegram.accounts.*.streaming",
|
"path": "channels.telegram.accounts.*.streaming",
|
||||||
"kind": "channel",
|
"kind": "channel",
|
||||||
@ -34137,6 +34161,21 @@
|
|||||||
"help": "Minimum retry delay in ms for Telegram outbound calls.",
|
"help": "Minimum retry delay in ms for Telegram outbound calls.",
|
||||||
"hasChildren": false
|
"hasChildren": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "channels.telegram.silentErrorReplies",
|
||||||
|
"kind": "channel",
|
||||||
|
"type": "boolean",
|
||||||
|
"required": false,
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [
|
||||||
|
"channels",
|
||||||
|
"network"
|
||||||
|
],
|
||||||
|
"label": "Telegram Silent Error Replies",
|
||||||
|
"help": "When true, Telegram bot replies marked as errors are sent silently (no notification sound). Default: false.",
|
||||||
|
"hasChildren": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "channels.telegram.streaming",
|
"path": "channels.telegram.streaming",
|
||||||
"kind": "channel",
|
"kind": "channel",
|
||||||
@ -51938,6 +51977,48 @@
|
|||||||
"help": "Resolved npm dist integrity hash for the fetched artifact (if reported by npm).",
|
"help": "Resolved npm dist integrity hash for the fetched artifact (if reported by npm).",
|
||||||
"hasChildren": false
|
"hasChildren": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins.installs.*.marketplaceName",
|
||||||
|
"kind": "core",
|
||||||
|
"type": "string",
|
||||||
|
"required": false,
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [
|
||||||
|
"advanced"
|
||||||
|
],
|
||||||
|
"label": "Plugin Marketplace Name",
|
||||||
|
"help": "Marketplace display name recorded for marketplace-backed plugin installs (if available).",
|
||||||
|
"hasChildren": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins.installs.*.marketplacePlugin",
|
||||||
|
"kind": "core",
|
||||||
|
"type": "string",
|
||||||
|
"required": false,
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [
|
||||||
|
"advanced"
|
||||||
|
],
|
||||||
|
"label": "Plugin Marketplace Plugin",
|
||||||
|
"help": "Plugin entry name inside the source marketplace, used for later updates.",
|
||||||
|
"hasChildren": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "plugins.installs.*.marketplaceSource",
|
||||||
|
"kind": "core",
|
||||||
|
"type": "string",
|
||||||
|
"required": false,
|
||||||
|
"deprecated": false,
|
||||||
|
"sensitive": false,
|
||||||
|
"tags": [
|
||||||
|
"advanced"
|
||||||
|
],
|
||||||
|
"label": "Plugin Marketplace Source",
|
||||||
|
"help": "Original marketplace source used to resolve the install (for example a repo path or Git URL).",
|
||||||
|
"hasChildren": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "plugins.installs.*.resolvedAt",
|
"path": "plugins.installs.*.resolvedAt",
|
||||||
"kind": "core",
|
"kind": "core",
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5098}
|
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5104}
|
||||||
{"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true}
|
{"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true}
|
||||||
{"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true}
|
{"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true}
|
||||||
{"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
@ -707,7 +707,8 @@
|
|||||||
{"recordType":"path","path":"browser.profiles.*.cdpPort","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile CDP Port","help":"Per-profile local CDP port used when connecting to browser instances by port instead of URL. Use unique ports per profile to avoid connection collisions.","hasChildren":false}
|
{"recordType":"path","path":"browser.profiles.*.cdpPort","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile CDP Port","help":"Per-profile local CDP port used when connecting to browser instances by port instead of URL. Use unique ports per profile to avoid connection collisions.","hasChildren":false}
|
||||||
{"recordType":"path","path":"browser.profiles.*.cdpUrl","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile CDP URL","help":"Per-profile CDP websocket URL used for explicit remote browser routing by profile name. Use this when profile connections terminate on remote hosts or tunnels.","hasChildren":false}
|
{"recordType":"path","path":"browser.profiles.*.cdpUrl","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile CDP URL","help":"Per-profile CDP websocket URL used for explicit remote browser routing by profile name. Use this when profile connections terminate on remote hosts or tunnels.","hasChildren":false}
|
||||||
{"recordType":"path","path":"browser.profiles.*.color","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile Accent Color","help":"Per-profile accent color for visual differentiation in dashboards and browser-related UI hints. Use distinct colors for high-signal operator recognition of active profiles.","hasChildren":false}
|
{"recordType":"path","path":"browser.profiles.*.color","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile Accent Color","help":"Per-profile accent color for visual differentiation in dashboards and browser-related UI hints. Use distinct colors for high-signal operator recognition of active profiles.","hasChildren":false}
|
||||||
{"recordType":"path","path":"browser.profiles.*.driver","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile Driver","help":"Per-profile browser driver mode. Use \"openclaw\" (or legacy \"clawd\") for CDP-based profiles, or use \"existing-session\" for host-local Chrome MCP attachment.","hasChildren":false}
|
{"recordType":"path","path":"browser.profiles.*.driver","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile Driver","help":"Per-profile browser driver mode. Use \"openclaw\" (or legacy \"clawd\") for CDP-based profiles, or use \"existing-session\" for host-local Chrome DevTools MCP attachment.","hasChildren":false}
|
||||||
|
{"recordType":"path","path":"browser.profiles.*.userDataDir","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Browser Profile User Data Dir","help":"Per-profile Chromium user data directory for existing-session attachment through Chrome DevTools MCP. Use this for host-local Brave, Edge, Chromium, or non-default Chrome profiles when the built-in auto-connect path would pick the wrong browser data directory.","hasChildren":false}
|
||||||
{"recordType":"path","path":"browser.remoteCdpHandshakeTimeoutMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Remote CDP Handshake Timeout (ms)","help":"Timeout in milliseconds for post-connect CDP handshake readiness checks against remote browser targets. Raise this for slow-start remote browsers and lower to fail fast in automation loops.","hasChildren":false}
|
{"recordType":"path","path":"browser.remoteCdpHandshakeTimeoutMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Remote CDP Handshake Timeout (ms)","help":"Timeout in milliseconds for post-connect CDP handshake readiness checks against remote browser targets. Raise this for slow-start remote browsers and lower to fail fast in automation loops.","hasChildren":false}
|
||||||
{"recordType":"path","path":"browser.remoteCdpTimeoutMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Remote CDP Timeout (ms)","help":"Timeout in milliseconds for connecting to a remote CDP endpoint before failing the browser attach attempt. Increase for high-latency tunnels, or lower for faster failure detection.","hasChildren":false}
|
{"recordType":"path","path":"browser.remoteCdpTimeoutMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Remote CDP Timeout (ms)","help":"Timeout in milliseconds for connecting to a remote CDP endpoint before failing the browser attach attempt. Increase for high-latency tunnels, or lower for faster failure detection.","hasChildren":false}
|
||||||
{"recordType":"path","path":"browser.snapshotDefaults","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Browser Snapshot Defaults","help":"Default snapshot capture configuration used when callers do not provide explicit snapshot options. Tune this for consistent capture behavior across channels and automation paths.","hasChildren":true}
|
{"recordType":"path","path":"browser.snapshotDefaults","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Browser Snapshot Defaults","help":"Default snapshot capture configuration used when callers do not provide explicit snapshot options. Tune this for consistent capture behavior across channels and automation paths.","hasChildren":true}
|
||||||
@ -2903,6 +2904,7 @@
|
|||||||
{"recordType":"path","path":"channels.telegram.accounts.*.retry.jitter","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.telegram.accounts.*.retry.jitter","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.telegram.accounts.*.retry.maxDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.telegram.accounts.*.retry.maxDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.telegram.accounts.*.retry.minDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.telegram.accounts.*.retry.minDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
|
{"recordType":"path","path":"channels.telegram.accounts.*.silentErrorReplies","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.telegram.accounts.*.streaming","kind":"channel","type":["boolean","string"],"required":false,"enumValues":["off","partial","block","progress"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.telegram.accounts.*.streaming","kind":"channel","type":["boolean","string"],"required":false,"enumValues":["off","partial","block","progress"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.telegram.accounts.*.streamMode","kind":"channel","type":"string","required":false,"enumValues":["off","partial","block"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.telegram.accounts.*.streamMode","kind":"channel","type":"string","required":false,"enumValues":["off","partial","block"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.telegram.accounts.*.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.telegram.accounts.*.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
@ -3080,6 +3082,7 @@
|
|||||||
{"recordType":"path","path":"channels.telegram.retry.jitter","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network","reliability"],"label":"Telegram Retry Jitter","help":"Jitter factor (0-1) applied to Telegram retry delays.","hasChildren":false}
|
{"recordType":"path","path":"channels.telegram.retry.jitter","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network","reliability"],"label":"Telegram Retry Jitter","help":"Jitter factor (0-1) applied to Telegram retry delays.","hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.telegram.retry.maxDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network","performance","reliability"],"label":"Telegram Retry Max Delay (ms)","help":"Maximum retry delay cap in ms for Telegram outbound calls.","hasChildren":false}
|
{"recordType":"path","path":"channels.telegram.retry.maxDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network","performance","reliability"],"label":"Telegram Retry Max Delay (ms)","help":"Maximum retry delay cap in ms for Telegram outbound calls.","hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.telegram.retry.minDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network","reliability"],"label":"Telegram Retry Min Delay (ms)","help":"Minimum retry delay in ms for Telegram outbound calls.","hasChildren":false}
|
{"recordType":"path","path":"channels.telegram.retry.minDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network","reliability"],"label":"Telegram Retry Min Delay (ms)","help":"Minimum retry delay in ms for Telegram outbound calls.","hasChildren":false}
|
||||||
|
{"recordType":"path","path":"channels.telegram.silentErrorReplies","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Silent Error Replies","help":"When true, Telegram bot replies marked as errors are sent silently (no notification sound). Default: false.","hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.telegram.streaming","kind":"channel","type":["boolean","string"],"required":false,"enumValues":["off","partial","block","progress"],"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Streaming Mode","help":"Unified Telegram stream preview mode: \"off\" | \"partial\" | \"block\" | \"progress\" (default: \"partial\"). \"progress\" maps to \"partial\" on Telegram. Legacy boolean/streamMode keys are auto-mapped.","hasChildren":false}
|
{"recordType":"path","path":"channels.telegram.streaming","kind":"channel","type":["boolean","string"],"required":false,"enumValues":["off","partial","block","progress"],"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Telegram Streaming Mode","help":"Unified Telegram stream preview mode: \"off\" | \"partial\" | \"block\" | \"progress\" (default: \"partial\"). \"progress\" maps to \"partial\" on Telegram. Legacy boolean/streamMode keys are auto-mapped.","hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.telegram.streamMode","kind":"channel","type":"string","required":false,"enumValues":["off","partial","block"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.telegram.streamMode","kind":"channel","type":"string","required":false,"enumValues":["off","partial","block"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
{"recordType":"path","path":"channels.telegram.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
{"recordType":"path","path":"channels.telegram.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||||
@ -4506,6 +4509,9 @@
|
|||||||
{"recordType":"path","path":"plugins.installs.*.installedAt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Install Time","help":"ISO timestamp of last install/update.","hasChildren":false}
|
{"recordType":"path","path":"plugins.installs.*.installedAt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Install Time","help":"ISO timestamp of last install/update.","hasChildren":false}
|
||||||
{"recordType":"path","path":"plugins.installs.*.installPath","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Plugin Install Path","help":"Resolved install directory (usually ~/.openclaw/extensions/<id>).","hasChildren":false}
|
{"recordType":"path","path":"plugins.installs.*.installPath","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Plugin Install Path","help":"Resolved install directory (usually ~/.openclaw/extensions/<id>).","hasChildren":false}
|
||||||
{"recordType":"path","path":"plugins.installs.*.integrity","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Resolved Integrity","help":"Resolved npm dist integrity hash for the fetched artifact (if reported by npm).","hasChildren":false}
|
{"recordType":"path","path":"plugins.installs.*.integrity","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Resolved Integrity","help":"Resolved npm dist integrity hash for the fetched artifact (if reported by npm).","hasChildren":false}
|
||||||
|
{"recordType":"path","path":"plugins.installs.*.marketplaceName","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Marketplace Name","help":"Marketplace display name recorded for marketplace-backed plugin installs (if available).","hasChildren":false}
|
||||||
|
{"recordType":"path","path":"plugins.installs.*.marketplacePlugin","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Marketplace Plugin","help":"Plugin entry name inside the source marketplace, used for later updates.","hasChildren":false}
|
||||||
|
{"recordType":"path","path":"plugins.installs.*.marketplaceSource","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Marketplace Source","help":"Original marketplace source used to resolve the install (for example a repo path or Git URL).","hasChildren":false}
|
||||||
{"recordType":"path","path":"plugins.installs.*.resolvedAt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Resolution Time","help":"ISO timestamp when npm package metadata was last resolved for this install record.","hasChildren":false}
|
{"recordType":"path","path":"plugins.installs.*.resolvedAt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Resolution Time","help":"ISO timestamp when npm package metadata was last resolved for this install record.","hasChildren":false}
|
||||||
{"recordType":"path","path":"plugins.installs.*.resolvedName","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Resolved Package Name","help":"Resolved npm package name from the fetched artifact.","hasChildren":false}
|
{"recordType":"path","path":"plugins.installs.*.resolvedName","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Resolved Package Name","help":"Resolved npm package name from the fetched artifact.","hasChildren":false}
|
||||||
{"recordType":"path","path":"plugins.installs.*.resolvedSpec","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Resolved Package Spec","help":"Resolved exact npm spec (<name>@<version>) from the fetched artifact.","hasChildren":false}
|
{"recordType":"path","path":"plugins.installs.*.resolvedSpec","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Resolved Package Spec","help":"Resolved exact npm spec (<name>@<version>) from the fetched artifact.","hasChildren":false}
|
||||||
|
|||||||
@ -91,6 +91,7 @@ Use the built-in `user` profile, or create your own `existing-session` profile:
|
|||||||
```bash
|
```bash
|
||||||
openclaw browser --browser-profile user tabs
|
openclaw browser --browser-profile user tabs
|
||||||
openclaw browser create-profile --name chrome-live --driver existing-session
|
openclaw browser create-profile --name chrome-live --driver existing-session
|
||||||
|
openclaw browser create-profile --name brave-live --driver existing-session --user-data-dir "~/Library/Application Support/BraveSoftware/Brave-Browser"
|
||||||
openclaw browser --browser-profile chrome-live tabs
|
openclaw browser --browser-profile chrome-live tabs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -284,7 +284,8 @@ Manage extensions and their config:
|
|||||||
|
|
||||||
- `openclaw plugins list` — discover plugins (use `--json` for machine output).
|
- `openclaw plugins list` — discover plugins (use `--json` for machine output).
|
||||||
- `openclaw plugins info <id>` — show details for a plugin.
|
- `openclaw plugins info <id>` — show details for a plugin.
|
||||||
- `openclaw plugins install <path|.tgz|npm-spec>` — install a plugin (or add a plugin path to `plugins.load.paths`).
|
- `openclaw plugins install <path|.tgz|npm-spec|plugin@marketplace>` — install a plugin (or add a plugin path to `plugins.load.paths`).
|
||||||
|
- `openclaw plugins marketplace list <marketplace>` — list marketplace entries before install.
|
||||||
- `openclaw plugins enable <id>` / `disable <id>` — toggle `plugins.entries.<id>.enabled`.
|
- `openclaw plugins enable <id>` / `disable <id>` — toggle `plugins.entries.<id>.enabled`.
|
||||||
- `openclaw plugins doctor` — report plugin load errors.
|
- `openclaw plugins doctor` — report plugin load errors.
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
summary: "CLI reference for `openclaw plugins` (list, install, uninstall, enable/disable, doctor)"
|
summary: "CLI reference for `openclaw plugins` (list, install, marketplace, uninstall, enable/disable, doctor)"
|
||||||
read_when:
|
read_when:
|
||||||
- You want to install or manage Gateway plugins or compatible bundles
|
- You want to install or manage Gateway plugins or compatible bundles
|
||||||
- You want to debug plugin load failures
|
- You want to debug plugin load failures
|
||||||
@ -28,6 +28,7 @@ openclaw plugins uninstall <id>
|
|||||||
openclaw plugins doctor
|
openclaw plugins doctor
|
||||||
openclaw plugins update <id>
|
openclaw plugins update <id>
|
||||||
openclaw plugins update --all
|
openclaw plugins update --all
|
||||||
|
openclaw plugins marketplace list <marketplace>
|
||||||
```
|
```
|
||||||
|
|
||||||
Bundled plugins ship with OpenClaw but start disabled. Use `plugins enable` to
|
Bundled plugins ship with OpenClaw but start disabled. Use `plugins enable` to
|
||||||
@ -46,6 +47,8 @@ capabilities.
|
|||||||
```bash
|
```bash
|
||||||
openclaw plugins install <path-or-spec>
|
openclaw plugins install <path-or-spec>
|
||||||
openclaw plugins install <npm-spec> --pin
|
openclaw plugins install <npm-spec> --pin
|
||||||
|
openclaw plugins install <plugin>@<marketplace>
|
||||||
|
openclaw plugins install <plugin> --marketplace <marketplace>
|
||||||
```
|
```
|
||||||
|
|
||||||
Security note: treat plugin installs like running code. Prefer pinned versions.
|
Security note: treat plugin installs like running code. Prefer pinned versions.
|
||||||
@ -65,6 +68,31 @@ name, use an explicit scoped spec (for example `@scope/diffs`).
|
|||||||
|
|
||||||
Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`.
|
Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`.
|
||||||
|
|
||||||
|
Claude marketplace installs are also supported.
|
||||||
|
|
||||||
|
Use `plugin@marketplace` shorthand when the marketplace name exists in Claude's
|
||||||
|
local registry cache at `~/.claude/plugins/known_marketplaces.json`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw plugins marketplace list <marketplace-name>
|
||||||
|
openclaw plugins install <plugin-name>@<marketplace-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `--marketplace` when you want to pass the marketplace source explicitly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw plugins install <plugin-name> --marketplace <marketplace-name>
|
||||||
|
openclaw plugins install <plugin-name> --marketplace <owner/repo>
|
||||||
|
openclaw plugins install <plugin-name> --marketplace ./my-marketplace
|
||||||
|
```
|
||||||
|
|
||||||
|
Marketplace sources can be:
|
||||||
|
|
||||||
|
- a Claude known-marketplace name from `~/.claude/plugins/known_marketplaces.json`
|
||||||
|
- a local marketplace root or `marketplace.json` path
|
||||||
|
- a GitHub repo shorthand such as `owner/repo`
|
||||||
|
- a git URL
|
||||||
|
|
||||||
For local paths and archives, OpenClaw auto-detects:
|
For local paths and archives, OpenClaw auto-detects:
|
||||||
|
|
||||||
- native OpenClaw plugins (`openclaw.plugin.json`)
|
- native OpenClaw plugins (`openclaw.plugin.json`)
|
||||||
@ -114,7 +142,8 @@ openclaw plugins update --all
|
|||||||
openclaw plugins update <id> --dry-run
|
openclaw plugins update <id> --dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
Updates only apply to plugins installed from npm (tracked in `plugins.installs`).
|
Updates apply to tracked installs in `plugins.installs`, currently npm and
|
||||||
|
marketplace installs.
|
||||||
|
|
||||||
When a stored integrity hash exists and the fetched artifact hash changes,
|
When a stored integrity hash exists and the fetched artifact hash changes,
|
||||||
OpenClaw prints a warning and asks for confirmation before proceeding. Use
|
OpenClaw prints a warning and asks for confirmation before proceeding. Use
|
||||||
|
|||||||
@ -2443,6 +2443,12 @@ See [Plugins](/tools/plugin).
|
|||||||
openclaw: { cdpPort: 18800, color: "#FF4500" },
|
openclaw: { cdpPort: 18800, color: "#FF4500" },
|
||||||
work: { cdpPort: 18801, color: "#0066CC" },
|
work: { cdpPort: 18801, color: "#0066CC" },
|
||||||
user: { driver: "existing-session", attachOnly: true, color: "#00AA00" },
|
user: { driver: "existing-session", attachOnly: true, color: "#00AA00" },
|
||||||
|
brave: {
|
||||||
|
driver: "existing-session",
|
||||||
|
attachOnly: true,
|
||||||
|
userDataDir: "~/Library/Application Support/BraveSoftware/Brave-Browser",
|
||||||
|
color: "#FB542B",
|
||||||
|
},
|
||||||
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" },
|
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" },
|
||||||
},
|
},
|
||||||
color: "#FF4500",
|
color: "#FF4500",
|
||||||
@ -2463,6 +2469,8 @@ See [Plugins](/tools/plugin).
|
|||||||
- In strict mode, use `ssrfPolicy.hostnameAllowlist` and `ssrfPolicy.allowedHostnames` for explicit exceptions.
|
- In strict mode, use `ssrfPolicy.hostnameAllowlist` and `ssrfPolicy.allowedHostnames` for explicit exceptions.
|
||||||
- Remote profiles are attach-only (start/stop/reset disabled).
|
- Remote profiles are attach-only (start/stop/reset disabled).
|
||||||
- `existing-session` profiles are host-only and use Chrome MCP instead of CDP.
|
- `existing-session` profiles are host-only and use Chrome MCP instead of CDP.
|
||||||
|
- `existing-session` profiles can set `userDataDir` to target a specific
|
||||||
|
Chromium-based browser profile such as Brave or Edge.
|
||||||
- Auto-detect order: default browser if Chromium-based → Chrome → Brave → Edge → Chromium → Chrome Canary.
|
- Auto-detect order: default browser if Chromium-based → Chrome → Brave → Edge → Chromium → Chrome Canary.
|
||||||
- Control service: loopback only (port derived from `gateway.port`, default `18791`).
|
- Control service: loopback only (port derived from `gateway.port`, default `18791`).
|
||||||
- `extraArgs` appends extra launch flags to local Chromium startup (for example
|
- `extraArgs` appends extra launch flags to local Chromium startup (for example
|
||||||
|
|||||||
@ -155,18 +155,20 @@ normalizes it to the current host-local Chrome MCP attach model:
|
|||||||
Doctor also audits the host-local Chrome MCP path when you use `defaultProfile:
|
Doctor also audits the host-local Chrome MCP path when you use `defaultProfile:
|
||||||
"user"` or a configured `existing-session` profile:
|
"user"` or a configured `existing-session` profile:
|
||||||
|
|
||||||
- checks whether Google Chrome is installed on the same host
|
- checks whether Google Chrome is installed on the same host for default
|
||||||
|
auto-connect profiles
|
||||||
- checks the detected Chrome version and warns when it is below Chrome 144
|
- checks the detected Chrome version and warns when it is below Chrome 144
|
||||||
- reminds you to enable remote debugging in Chrome at
|
- reminds you to enable remote debugging in the browser inspect page (for
|
||||||
`chrome://inspect/#remote-debugging`
|
example `chrome://inspect/#remote-debugging`, `brave://inspect/#remote-debugging`,
|
||||||
|
or `edge://inspect/#remote-debugging`)
|
||||||
|
|
||||||
Doctor cannot enable the Chrome-side setting for you. Host-local Chrome MCP
|
Doctor cannot enable the Chrome-side setting for you. Host-local Chrome MCP
|
||||||
still requires:
|
still requires:
|
||||||
|
|
||||||
- Google Chrome 144+ on the gateway/node host
|
- a Chromium-based browser 144+ on the gateway/node host
|
||||||
- Chrome running locally
|
- the browser running locally
|
||||||
- remote debugging enabled in Chrome
|
- remote debugging enabled in that browser
|
||||||
- approving the first attach consent prompt in Chrome
|
- approving the first attach consent prompt in the browser
|
||||||
|
|
||||||
This check does **not** apply to Docker, sandbox, remote-browser, or other
|
This check does **not** apply to Docker, sandbox, remote-browser, or other
|
||||||
headless flows. Those continue to use raw CDP.
|
headless flows. Those continue to use raw CDP.
|
||||||
|
|||||||
@ -259,12 +259,19 @@ openclaw plugins install ./my-codex-bundle
|
|||||||
openclaw plugins install ./my-claude-bundle
|
openclaw plugins install ./my-claude-bundle
|
||||||
openclaw plugins install ./my-cursor-bundle
|
openclaw plugins install ./my-cursor-bundle
|
||||||
openclaw plugins install ./my-bundle.tgz
|
openclaw plugins install ./my-bundle.tgz
|
||||||
|
openclaw plugins marketplace list <marketplace-name>
|
||||||
|
openclaw plugins install <plugin-name>@<marketplace-name>
|
||||||
openclaw plugins info my-bundle
|
openclaw plugins info my-bundle
|
||||||
```
|
```
|
||||||
|
|
||||||
If the directory is a native OpenClaw plugin/package, the native install path
|
If the directory is a native OpenClaw plugin/package, the native install path
|
||||||
still wins.
|
still wins.
|
||||||
|
|
||||||
|
For Claude marketplace names, OpenClaw reads the local Claude known-marketplace
|
||||||
|
registry at `~/.claude/plugins/known_marketplaces.json`. Marketplace entries
|
||||||
|
can resolve to bundle-compatible directories/archives or to native plugin
|
||||||
|
sources; after resolution, the normal install rules still apply.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Bundle is detected but capabilities do not run
|
### Bundle is detected but capabilities do not run
|
||||||
|
|||||||
@ -88,6 +88,12 @@ Browser settings live in `~/.openclaw/openclaw.json`.
|
|||||||
attachOnly: true,
|
attachOnly: true,
|
||||||
color: "#00AA00",
|
color: "#00AA00",
|
||||||
},
|
},
|
||||||
|
brave: {
|
||||||
|
driver: "existing-session",
|
||||||
|
attachOnly: true,
|
||||||
|
userDataDir: "~/Library/Application Support/BraveSoftware/Brave-Browser",
|
||||||
|
color: "#FB542B",
|
||||||
|
},
|
||||||
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" },
|
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -114,6 +120,8 @@ Notes:
|
|||||||
- Local `openclaw` profiles auto-assign `cdpPort`/`cdpUrl` — set those only for remote CDP.
|
- Local `openclaw` profiles auto-assign `cdpPort`/`cdpUrl` — set those only for remote CDP.
|
||||||
- `driver: "existing-session"` uses Chrome DevTools MCP instead of raw CDP. Do
|
- `driver: "existing-session"` uses Chrome DevTools MCP instead of raw CDP. Do
|
||||||
not set `cdpUrl` for that driver.
|
not set `cdpUrl` for that driver.
|
||||||
|
- Set `browser.profiles.<name>.userDataDir` when an existing-session profile
|
||||||
|
should attach to a non-default Chromium user profile such as Brave or Edge.
|
||||||
|
|
||||||
## Use Brave (or another Chromium-based browser)
|
## Use Brave (or another Chromium-based browser)
|
||||||
|
|
||||||
@ -289,11 +297,11 @@ Defaults:
|
|||||||
|
|
||||||
All control endpoints accept `?profile=<name>`; the CLI uses `--browser-profile`.
|
All control endpoints accept `?profile=<name>`; the CLI uses `--browser-profile`.
|
||||||
|
|
||||||
## Chrome existing-session via MCP
|
## Existing-session via Chrome DevTools MCP
|
||||||
|
|
||||||
OpenClaw can also attach to a running Chrome profile through the official
|
OpenClaw can also attach to a running Chromium-based browser profile through the
|
||||||
Chrome DevTools MCP server. This reuses the tabs and login state already open in
|
official Chrome DevTools MCP server. This reuses the tabs and login state
|
||||||
that Chrome profile.
|
already open in that browser profile.
|
||||||
|
|
||||||
Official background and setup references:
|
Official background and setup references:
|
||||||
|
|
||||||
@ -305,13 +313,41 @@ Built-in profile:
|
|||||||
- `user`
|
- `user`
|
||||||
|
|
||||||
Optional: create your own custom existing-session profile if you want a
|
Optional: create your own custom existing-session profile if you want a
|
||||||
different name or color.
|
different name, color, or browser data directory.
|
||||||
|
|
||||||
Then in Chrome:
|
Default behavior:
|
||||||
|
|
||||||
1. Open `chrome://inspect/#remote-debugging`
|
- The built-in `user` profile uses Chrome MCP auto-connect, which targets the
|
||||||
2. Enable remote debugging
|
default local Google Chrome profile.
|
||||||
3. Keep Chrome running and approve the connection prompt when OpenClaw attaches
|
|
||||||
|
Use `userDataDir` for Brave, Edge, Chromium, or a non-default Chrome profile:
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
browser: {
|
||||||
|
profiles: {
|
||||||
|
brave: {
|
||||||
|
driver: "existing-session",
|
||||||
|
attachOnly: true,
|
||||||
|
userDataDir: "~/Library/Application Support/BraveSoftware/Brave-Browser",
|
||||||
|
color: "#FB542B",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in the matching browser:
|
||||||
|
|
||||||
|
1. Open that browser's inspect page for remote debugging.
|
||||||
|
2. Enable remote debugging.
|
||||||
|
3. Keep the browser running and approve the connection prompt when OpenClaw attaches.
|
||||||
|
|
||||||
|
Common inspect pages:
|
||||||
|
|
||||||
|
- Chrome: `chrome://inspect/#remote-debugging`
|
||||||
|
- Brave: `brave://inspect/#remote-debugging`
|
||||||
|
- Edge: `edge://inspect/#remote-debugging`
|
||||||
|
|
||||||
Live attach smoke test:
|
Live attach smoke test:
|
||||||
|
|
||||||
@ -327,17 +363,17 @@ What success looks like:
|
|||||||
- `status` shows `driver: existing-session`
|
- `status` shows `driver: existing-session`
|
||||||
- `status` shows `transport: chrome-mcp`
|
- `status` shows `transport: chrome-mcp`
|
||||||
- `status` shows `running: true`
|
- `status` shows `running: true`
|
||||||
- `tabs` lists your already-open Chrome tabs
|
- `tabs` lists your already-open browser tabs
|
||||||
- `snapshot` returns refs from the selected live tab
|
- `snapshot` returns refs from the selected live tab
|
||||||
|
|
||||||
What to check if attach does not work:
|
What to check if attach does not work:
|
||||||
|
|
||||||
- Chrome is version `144+`
|
- the target Chromium-based browser is version `144+`
|
||||||
- remote debugging is enabled at `chrome://inspect/#remote-debugging`
|
- remote debugging is enabled in that browser's inspect page
|
||||||
- Chrome showed and you accepted the attach consent prompt
|
- the browser showed and you accepted the attach consent prompt
|
||||||
- `openclaw doctor` migrates old extension-based browser config and checks that
|
- `openclaw doctor` migrates old extension-based browser config and checks that
|
||||||
Chrome is installed locally with a compatible version, but it cannot enable
|
Chrome is installed locally for default auto-connect profiles, but it cannot
|
||||||
Chrome-side remote debugging for you
|
enable browser-side remote debugging for you
|
||||||
|
|
||||||
Agent use:
|
Agent use:
|
||||||
|
|
||||||
@ -351,10 +387,11 @@ Notes:
|
|||||||
|
|
||||||
- This path is higher-risk than the isolated `openclaw` profile because it can
|
- This path is higher-risk than the isolated `openclaw` profile because it can
|
||||||
act inside your signed-in browser session.
|
act inside your signed-in browser session.
|
||||||
- OpenClaw does not launch Chrome for this driver; it attaches to an existing
|
- OpenClaw does not launch the browser for this driver; it attaches to an
|
||||||
session only.
|
existing session only.
|
||||||
- OpenClaw uses the official Chrome DevTools MCP `--autoConnect` flow here, not
|
- OpenClaw uses the official Chrome DevTools MCP `--autoConnect` flow here. If
|
||||||
the legacy default-profile remote debugging port workflow.
|
`userDataDir` is set, OpenClaw passes it through to target that explicit
|
||||||
|
Chromium user data directory.
|
||||||
- Existing-session screenshots support page captures and `--ref` element
|
- Existing-session screenshots support page captures and `--ref` element
|
||||||
captures from snapshots, but not CSS `--element` selectors.
|
captures from snapshots, but not CSS `--element` selectors.
|
||||||
- Existing-session `wait --url` supports exact, substring, and glob patterns
|
- Existing-session `wait --url` supports exact, substring, and glob patterns
|
||||||
|
|||||||
@ -57,6 +57,18 @@ openclaw plugins install ./my-bundle
|
|||||||
openclaw plugins install ./my-bundle.tgz
|
openclaw plugins install ./my-bundle.tgz
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For Claude marketplace installs, list the marketplace first, then install by
|
||||||
|
marketplace entry name:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openclaw plugins marketplace list <marketplace-name>
|
||||||
|
openclaw plugins install <plugin-name>@<marketplace-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
OpenClaw resolves known Claude marketplace names from
|
||||||
|
`~/.claude/plugins/known_marketplaces.json`. You can also pass an explicit
|
||||||
|
marketplace source with `--marketplace`.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
OpenClaw's plugin system has four layers:
|
OpenClaw's plugin system has four layers:
|
||||||
@ -94,6 +106,10 @@ OpenClaw also recognizes two compatible external bundle layouts:
|
|||||||
component layout without a manifest
|
component layout without a manifest
|
||||||
- Cursor-style bundles: `.cursor-plugin/plugin.json`
|
- Cursor-style bundles: `.cursor-plugin/plugin.json`
|
||||||
|
|
||||||
|
Claude marketplace entries can point at any of these compatible bundles, or at
|
||||||
|
native OpenClaw plugin sources. OpenClaw resolves the marketplace entry first,
|
||||||
|
then runs the normal install path for the resolved source.
|
||||||
|
|
||||||
They are shown in the plugin list as `format=bundle`, with a subtype of
|
They are shown in the plugin list as `format=bundle`, with a subtype of
|
||||||
`codex` or `claude` in verbose/info output.
|
`codex` or `claude` in verbose/info output.
|
||||||
|
|
||||||
@ -826,6 +842,37 @@ instead of the full plugin entry. This keeps startup and setup lighter
|
|||||||
when your main plugin entry also wires tools, hooks, or other runtime-only
|
when your main plugin entry also wires tools, hooks, or other runtime-only
|
||||||
code.
|
code.
|
||||||
|
|
||||||
|
Optional: `openclaw.startup.deferConfiguredChannelFullLoadUntilAfterListen`
|
||||||
|
can opt a channel plugin into the same `setupEntry` path during the gateway's
|
||||||
|
pre-listen startup phase, even when the channel is already configured.
|
||||||
|
|
||||||
|
Use this only when `setupEntry` fully covers the startup surface that must exist
|
||||||
|
before the gateway starts listening. In practice, that means the setup entry
|
||||||
|
must register every channel-owned capability that startup depends on, such as:
|
||||||
|
|
||||||
|
- channel registration itself
|
||||||
|
- any HTTP routes that must be available before the gateway starts listening
|
||||||
|
- any gateway methods, tools, or services that must exist during that same window
|
||||||
|
|
||||||
|
If your full entry still owns any required startup capability, do not enable
|
||||||
|
this flag. Keep the plugin on the default behavior and let OpenClaw load the
|
||||||
|
full entry during startup.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "@scope/my-channel",
|
||||||
|
"openclaw": {
|
||||||
|
"extensions": ["./index.ts"],
|
||||||
|
"setupEntry": "./setup-entry.ts",
|
||||||
|
"startup": {
|
||||||
|
"deferConfiguredChannelFullLoadUntilAfterListen": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Channel catalog metadata
|
### Channel catalog metadata
|
||||||
|
|
||||||
Channel plugins can advertise setup/discovery metadata via `openclaw.channel` and
|
Channel plugins can advertise setup/discovery metadata via `openclaw.channel` and
|
||||||
@ -1736,6 +1783,7 @@ Publishing contract:
|
|||||||
|
|
||||||
- Plugin `package.json` must include `openclaw.extensions` with one or more entry files.
|
- Plugin `package.json` must include `openclaw.extensions` with one or more entry files.
|
||||||
- Optional: `openclaw.setupEntry` may point at a lightweight setup-only entry for disabled or still-unconfigured channel setup.
|
- Optional: `openclaw.setupEntry` may point at a lightweight setup-only entry for disabled or still-unconfigured channel setup.
|
||||||
|
- Optional: `openclaw.startup.deferConfiguredChannelFullLoadUntilAfterListen` may opt a channel plugin into using `setupEntry` during pre-listen gateway startup, but only when that setup entry completely covers the plugin's startup-critical surface.
|
||||||
- Entry files can be `.js` or `.ts` (jiti loads TS at runtime).
|
- Entry files can be `.js` or `.ts` (jiti loads TS at runtime).
|
||||||
- `openclaw plugins install <npm-spec>` uses `npm pack`, extracts into `~/.openclaw/extensions/<id>/`, and enables it in config.
|
- `openclaw plugins install <npm-spec>` uses `npm pack`, extracts into `~/.openclaw/extensions/<id>/`, and enables it in config.
|
||||||
- Config key stability: scoped packages are normalized to the **unscoped** id for `plugins.entries.*`.
|
- Config key stability: scoped packages are normalized to the **unscoped** id for `plugins.entries.*`.
|
||||||
|
|||||||
13
extensions/bluebubbles/src/actions.runtime.ts
Normal file
13
extensions/bluebubbles/src/actions.runtime.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
export { sendBlueBubblesAttachment } from "./attachments.js";
|
||||||
|
export {
|
||||||
|
addBlueBubblesParticipant,
|
||||||
|
editBlueBubblesMessage,
|
||||||
|
leaveBlueBubblesChat,
|
||||||
|
removeBlueBubblesParticipant,
|
||||||
|
renameBlueBubblesChat,
|
||||||
|
setGroupIconBlueBubbles,
|
||||||
|
unsendBlueBubblesMessage,
|
||||||
|
} from "./chat.js";
|
||||||
|
export { resolveBlueBubblesMessageId } from "./monitor.js";
|
||||||
|
export { sendBlueBubblesReaction } from "./reactions.js";
|
||||||
|
export { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
||||||
@ -12,24 +12,18 @@ import {
|
|||||||
type ChannelMessageActionName,
|
type ChannelMessageActionName,
|
||||||
} from "openclaw/plugin-sdk/bluebubbles";
|
} from "openclaw/plugin-sdk/bluebubbles";
|
||||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||||
import { sendBlueBubblesAttachment } from "./attachments.js";
|
|
||||||
import {
|
|
||||||
editBlueBubblesMessage,
|
|
||||||
unsendBlueBubblesMessage,
|
|
||||||
renameBlueBubblesChat,
|
|
||||||
setGroupIconBlueBubbles,
|
|
||||||
addBlueBubblesParticipant,
|
|
||||||
removeBlueBubblesParticipant,
|
|
||||||
leaveBlueBubblesChat,
|
|
||||||
} from "./chat.js";
|
|
||||||
import { resolveBlueBubblesMessageId } from "./monitor.js";
|
|
||||||
import { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js";
|
import { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js";
|
||||||
import { sendBlueBubblesReaction } from "./reactions.js";
|
|
||||||
import { normalizeSecretInputString } from "./secret-input.js";
|
import { normalizeSecretInputString } from "./secret-input.js";
|
||||||
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
|
||||||
import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
|
import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
|
||||||
import type { BlueBubblesSendTarget } from "./types.js";
|
import type { BlueBubblesSendTarget } from "./types.js";
|
||||||
|
|
||||||
|
let actionsRuntimePromise: Promise<typeof import("./actions.runtime.js")> | null = null;
|
||||||
|
|
||||||
|
function loadBlueBubblesActionsRuntime() {
|
||||||
|
actionsRuntimePromise ??= import("./actions.runtime.js");
|
||||||
|
return actionsRuntimePromise;
|
||||||
|
}
|
||||||
|
|
||||||
const providerId = "bluebubbles";
|
const providerId = "bluebubbles";
|
||||||
|
|
||||||
function mapTarget(raw: string): BlueBubblesSendTarget {
|
function mapTarget(raw: string): BlueBubblesSendTarget {
|
||||||
@ -99,6 +93,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
|
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
|
||||||
extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
|
extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
|
||||||
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
|
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
|
||||||
|
const runtime = await loadBlueBubblesActionsRuntime();
|
||||||
const account = resolveBlueBubblesAccount({
|
const account = resolveBlueBubblesAccount({
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
@ -147,7 +142,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
throw new Error(`BlueBubbles ${action} requires serverUrl and password.`);
|
throw new Error(`BlueBubbles ${action} requires serverUrl and password.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolved = await resolveChatGuidForTarget({ baseUrl, password, target });
|
const resolved = await runtime.resolveChatGuidForTarget({ baseUrl, password, target });
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
throw new Error(`BlueBubbles ${action} failed: chatGuid not found for target.`);
|
throw new Error(`BlueBubbles ${action} failed: chatGuid not found for target.`);
|
||||||
}
|
}
|
||||||
@ -173,11 +168,13 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Resolve short ID (e.g., "1", "2") to full UUID
|
// Resolve short ID (e.g., "1", "2") to full UUID
|
||||||
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
|
const messageId = runtime.resolveBlueBubblesMessageId(rawMessageId, {
|
||||||
|
requireKnownShortId: true,
|
||||||
|
});
|
||||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||||
const resolvedChatGuid = await resolveChatGuid();
|
const resolvedChatGuid = await resolveChatGuid();
|
||||||
|
|
||||||
await sendBlueBubblesReaction({
|
await runtime.sendBlueBubblesReaction({
|
||||||
chatGuid: resolvedChatGuid,
|
chatGuid: resolvedChatGuid,
|
||||||
messageGuid: messageId,
|
messageGuid: messageId,
|
||||||
emoji,
|
emoji,
|
||||||
@ -218,11 +215,13 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Resolve short ID (e.g., "1", "2") to full UUID
|
// Resolve short ID (e.g., "1", "2") to full UUID
|
||||||
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
|
const messageId = runtime.resolveBlueBubblesMessageId(rawMessageId, {
|
||||||
|
requireKnownShortId: true,
|
||||||
|
});
|
||||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||||
const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage");
|
const backwardsCompatMessage = readStringParam(params, "backwardsCompatMessage");
|
||||||
|
|
||||||
await editBlueBubblesMessage(messageId, newText, {
|
await runtime.editBlueBubblesMessage(messageId, newText, {
|
||||||
...opts,
|
...opts,
|
||||||
partIndex: typeof partIndex === "number" ? partIndex : undefined,
|
partIndex: typeof partIndex === "number" ? partIndex : undefined,
|
||||||
backwardsCompatMessage: backwardsCompatMessage ?? undefined,
|
backwardsCompatMessage: backwardsCompatMessage ?? undefined,
|
||||||
@ -242,10 +241,12 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Resolve short ID (e.g., "1", "2") to full UUID
|
// Resolve short ID (e.g., "1", "2") to full UUID
|
||||||
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
|
const messageId = runtime.resolveBlueBubblesMessageId(rawMessageId, {
|
||||||
|
requireKnownShortId: true,
|
||||||
|
});
|
||||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||||
|
|
||||||
await unsendBlueBubblesMessage(messageId, {
|
await runtime.unsendBlueBubblesMessage(messageId, {
|
||||||
...opts,
|
...opts,
|
||||||
partIndex: typeof partIndex === "number" ? partIndex : undefined,
|
partIndex: typeof partIndex === "number" ? partIndex : undefined,
|
||||||
});
|
});
|
||||||
@ -276,10 +277,12 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Resolve short ID (e.g., "1", "2") to full UUID
|
// Resolve short ID (e.g., "1", "2") to full UUID
|
||||||
const messageId = resolveBlueBubblesMessageId(rawMessageId, { requireKnownShortId: true });
|
const messageId = runtime.resolveBlueBubblesMessageId(rawMessageId, {
|
||||||
|
requireKnownShortId: true,
|
||||||
|
});
|
||||||
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
const partIndex = readNumberParam(params, "partIndex", { integer: true });
|
||||||
|
|
||||||
const result = await sendMessageBlueBubbles(to, text, {
|
const result = await runtime.sendMessageBlueBubbles(to, text, {
|
||||||
...opts,
|
...opts,
|
||||||
replyToMessageGuid: messageId,
|
replyToMessageGuid: messageId,
|
||||||
replyToPartIndex: typeof partIndex === "number" ? partIndex : undefined,
|
replyToPartIndex: typeof partIndex === "number" ? partIndex : undefined,
|
||||||
@ -313,7 +316,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await sendMessageBlueBubbles(to, text, {
|
const result = await runtime.sendMessageBlueBubbles(to, text, {
|
||||||
...opts,
|
...opts,
|
||||||
effectId,
|
effectId,
|
||||||
});
|
});
|
||||||
@ -330,7 +333,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
throw new Error("BlueBubbles renameGroup requires displayName or name parameter.");
|
throw new Error("BlueBubbles renameGroup requires displayName or name parameter.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await renameBlueBubblesChat(resolvedChatGuid, displayName, opts);
|
await runtime.renameBlueBubblesChat(resolvedChatGuid, displayName, opts);
|
||||||
|
|
||||||
return jsonResult({ ok: true, renamed: resolvedChatGuid, displayName });
|
return jsonResult({ ok: true, renamed: resolvedChatGuid, displayName });
|
||||||
}
|
}
|
||||||
@ -355,7 +358,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
// Decode base64 to buffer
|
// Decode base64 to buffer
|
||||||
const buffer = Uint8Array.from(atob(base64Buffer), (c) => c.charCodeAt(0));
|
const buffer = Uint8Array.from(atob(base64Buffer), (c) => c.charCodeAt(0));
|
||||||
|
|
||||||
await setGroupIconBlueBubbles(resolvedChatGuid, buffer, filename, {
|
await runtime.setGroupIconBlueBubbles(resolvedChatGuid, buffer, filename, {
|
||||||
...opts,
|
...opts,
|
||||||
contentType: contentType ?? undefined,
|
contentType: contentType ?? undefined,
|
||||||
});
|
});
|
||||||
@ -372,7 +375,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
throw new Error("BlueBubbles addParticipant requires address or participant parameter.");
|
throw new Error("BlueBubbles addParticipant requires address or participant parameter.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await addBlueBubblesParticipant(resolvedChatGuid, address, opts);
|
await runtime.addBlueBubblesParticipant(resolvedChatGuid, address, opts);
|
||||||
|
|
||||||
return jsonResult({ ok: true, added: address, chatGuid: resolvedChatGuid });
|
return jsonResult({ ok: true, added: address, chatGuid: resolvedChatGuid });
|
||||||
}
|
}
|
||||||
@ -386,7 +389,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
throw new Error("BlueBubbles removeParticipant requires address or participant parameter.");
|
throw new Error("BlueBubbles removeParticipant requires address or participant parameter.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await removeBlueBubblesParticipant(resolvedChatGuid, address, opts);
|
await runtime.removeBlueBubblesParticipant(resolvedChatGuid, address, opts);
|
||||||
|
|
||||||
return jsonResult({ ok: true, removed: address, chatGuid: resolvedChatGuid });
|
return jsonResult({ ok: true, removed: address, chatGuid: resolvedChatGuid });
|
||||||
}
|
}
|
||||||
@ -396,7 +399,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
assertPrivateApiEnabled();
|
assertPrivateApiEnabled();
|
||||||
const resolvedChatGuid = await resolveChatGuid();
|
const resolvedChatGuid = await resolveChatGuid();
|
||||||
|
|
||||||
await leaveBlueBubblesChat(resolvedChatGuid, opts);
|
await runtime.leaveBlueBubblesChat(resolvedChatGuid, opts);
|
||||||
|
|
||||||
return jsonResult({ ok: true, left: resolvedChatGuid });
|
return jsonResult({ ok: true, left: resolvedChatGuid });
|
||||||
}
|
}
|
||||||
@ -427,7 +430,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
throw new Error("BlueBubbles sendAttachment requires buffer (base64) parameter.");
|
throw new Error("BlueBubbles sendAttachment requires buffer (base64) parameter.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await sendBlueBubblesAttachment({
|
const result = await runtime.sendBlueBubblesAttachment({
|
||||||
to,
|
to,
|
||||||
buffer,
|
buffer,
|
||||||
filename,
|
filename,
|
||||||
|
|||||||
6
extensions/bluebubbles/src/channel.runtime.ts
Normal file
6
extensions/bluebubbles/src/channel.runtime.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export { sendBlueBubblesMedia } from "./media-send.js";
|
||||||
|
export { resolveBlueBubblesMessageId } from "./monitor.js";
|
||||||
|
export { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
|
||||||
|
export { type BlueBubblesProbe, probeBlueBubbles } from "./probe.js";
|
||||||
|
export { sendMessageBlueBubbles } from "./send.js";
|
||||||
|
export { blueBubblesSetupWizard } from "./setup-surface.js";
|
||||||
@ -25,12 +25,8 @@ import {
|
|||||||
resolveDefaultBlueBubblesAccountId,
|
resolveDefaultBlueBubblesAccountId,
|
||||||
} from "./accounts.js";
|
} from "./accounts.js";
|
||||||
import { bluebubblesMessageActions } from "./actions.js";
|
import { bluebubblesMessageActions } from "./actions.js";
|
||||||
|
import type { BlueBubblesProbe } from "./channel.runtime.js";
|
||||||
import { BlueBubblesConfigSchema } from "./config-schema.js";
|
import { BlueBubblesConfigSchema } from "./config-schema.js";
|
||||||
import { sendBlueBubblesMedia } from "./media-send.js";
|
|
||||||
import { resolveBlueBubblesMessageId } from "./monitor.js";
|
|
||||||
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
|
|
||||||
import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js";
|
|
||||||
import { sendMessageBlueBubbles } from "./send.js";
|
|
||||||
import { blueBubblesSetupAdapter } from "./setup-core.js";
|
import { blueBubblesSetupAdapter } from "./setup-core.js";
|
||||||
import { blueBubblesSetupWizard } from "./setup-surface.js";
|
import { blueBubblesSetupWizard } from "./setup-surface.js";
|
||||||
import {
|
import {
|
||||||
@ -41,6 +37,13 @@ import {
|
|||||||
parseBlueBubblesTarget,
|
parseBlueBubblesTarget,
|
||||||
} from "./targets.js";
|
} from "./targets.js";
|
||||||
|
|
||||||
|
let blueBubblesChannelRuntimePromise: Promise<typeof import("./channel.runtime.js")> | null = null;
|
||||||
|
|
||||||
|
function loadBlueBubblesChannelRuntime() {
|
||||||
|
blueBubblesChannelRuntimePromise ??= import("./channel.runtime.js");
|
||||||
|
return blueBubblesChannelRuntimePromise;
|
||||||
|
}
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
id: "bluebubbles",
|
id: "bluebubbles",
|
||||||
label: "BlueBubbles",
|
label: "BlueBubbles",
|
||||||
@ -221,7 +224,9 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
idLabel: "bluebubblesSenderId",
|
idLabel: "bluebubblesSenderId",
|
||||||
normalizeAllowEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
|
normalizeAllowEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
|
||||||
notifyApproval: async ({ cfg, id }) => {
|
notifyApproval: async ({ cfg, id }) => {
|
||||||
await sendMessageBlueBubbles(id, PAIRING_APPROVED_MESSAGE, {
|
await (
|
||||||
|
await loadBlueBubblesChannelRuntime()
|
||||||
|
).sendMessageBlueBubbles(id, PAIRING_APPROVED_MESSAGE, {
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@ -240,12 +245,13 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
return { ok: true, to: trimmed };
|
return { ok: true, to: trimmed };
|
||||||
},
|
},
|
||||||
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
|
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
|
||||||
|
const runtime = await loadBlueBubblesChannelRuntime();
|
||||||
const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : "";
|
const rawReplyToId = typeof replyToId === "string" ? replyToId.trim() : "";
|
||||||
// Resolve short ID (e.g., "5") to full UUID
|
// Resolve short ID (e.g., "5") to full UUID
|
||||||
const replyToMessageGuid = rawReplyToId
|
const replyToMessageGuid = rawReplyToId
|
||||||
? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
|
? runtime.resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
|
||||||
: "";
|
: "";
|
||||||
const result = await sendMessageBlueBubbles(to, text, {
|
const result = await runtime.sendMessageBlueBubbles(to, text, {
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
replyToMessageGuid: replyToMessageGuid || undefined,
|
replyToMessageGuid: replyToMessageGuid || undefined,
|
||||||
@ -253,6 +259,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
return { channel: "bluebubbles", ...result };
|
return { channel: "bluebubbles", ...result };
|
||||||
},
|
},
|
||||||
sendMedia: async (ctx) => {
|
sendMedia: async (ctx) => {
|
||||||
|
const runtime = await loadBlueBubblesChannelRuntime();
|
||||||
const { cfg, to, text, mediaUrl, accountId, replyToId } = ctx;
|
const { cfg, to, text, mediaUrl, accountId, replyToId } = ctx;
|
||||||
const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as {
|
const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as {
|
||||||
mediaPath?: string;
|
mediaPath?: string;
|
||||||
@ -262,7 +269,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
caption?: string;
|
caption?: string;
|
||||||
};
|
};
|
||||||
const resolvedCaption = caption ?? text;
|
const resolvedCaption = caption ?? text;
|
||||||
const result = await sendBlueBubblesMedia({
|
const result = await runtime.sendBlueBubblesMedia({
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
to,
|
to,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
@ -290,7 +297,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
buildChannelSummary: ({ snapshot }) =>
|
buildChannelSummary: ({ snapshot }) =>
|
||||||
buildProbeChannelStatusSummary(snapshot, { baseUrl: snapshot.baseUrl ?? null }),
|
buildProbeChannelStatusSummary(snapshot, { baseUrl: snapshot.baseUrl ?? null }),
|
||||||
probeAccount: async ({ account, timeoutMs }) =>
|
probeAccount: async ({ account, timeoutMs }) =>
|
||||||
probeBlueBubbles({
|
(await loadBlueBubblesChannelRuntime()).probeBlueBubbles({
|
||||||
baseUrl: account.baseUrl,
|
baseUrl: account.baseUrl,
|
||||||
password: account.config.password ?? null,
|
password: account.config.password ?? null,
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
@ -315,8 +322,9 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
},
|
},
|
||||||
gateway: {
|
gateway: {
|
||||||
startAccount: async (ctx) => {
|
startAccount: async (ctx) => {
|
||||||
|
const runtime = await loadBlueBubblesChannelRuntime();
|
||||||
const account = ctx.account;
|
const account = ctx.account;
|
||||||
const webhookPath = resolveWebhookPathFromConfig(account.config);
|
const webhookPath = runtime.resolveWebhookPathFromConfig(account.config);
|
||||||
const statusSink = createAccountStatusSink({
|
const statusSink = createAccountStatusSink({
|
||||||
accountId: ctx.accountId,
|
accountId: ctx.accountId,
|
||||||
setStatus: ctx.setStatus,
|
setStatus: ctx.setStatus,
|
||||||
@ -325,7 +333,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
baseUrl: account.baseUrl,
|
baseUrl: account.baseUrl,
|
||||||
});
|
});
|
||||||
ctx.log?.info(`[${account.accountId}] starting provider (webhook=${webhookPath})`);
|
ctx.log?.info(`[${account.accountId}] starting provider (webhook=${webhookPath})`);
|
||||||
return monitorBlueBubblesProvider({
|
return runtime.monitorBlueBubblesProvider({
|
||||||
account,
|
account,
|
||||||
config: ctx.cfg,
|
config: ctx.cfg,
|
||||||
runtime: ctx.runtime,
|
runtime: ctx.runtime,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
||||||
import { discordPlugin } from "./src/channel.js";
|
import { discordPlugin } from "./src/channel.js";
|
||||||
import { setDiscordRuntime } from "./src/runtime.js";
|
import { setDiscordRuntime } from "./src/runtime.js";
|
||||||
import { registerDiscordSubagentHooks } from "./src/subagent-hooks.js";
|
import { registerDiscordSubagentHooks } from "./src/subagent-hooks.js";
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../../src/channels/plugins/contracts/suites.js";
|
||||||
import { inboundCtxCapture as capture } from "../../../../test/helpers/inbound-contract-dispatch-mock.js";
|
import { inboundCtxCapture as capture } from "../../../../test/helpers/inbound-contract-dispatch-mock.js";
|
||||||
import { expectInboundContextContract } from "../../../../test/helpers/inbound-contract.js";
|
|
||||||
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
|
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
|
||||||
import { processDiscordMessage } from "./message-handler.process.js";
|
import { processDiscordMessage } from "./message-handler.process.js";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@ -21,7 +21,6 @@ import {
|
|||||||
} from "../../../../src/acp/persistent-bindings.route.js";
|
} from "../../../../src/acp/persistent-bindings.route.js";
|
||||||
import { resolveHumanDelayConfig } from "../../../../src/agents/identity.js";
|
import { resolveHumanDelayConfig } from "../../../../src/agents/identity.js";
|
||||||
import { resolveChunkMode, resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js";
|
import { resolveChunkMode, resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js";
|
||||||
import { resolveCommandAuthorization } from "../../../../src/auto-reply/command-auth.js";
|
|
||||||
import type {
|
import type {
|
||||||
ChatCommandDefinition,
|
ChatCommandDefinition,
|
||||||
CommandArgDefinition,
|
CommandArgDefinition,
|
||||||
@ -61,8 +60,10 @@ import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
|
|||||||
import { chunkDiscordTextWithMode } from "../chunk.js";
|
import { chunkDiscordTextWithMode } from "../chunk.js";
|
||||||
import {
|
import {
|
||||||
isDiscordGroupAllowedByPolicy,
|
isDiscordGroupAllowedByPolicy,
|
||||||
|
normalizeDiscordAllowList,
|
||||||
normalizeDiscordSlug,
|
normalizeDiscordSlug,
|
||||||
resolveDiscordChannelConfigWithFallback,
|
resolveDiscordChannelConfigWithFallback,
|
||||||
|
resolveDiscordAllowListMatch,
|
||||||
resolveDiscordGuildEntry,
|
resolveDiscordGuildEntry,
|
||||||
resolveDiscordMemberAccessState,
|
resolveDiscordMemberAccessState,
|
||||||
resolveDiscordOwnerAccess,
|
resolveDiscordOwnerAccess,
|
||||||
@ -108,33 +109,26 @@ function resolveDiscordNativeCommandAllowlistAccess(params: {
|
|||||||
if (!commandsAllowFrom || typeof commandsAllowFrom !== "object") {
|
if (!commandsAllowFrom || typeof commandsAllowFrom !== "object") {
|
||||||
return { configured: false, allowed: false } as const;
|
return { configured: false, allowed: false } as const;
|
||||||
}
|
}
|
||||||
const configured =
|
const rawAllowList = Array.isArray(commandsAllowFrom.discord)
|
||||||
Array.isArray(commandsAllowFrom.discord) || Array.isArray(commandsAllowFrom["*"]);
|
? commandsAllowFrom.discord
|
||||||
if (!configured) {
|
: commandsAllowFrom["*"];
|
||||||
|
if (!Array.isArray(rawAllowList)) {
|
||||||
return { configured: false, allowed: false } as const;
|
return { configured: false, allowed: false } as const;
|
||||||
}
|
}
|
||||||
|
const allowList = normalizeDiscordAllowList(rawAllowList.map(String), [
|
||||||
const from =
|
"discord:",
|
||||||
params.chatType === "direct"
|
"user:",
|
||||||
? `discord:${params.sender.id}`
|
"pk:",
|
||||||
: `discord:${params.chatType}:${params.conversationId ?? "unknown"}`;
|
]);
|
||||||
const auth = resolveCommandAuthorization({
|
if (!allowList) {
|
||||||
ctx: {
|
return { configured: true, allowed: false } as const;
|
||||||
Provider: "discord",
|
}
|
||||||
Surface: "discord",
|
const match = resolveDiscordAllowListMatch({
|
||||||
OriginatingChannel: "discord",
|
allowList,
|
||||||
AccountId: params.accountId ?? undefined,
|
candidate: params.sender,
|
||||||
ChatType: params.chatType,
|
allowNameMatching: false,
|
||||||
From: from,
|
|
||||||
SenderId: params.sender.id,
|
|
||||||
SenderUsername: params.sender.name,
|
|
||||||
SenderTag: params.sender.tag,
|
|
||||||
},
|
|
||||||
cfg: params.cfg,
|
|
||||||
// We only want explicit commands.allowFrom authorization here.
|
|
||||||
commandAuthorized: false,
|
|
||||||
});
|
});
|
||||||
return { configured: true, allowed: auth.isAuthorizedSender } as const;
|
return { configured: true, allowed: match.allowed } as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDiscordCommandOptions(params: {
|
function buildDiscordCommandOptions(params: {
|
||||||
|
|||||||
@ -1,22 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../../../src/test-utils/runtime-group-policy-contract.js";
|
|
||||||
import { __testing } from "./provider.js";
|
|
||||||
|
|
||||||
describe("resolveDiscordRuntimeGroupPolicy", () => {
|
|
||||||
installProviderRuntimeGroupPolicyFallbackSuite({
|
|
||||||
resolve: __testing.resolveDiscordRuntimeGroupPolicy,
|
|
||||||
configuredLabel: "keeps open default when channels.discord is configured",
|
|
||||||
defaultGroupPolicyUnderTest: "open",
|
|
||||||
missingConfigLabel: "fails closed when channels.discord is missing and no defaults are set",
|
|
||||||
missingDefaultLabel: "ignores explicit global defaults when provider config is missing",
|
|
||||||
});
|
|
||||||
|
|
||||||
it("respects explicit provider policy", () => {
|
|
||||||
const resolved = __testing.resolveDiscordRuntimeGroupPolicy({
|
|
||||||
providerConfigPresent: false,
|
|
||||||
groupPolicy: "disabled",
|
|
||||||
});
|
|
||||||
expect(resolved.groupPolicy).toBe("disabled");
|
|
||||||
expect(resolved.providerMissingFallbackApplied).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
import { describe, vi } from "vitest";
|
|
||||||
import type { ReplyPayload } from "../../../src/auto-reply/types.js";
|
|
||||||
import {
|
|
||||||
installSendPayloadContractSuite,
|
|
||||||
primeSendMock,
|
|
||||||
} from "../../../src/test-utils/send-payload-contract.js";
|
|
||||||
import { discordOutbound } from "./outbound-adapter.js";
|
|
||||||
|
|
||||||
function createHarness(params: {
|
|
||||||
payload: ReplyPayload;
|
|
||||||
sendResults?: Array<{ messageId: string }>;
|
|
||||||
}) {
|
|
||||||
const sendDiscord = vi.fn();
|
|
||||||
primeSendMock(sendDiscord, { messageId: "dc-1", channelId: "123456" }, params.sendResults);
|
|
||||||
const ctx = {
|
|
||||||
cfg: {},
|
|
||||||
to: "channel:123456",
|
|
||||||
text: "",
|
|
||||||
payload: params.payload,
|
|
||||||
deps: {
|
|
||||||
sendDiscord,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
run: async () => await discordOutbound.sendPayload!(ctx),
|
|
||||||
sendMock: sendDiscord,
|
|
||||||
to: ctx.to,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("discordOutbound sendPayload", () => {
|
|
||||||
installSendPayloadContractSuite({
|
|
||||||
channel: "discord",
|
|
||||||
chunking: { mode: "passthrough", longTextLength: 3000 },
|
|
||||||
createHarness,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
||||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||||
|
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||||
|
|
||||||
const { setRuntime: setDiscordRuntime, getRuntime: getDiscordRuntime } =
|
const { setRuntime: setDiscordRuntime, getRuntime: getDiscordRuntime } =
|
||||||
createPluginRuntimeStore<PluginRuntime>("Discord runtime not initialized");
|
createPluginRuntimeStore<PluginRuntime>("Discord runtime not initialized");
|
||||||
|
|||||||
@ -35,8 +35,10 @@ const hookMocks = vi.hoisted(() => ({
|
|||||||
unbindThreadBindingsBySessionKey: vi.fn(() => []),
|
unbindThreadBindingsBySessionKey: vi.fn(() => []),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("openclaw/plugin-sdk/discord", () => ({
|
vi.mock("./accounts.js", () => ({
|
||||||
resolveDiscordAccount: hookMocks.resolveDiscordAccount,
|
resolveDiscordAccount: hookMocks.resolveDiscordAccount,
|
||||||
|
}));
|
||||||
|
vi.mock("./monitor/thread-bindings.js", () => ({
|
||||||
autoBindSpawnedDiscordSubagent: hookMocks.autoBindSpawnedDiscordSubagent,
|
autoBindSpawnedDiscordSubagent: hookMocks.autoBindSpawnedDiscordSubagent,
|
||||||
listThreadBindingsBySessionKey: hookMocks.listThreadBindingsBySessionKey,
|
listThreadBindingsBySessionKey: hookMocks.listThreadBindingsBySessionKey,
|
||||||
unbindThreadBindingsBySessionKey: hookMocks.unbindThreadBindingsBySessionKey,
|
unbindThreadBindingsBySessionKey: hookMocks.unbindThreadBindingsBySessionKey,
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||||
import { resolveDiscordAccount } from "./accounts.js";
|
import { resolveDiscordAccount } from "./accounts.js";
|
||||||
import {
|
import {
|
||||||
autoBindSpawnedDiscordSubagent,
|
autoBindSpawnedDiscordSubagent,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
||||||
import { imessagePlugin } from "./src/channel.js";
|
import { imessagePlugin } from "./src/channel.js";
|
||||||
import { setIMessageRuntime } from "./src/runtime.js";
|
import { setIMessageRuntime } from "./src/runtime.js";
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +0,0 @@
|
|||||||
import { describe } from "vitest";
|
|
||||||
import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../../../src/test-utils/runtime-group-policy-contract.js";
|
|
||||||
import { __testing } from "./monitor-provider.js";
|
|
||||||
|
|
||||||
describe("resolveIMessageRuntimeGroupPolicy", () => {
|
|
||||||
installProviderRuntimeGroupPolicyFallbackSuite({
|
|
||||||
resolve: __testing.resolveIMessageRuntimeGroupPolicy,
|
|
||||||
configuredLabel: "keeps open fallback when channels.imessage is configured",
|
|
||||||
defaultGroupPolicyUnderTest: "disabled",
|
|
||||||
missingConfigLabel: "fails closed when channels.imessage is missing and no defaults are set",
|
|
||||||
missingDefaultLabel: "ignores explicit global defaults when provider config is missing",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
||||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||||
|
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||||
|
|
||||||
const { setRuntime: setIMessageRuntime, getRuntime: getIMessageRuntime } =
|
const { setRuntime: setIMessageRuntime, getRuntime: getIMessageRuntime } =
|
||||||
createPluginRuntimeStore<PluginRuntime>("iMessage runtime not initialized");
|
createPluginRuntimeStore<PluginRuntime>("iMessage runtime not initialized");
|
||||||
|
|||||||
@ -1,10 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
buildOllamaProvider,
|
|
||||||
emptyPluginConfigSchema,
|
emptyPluginConfigSchema,
|
||||||
ensureOllamaModelPulled,
|
|
||||||
OLLAMA_DEFAULT_BASE_URL,
|
|
||||||
promptAndConfigureOllama,
|
|
||||||
configureOllamaNonInteractive,
|
|
||||||
type OpenClawPluginApi,
|
type OpenClawPluginApi,
|
||||||
type ProviderAuthContext,
|
type ProviderAuthContext,
|
||||||
type ProviderAuthMethodNonInteractiveContext,
|
type ProviderAuthMethodNonInteractiveContext,
|
||||||
@ -12,10 +7,15 @@ import {
|
|||||||
type ProviderDiscoveryContext,
|
type ProviderDiscoveryContext,
|
||||||
} from "openclaw/plugin-sdk/core";
|
} from "openclaw/plugin-sdk/core";
|
||||||
import { resolveOllamaApiBase } from "../../src/agents/models-config.providers.discovery.js";
|
import { resolveOllamaApiBase } from "../../src/agents/models-config.providers.discovery.js";
|
||||||
|
import { OLLAMA_DEFAULT_BASE_URL } from "../../src/agents/ollama-defaults.js";
|
||||||
|
|
||||||
const PROVIDER_ID = "ollama";
|
const PROVIDER_ID = "ollama";
|
||||||
const DEFAULT_API_KEY = "ollama-local";
|
const DEFAULT_API_KEY = "ollama-local";
|
||||||
|
|
||||||
|
async function loadProviderSetup() {
|
||||||
|
return await import("openclaw/plugin-sdk/ollama-setup");
|
||||||
|
}
|
||||||
|
|
||||||
const ollamaPlugin = {
|
const ollamaPlugin = {
|
||||||
id: "ollama",
|
id: "ollama",
|
||||||
name: "Ollama Provider",
|
name: "Ollama Provider",
|
||||||
@ -34,7 +34,8 @@ const ollamaPlugin = {
|
|||||||
hint: "Cloud and local open models",
|
hint: "Cloud and local open models",
|
||||||
kind: "custom",
|
kind: "custom",
|
||||||
run: async (ctx: ProviderAuthContext): Promise<ProviderAuthResult> => {
|
run: async (ctx: ProviderAuthContext): Promise<ProviderAuthResult> => {
|
||||||
const result = await promptAndConfigureOllama({
|
const providerSetup = await loadProviderSetup();
|
||||||
|
const result = await providerSetup.promptAndConfigureOllama({
|
||||||
cfg: ctx.config,
|
cfg: ctx.config,
|
||||||
prompter: ctx.prompter,
|
prompter: ctx.prompter,
|
||||||
});
|
});
|
||||||
@ -53,12 +54,14 @@ const ollamaPlugin = {
|
|||||||
defaultModel: `ollama/${result.defaultModelId}`,
|
defaultModel: `ollama/${result.defaultModelId}`,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) =>
|
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => {
|
||||||
configureOllamaNonInteractive({
|
const providerSetup = await loadProviderSetup();
|
||||||
|
return await providerSetup.configureOllamaNonInteractive({
|
||||||
nextConfig: ctx.config,
|
nextConfig: ctx.config,
|
||||||
opts: ctx.opts,
|
opts: ctx.opts,
|
||||||
runtime: ctx.runtime,
|
runtime: ctx.runtime,
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
discovery: {
|
discovery: {
|
||||||
@ -81,7 +84,8 @@ const ollamaPlugin = {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = await buildOllamaProvider(explicit?.baseUrl, {
|
const providerSetup = await loadProviderSetup();
|
||||||
|
const provider = await providerSetup.buildOllamaProvider(explicit?.baseUrl, {
|
||||||
quiet: !ollamaKey && !explicit,
|
quiet: !ollamaKey && !explicit,
|
||||||
});
|
});
|
||||||
if (provider.models.length === 0 && !ollamaKey && !explicit?.apiKey) {
|
if (provider.models.length === 0 && !ollamaKey && !explicit?.apiKey) {
|
||||||
@ -115,7 +119,8 @@ const ollamaPlugin = {
|
|||||||
if (!model.startsWith("ollama/")) {
|
if (!model.startsWith("ollama/")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await ensureOllamaModelPulled({ config, prompter });
|
const providerSetup = await loadProviderSetup();
|
||||||
|
await providerSetup.ensureOllamaModelPulled({ config, prompter });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@ -10,8 +10,8 @@ import { ensureAuthProfileStore } from "../../src/agents/auth-profiles/store.js"
|
|||||||
import type { OAuthCredential } from "../../src/agents/auth-profiles/types.js";
|
import type { OAuthCredential } from "../../src/agents/auth-profiles/types.js";
|
||||||
import { DEFAULT_CONTEXT_TOKENS } from "../../src/agents/defaults.js";
|
import { DEFAULT_CONTEXT_TOKENS } from "../../src/agents/defaults.js";
|
||||||
import { normalizeModelCompat } from "../../src/agents/model-compat.js";
|
import { normalizeModelCompat } from "../../src/agents/model-compat.js";
|
||||||
import { normalizeProviderId } from "../../src/agents/model-selection.js";
|
|
||||||
import { buildOpenAICodexProvider } from "../../src/agents/models-config.providers.static.js";
|
import { buildOpenAICodexProvider } from "../../src/agents/models-config.providers.static.js";
|
||||||
|
import { normalizeProviderId } from "../../src/agents/provider-id.js";
|
||||||
import { loginOpenAICodexOAuth } from "../../src/commands/openai-codex-oauth.js";
|
import { loginOpenAICodexOAuth } from "../../src/commands/openai-codex-oauth.js";
|
||||||
import { fetchCodexUsage } from "../../src/infra/provider-usage.fetch.js";
|
import { fetchCodexUsage } from "../../src/infra/provider-usage.fetch.js";
|
||||||
import { buildOauthProviderAuthResult } from "../../src/plugin-sdk/provider-auth-result.js";
|
import { buildOauthProviderAuthResult } from "../../src/plugin-sdk/provider-auth-result.js";
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import {
|
|||||||
type ProviderRuntimeModel,
|
type ProviderRuntimeModel,
|
||||||
} from "openclaw/plugin-sdk/core";
|
} from "openclaw/plugin-sdk/core";
|
||||||
import { normalizeModelCompat } from "../../src/agents/model-compat.js";
|
import { normalizeModelCompat } from "../../src/agents/model-compat.js";
|
||||||
import { normalizeProviderId } from "../../src/agents/model-selection.js";
|
import { normalizeProviderId } from "../../src/agents/provider-id.js";
|
||||||
import {
|
import {
|
||||||
applyOpenAIConfig,
|
applyOpenAIConfig,
|
||||||
OPENAI_DEFAULT_MODEL,
|
OPENAI_DEFAULT_MODEL,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||||
import { registerSandboxBackend } from "openclaw/plugin-sdk/core";
|
import { registerSandboxBackend } from "openclaw/plugin-sdk/sandbox";
|
||||||
import {
|
import {
|
||||||
createOpenShellSandboxBackendFactory,
|
createOpenShellSandboxBackendFactory,
|
||||||
createOpenShellSandboxBackendManager,
|
createOpenShellSandboxBackendManager,
|
||||||
|
|||||||
@ -11,13 +11,13 @@ import type {
|
|||||||
SandboxBackendHandle,
|
SandboxBackendHandle,
|
||||||
SandboxBackendManager,
|
SandboxBackendManager,
|
||||||
SshSandboxSession,
|
SshSandboxSession,
|
||||||
} from "openclaw/plugin-sdk/core";
|
} from "openclaw/plugin-sdk/sandbox";
|
||||||
import {
|
import {
|
||||||
createRemoteShellSandboxFsBridge,
|
createRemoteShellSandboxFsBridge,
|
||||||
disposeSshSandboxSession,
|
disposeSshSandboxSession,
|
||||||
resolvePreferredOpenClawTmpDir,
|
resolvePreferredOpenClawTmpDir,
|
||||||
runSshSandboxCommand,
|
runSshSandboxCommand,
|
||||||
} from "openclaw/plugin-sdk/core";
|
} from "openclaw/plugin-sdk/sandbox";
|
||||||
import {
|
import {
|
||||||
buildExecRemoteCommand,
|
buildExecRemoteCommand,
|
||||||
buildRemoteCommand,
|
buildRemoteCommand,
|
||||||
|
|||||||
@ -4,10 +4,10 @@ import {
|
|||||||
runPluginCommandWithTimeout,
|
runPluginCommandWithTimeout,
|
||||||
shellEscape,
|
shellEscape,
|
||||||
type SshSandboxSession,
|
type SshSandboxSession,
|
||||||
} from "openclaw/plugin-sdk/core";
|
} from "openclaw/plugin-sdk/sandbox";
|
||||||
import type { ResolvedOpenShellPluginConfig } from "./config.js";
|
import type { ResolvedOpenShellPluginConfig } from "./config.js";
|
||||||
|
|
||||||
export { buildExecRemoteCommand, shellEscape } from "openclaw/plugin-sdk/core";
|
export { buildExecRemoteCommand, shellEscape } from "openclaw/plugin-sdk/sandbox";
|
||||||
|
|
||||||
export type OpenShellExecContext = {
|
export type OpenShellExecContext = {
|
||||||
config: ResolvedOpenShellPluginConfig;
|
config: ResolvedOpenShellPluginConfig;
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import type {
|
|||||||
SandboxFsBridge,
|
SandboxFsBridge,
|
||||||
SandboxFsStat,
|
SandboxFsStat,
|
||||||
SandboxResolvedPath,
|
SandboxResolvedPath,
|
||||||
} from "openclaw/plugin-sdk/core";
|
} from "openclaw/plugin-sdk/sandbox";
|
||||||
import type { OpenShellSandboxBackend } from "./backend.js";
|
import type { OpenShellSandboxBackend } from "./backend.js";
|
||||||
import { movePathWithCopyFallback } from "./mirror.js";
|
import { movePathWithCopyFallback } from "./mirror.js";
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import {
|
|||||||
type RemoteShellSandboxHandle,
|
type RemoteShellSandboxHandle,
|
||||||
type SandboxContext,
|
type SandboxContext,
|
||||||
type SandboxFsBridge,
|
type SandboxFsBridge,
|
||||||
} from "openclaw/plugin-sdk/core";
|
} from "openclaw/plugin-sdk/sandbox";
|
||||||
|
|
||||||
export function createOpenShellRemoteFsBridge(params: {
|
export function createOpenShellRemoteFsBridge(params: {
|
||||||
sandbox: SandboxContext;
|
sandbox: SandboxContext;
|
||||||
|
|||||||
@ -1,15 +1,20 @@
|
|||||||
import {
|
import {
|
||||||
buildSglangProvider,
|
|
||||||
configureOpenAICompatibleSelfHostedProviderNonInteractive,
|
|
||||||
discoverOpenAICompatibleSelfHostedProvider,
|
|
||||||
emptyPluginConfigSchema,
|
emptyPluginConfigSchema,
|
||||||
promptAndConfigureOpenAICompatibleSelfHostedProviderAuth,
|
|
||||||
type OpenClawPluginApi,
|
type OpenClawPluginApi,
|
||||||
type ProviderAuthMethodNonInteractiveContext,
|
type ProviderAuthMethodNonInteractiveContext,
|
||||||
} from "openclaw/plugin-sdk/core";
|
} from "openclaw/plugin-sdk/core";
|
||||||
|
import {
|
||||||
|
SGLANG_DEFAULT_API_KEY_ENV_VAR,
|
||||||
|
SGLANG_DEFAULT_BASE_URL,
|
||||||
|
SGLANG_MODEL_PLACEHOLDER,
|
||||||
|
SGLANG_PROVIDER_LABEL,
|
||||||
|
} from "../../src/agents/sglang-defaults.js";
|
||||||
|
|
||||||
const PROVIDER_ID = "sglang";
|
const PROVIDER_ID = "sglang";
|
||||||
const DEFAULT_BASE_URL = "http://127.0.0.1:30000/v1";
|
|
||||||
|
async function loadProviderSetup() {
|
||||||
|
return await import("openclaw/plugin-sdk/self-hosted-provider-setup");
|
||||||
|
}
|
||||||
|
|
||||||
const sglangPlugin = {
|
const sglangPlugin = {
|
||||||
id: "sglang",
|
id: "sglang",
|
||||||
@ -25,38 +30,44 @@ const sglangPlugin = {
|
|||||||
auth: [
|
auth: [
|
||||||
{
|
{
|
||||||
id: "custom",
|
id: "custom",
|
||||||
label: "SGLang",
|
label: SGLANG_PROVIDER_LABEL,
|
||||||
hint: "Fast self-hosted OpenAI-compatible server",
|
hint: "Fast self-hosted OpenAI-compatible server",
|
||||||
kind: "custom",
|
kind: "custom",
|
||||||
run: async (ctx) =>
|
run: async (ctx) => {
|
||||||
promptAndConfigureOpenAICompatibleSelfHostedProviderAuth({
|
const providerSetup = await loadProviderSetup();
|
||||||
|
return await providerSetup.promptAndConfigureOpenAICompatibleSelfHostedProviderAuth({
|
||||||
cfg: ctx.config,
|
cfg: ctx.config,
|
||||||
prompter: ctx.prompter,
|
prompter: ctx.prompter,
|
||||||
providerId: PROVIDER_ID,
|
providerId: PROVIDER_ID,
|
||||||
providerLabel: "SGLang",
|
providerLabel: SGLANG_PROVIDER_LABEL,
|
||||||
defaultBaseUrl: DEFAULT_BASE_URL,
|
defaultBaseUrl: SGLANG_DEFAULT_BASE_URL,
|
||||||
defaultApiKeyEnvVar: "SGLANG_API_KEY",
|
defaultApiKeyEnvVar: SGLANG_DEFAULT_API_KEY_ENV_VAR,
|
||||||
modelPlaceholder: "Qwen/Qwen3-8B",
|
modelPlaceholder: SGLANG_MODEL_PLACEHOLDER,
|
||||||
}),
|
});
|
||||||
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) =>
|
},
|
||||||
configureOpenAICompatibleSelfHostedProviderNonInteractive({
|
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => {
|
||||||
|
const providerSetup = await loadProviderSetup();
|
||||||
|
return await providerSetup.configureOpenAICompatibleSelfHostedProviderNonInteractive({
|
||||||
ctx,
|
ctx,
|
||||||
providerId: PROVIDER_ID,
|
providerId: PROVIDER_ID,
|
||||||
providerLabel: "SGLang",
|
providerLabel: SGLANG_PROVIDER_LABEL,
|
||||||
defaultBaseUrl: DEFAULT_BASE_URL,
|
defaultBaseUrl: SGLANG_DEFAULT_BASE_URL,
|
||||||
defaultApiKeyEnvVar: "SGLANG_API_KEY",
|
defaultApiKeyEnvVar: SGLANG_DEFAULT_API_KEY_ENV_VAR,
|
||||||
modelPlaceholder: "Qwen/Qwen3-8B",
|
modelPlaceholder: SGLANG_MODEL_PLACEHOLDER,
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
discovery: {
|
discovery: {
|
||||||
order: "late",
|
order: "late",
|
||||||
run: async (ctx) =>
|
run: async (ctx) => {
|
||||||
discoverOpenAICompatibleSelfHostedProvider({
|
const providerSetup = await loadProviderSetup();
|
||||||
|
return await providerSetup.discoverOpenAICompatibleSelfHostedProvider({
|
||||||
ctx,
|
ctx,
|
||||||
providerId: PROVIDER_ID,
|
providerId: PROVIDER_ID,
|
||||||
buildProvider: buildSglangProvider,
|
buildProvider: providerSetup.buildSglangProvider,
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
wizard: {
|
wizard: {
|
||||||
setup: {
|
setup: {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
||||||
import { signalPlugin } from "./src/channel.js";
|
import { signalPlugin } from "./src/channel.js";
|
||||||
import { setSignalRuntime } from "./src/runtime.js";
|
import { setSignalRuntime } from "./src/runtime.js";
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { MsgContext } from "../../../../src/auto-reply/templating.js";
|
import type { MsgContext } from "../../../../src/auto-reply/templating.js";
|
||||||
import { expectInboundContextContract } from "../../../../test/helpers/inbound-contract.js";
|
import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../../src/channels/plugins/contracts/suites.js";
|
||||||
import { createSignalEventHandler } from "./event-handler.js";
|
import { createSignalEventHandler } from "./event-handler.js";
|
||||||
import {
|
import {
|
||||||
createBaseSignalEventHandlerDeps,
|
createBaseSignalEventHandlerDeps,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
||||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||||
|
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||||
|
|
||||||
const { setRuntime: setSignalRuntime, getRuntime: getSignalRuntime } =
|
const { setRuntime: setSignalRuntime, getRuntime: getSignalRuntime } =
|
||||||
createPluginRuntimeStore<PluginRuntime>("Signal runtime not initialized");
|
createPluginRuntimeStore<PluginRuntime>("Signal runtime not initialized");
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
||||||
import { slackPlugin } from "./src/channel.js";
|
import { slackPlugin } from "./src/channel.js";
|
||||||
import { setSlackRuntime } from "./src/runtime.js";
|
import { setSlackRuntime } from "./src/runtime.js";
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||||
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk";
|
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/core";
|
||||||
import { parseSlackBlocksInput } from "./blocks-input.js";
|
import { parseSlackBlocksInput } from "./blocks-input.js";
|
||||||
import { buildSlackInteractiveBlocks } from "./blocks-render.js";
|
import { buildSlackInteractiveBlocks } from "./blocks-render.js";
|
||||||
|
|
||||||
|
|||||||
@ -3,10 +3,10 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type { App } from "@slack/bolt";
|
import type { App } from "@slack/bolt";
|
||||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../../../src/channels/plugins/contracts/suites.js";
|
||||||
import type { OpenClawConfig } from "../../../../../src/config/config.js";
|
import type { OpenClawConfig } from "../../../../../src/config/config.js";
|
||||||
import { resolveAgentRoute } from "../../../../../src/routing/resolve-route.js";
|
import { resolveAgentRoute } from "../../../../../src/routing/resolve-route.js";
|
||||||
import { resolveThreadSessionKeys } from "../../../../../src/routing/session-key.js";
|
import { resolveThreadSessionKeys } from "../../../../../src/routing/session-key.js";
|
||||||
import { expectInboundContextContract } from "../../../../../test/helpers/inbound-contract.js";
|
|
||||||
import type { ResolvedSlackAccount } from "../../accounts.js";
|
import type { ResolvedSlackAccount } from "../../accounts.js";
|
||||||
import type { SlackMessageEvent } from "../../types.js";
|
import type { SlackMessageEvent } from "../../types.js";
|
||||||
import type { SlackMonitorContext } from "../context.js";
|
import type { SlackMonitorContext } from "../context.js";
|
||||||
|
|||||||
@ -1,13 +0,0 @@
|
|||||||
import { describe } from "vitest";
|
|
||||||
import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../../../src/test-utils/runtime-group-policy-contract.js";
|
|
||||||
import { __testing } from "./provider.js";
|
|
||||||
|
|
||||||
describe("resolveSlackRuntimeGroupPolicy", () => {
|
|
||||||
installProviderRuntimeGroupPolicyFallbackSuite({
|
|
||||||
resolve: __testing.resolveSlackRuntimeGroupPolicy,
|
|
||||||
configuredLabel: "keeps open default when channels.slack is configured",
|
|
||||||
defaultGroupPolicyUnderTest: "open",
|
|
||||||
missingConfigLabel: "fails closed when channels.slack is missing and no defaults are set",
|
|
||||||
missingDefaultLabel: "ignores explicit global defaults when provider config is missing",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
94
extensions/slack/src/monitor/provider.interop.test.ts
Normal file
94
extensions/slack/src/monitor/provider.interop.test.ts
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { __testing } from "./provider.js";
|
||||||
|
|
||||||
|
describe("resolveSlackBoltInterop", () => {
|
||||||
|
class FakeApp {}
|
||||||
|
class FakeHTTPReceiver {}
|
||||||
|
|
||||||
|
it("uses the default import when it already exposes named exports", () => {
|
||||||
|
const resolved = __testing.resolveSlackBoltInterop({
|
||||||
|
defaultImport: {
|
||||||
|
App: FakeApp,
|
||||||
|
HTTPReceiver: FakeHTTPReceiver,
|
||||||
|
},
|
||||||
|
namespaceImport: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolved).toEqual({
|
||||||
|
App: FakeApp,
|
||||||
|
HTTPReceiver: FakeHTTPReceiver,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses nested default export when the default import is a wrapper object", () => {
|
||||||
|
const resolved = __testing.resolveSlackBoltInterop({
|
||||||
|
defaultImport: {
|
||||||
|
default: {
|
||||||
|
App: FakeApp,
|
||||||
|
HTTPReceiver: FakeHTTPReceiver,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
namespaceImport: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolved).toEqual({
|
||||||
|
App: FakeApp,
|
||||||
|
HTTPReceiver: FakeHTTPReceiver,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the namespace receiver when the default import is the App constructor itself", () => {
|
||||||
|
const resolved = __testing.resolveSlackBoltInterop({
|
||||||
|
defaultImport: FakeApp,
|
||||||
|
namespaceImport: {
|
||||||
|
HTTPReceiver: FakeHTTPReceiver,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolved).toEqual({
|
||||||
|
App: FakeApp,
|
||||||
|
HTTPReceiver: FakeHTTPReceiver,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses namespace.default when it exposes named exports", () => {
|
||||||
|
const resolved = __testing.resolveSlackBoltInterop({
|
||||||
|
defaultImport: undefined,
|
||||||
|
namespaceImport: {
|
||||||
|
default: {
|
||||||
|
App: FakeApp,
|
||||||
|
HTTPReceiver: FakeHTTPReceiver,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolved).toEqual({
|
||||||
|
App: FakeApp,
|
||||||
|
HTTPReceiver: FakeHTTPReceiver,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the namespace import when it exposes named exports", () => {
|
||||||
|
const resolved = __testing.resolveSlackBoltInterop({
|
||||||
|
defaultImport: undefined,
|
||||||
|
namespaceImport: {
|
||||||
|
App: FakeApp,
|
||||||
|
HTTPReceiver: FakeHTTPReceiver,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(resolved).toEqual({
|
||||||
|
App: FakeApp,
|
||||||
|
HTTPReceiver: FakeHTTPReceiver,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when the module cannot be resolved", () => {
|
||||||
|
expect(() =>
|
||||||
|
__testing.resolveSlackBoltInterop({
|
||||||
|
defaultImport: null,
|
||||||
|
namespaceImport: {},
|
||||||
|
}),
|
||||||
|
).toThrow("Unable to resolve @slack/bolt App/HTTPReceiver exports");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
import SlackBolt from "@slack/bolt";
|
import SlackBolt, * as SlackBoltNamespace from "@slack/bolt";
|
||||||
import { resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js";
|
import { resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js";
|
||||||
import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../../../src/auto-reply/reply/history.js";
|
import { DEFAULT_GROUP_HISTORY_LIMIT } from "../../../../src/auto-reply/reply/history.js";
|
||||||
import {
|
import {
|
||||||
@ -46,14 +46,77 @@ import {
|
|||||||
import { registerSlackMonitorSlashCommands } from "./slash.js";
|
import { registerSlackMonitorSlashCommands } from "./slash.js";
|
||||||
import type { MonitorSlackOpts } from "./types.js";
|
import type { MonitorSlackOpts } from "./types.js";
|
||||||
|
|
||||||
const slackBoltModule = SlackBolt as typeof import("@slack/bolt") & {
|
type SlackAppConstructor = typeof import("@slack/bolt").App;
|
||||||
default?: typeof import("@slack/bolt");
|
type SlackHttpReceiverConstructor = typeof import("@slack/bolt").HTTPReceiver;
|
||||||
|
type SlackBoltResolvedExports = {
|
||||||
|
App: SlackAppConstructor;
|
||||||
|
HTTPReceiver: SlackHttpReceiverConstructor;
|
||||||
};
|
};
|
||||||
// Bun allows named imports from CJS; Node ESM doesn't. Use default+fallback for compatibility.
|
type Constructor = abstract new (...args: never[]) => unknown;
|
||||||
// Fix: Check if module has App property directly (Node 25.x ESM/CJS compat issue)
|
|
||||||
const slackBolt =
|
function isConstructorFunction<T extends Constructor>(value: unknown): value is T {
|
||||||
(slackBoltModule.App ? slackBoltModule : slackBoltModule.default) ?? slackBoltModule;
|
return typeof value === "function";
|
||||||
const { App, HTTPReceiver } = slackBolt;
|
}
|
||||||
|
|
||||||
|
function resolveSlackBoltModule(value: unknown): SlackBoltResolvedExports | null {
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const app = Reflect.get(value, "App");
|
||||||
|
const httpReceiver = Reflect.get(value, "HTTPReceiver");
|
||||||
|
if (
|
||||||
|
!isConstructorFunction<SlackAppConstructor>(app) ||
|
||||||
|
!isConstructorFunction<SlackHttpReceiverConstructor>(httpReceiver)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
App: app,
|
||||||
|
HTTPReceiver: httpReceiver,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSlackBoltInterop(params: {
|
||||||
|
defaultImport: unknown;
|
||||||
|
namespaceImport: unknown;
|
||||||
|
}): SlackBoltResolvedExports {
|
||||||
|
const { defaultImport, namespaceImport } = params;
|
||||||
|
const nestedDefault =
|
||||||
|
defaultImport && typeof defaultImport === "object"
|
||||||
|
? Reflect.get(defaultImport, "default")
|
||||||
|
: undefined;
|
||||||
|
const namespaceDefault =
|
||||||
|
namespaceImport && typeof namespaceImport === "object"
|
||||||
|
? Reflect.get(namespaceImport, "default")
|
||||||
|
: undefined;
|
||||||
|
const namespaceReceiver =
|
||||||
|
namespaceImport && typeof namespaceImport === "object"
|
||||||
|
? Reflect.get(namespaceImport, "HTTPReceiver")
|
||||||
|
: undefined;
|
||||||
|
const directModule =
|
||||||
|
resolveSlackBoltModule(defaultImport) ??
|
||||||
|
resolveSlackBoltModule(nestedDefault) ??
|
||||||
|
resolveSlackBoltModule(namespaceDefault) ??
|
||||||
|
resolveSlackBoltModule(namespaceImport);
|
||||||
|
if (directModule) {
|
||||||
|
return directModule;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
isConstructorFunction<SlackAppConstructor>(defaultImport) &&
|
||||||
|
isConstructorFunction<SlackHttpReceiverConstructor>(namespaceReceiver)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
App: defaultImport,
|
||||||
|
HTTPReceiver: namespaceReceiver,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new TypeError("Unable to resolve @slack/bolt App/HTTPReceiver exports");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { App, HTTPReceiver } = resolveSlackBoltInterop({
|
||||||
|
defaultImport: SlackBolt,
|
||||||
|
namespaceImport: SlackBoltNamespace,
|
||||||
|
});
|
||||||
|
|
||||||
const SLACK_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
|
const SLACK_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
|
||||||
const SLACK_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
|
const SLACK_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
|
||||||
@ -515,6 +578,7 @@ export const __testing = {
|
|||||||
publishSlackDisconnectedStatus,
|
publishSlackDisconnectedStatus,
|
||||||
resolveSlackRuntimeGroupPolicy: resolveOpenProviderRuntimeGroupPolicy,
|
resolveSlackRuntimeGroupPolicy: resolveOpenProviderRuntimeGroupPolicy,
|
||||||
resolveDefaultGroupPolicy,
|
resolveDefaultGroupPolicy,
|
||||||
|
resolveSlackBoltInterop,
|
||||||
getSocketEmitter,
|
getSocketEmitter,
|
||||||
waitForSlackSocketDisconnect,
|
waitForSlackSocketDisconnect,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
||||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||||
|
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||||
|
|
||||||
const { setRuntime: setSlackRuntime, getRuntime: getSlackRuntime } =
|
const { setRuntime: setSlackRuntime, getRuntime: getSlackRuntime } =
|
||||||
createPluginRuntimeStore<PluginRuntime>("Slack runtime not initialized");
|
createPluginRuntimeStore<PluginRuntime>("Slack runtime not initialized");
|
||||||
|
|||||||
@ -5,14 +5,6 @@ vi.mock("./webhook-handler.js", () => ({
|
|||||||
createWebhookHandler: vi.fn(() => vi.fn()),
|
createWebhookHandler: vi.fn(() => vi.fn()),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("zod", () => ({
|
|
||||||
z: {
|
|
||||||
object: vi.fn(() => ({
|
|
||||||
passthrough: vi.fn(() => ({ _type: "zod-schema" })),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { createSynologyChatPlugin } = await import("./channel.js");
|
const { createSynologyChatPlugin } = await import("./channel.js");
|
||||||
|
|
||||||
describe("createSynologyChatPlugin", () => {
|
describe("createSynologyChatPlugin", () => {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import type { ChannelPlugin, OpenClawPluginApi } from "openclaw/plugin-sdk";
|
import type { ChannelPlugin, OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
||||||
import { telegramPlugin } from "./src/channel.js";
|
import { telegramPlugin } from "./src/channel.js";
|
||||||
import { setTelegramRuntime } from "./src/runtime.js";
|
import { setTelegramRuntime } from "./src/runtime.js";
|
||||||
|
|
||||||
|
|||||||
@ -298,6 +298,66 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends error replies silently when silentErrorReplies is enabled", async () => {
|
||||||
|
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
|
||||||
|
await dispatcherOptions.deliver({ text: "oops", isError: true }, { kind: "final" });
|
||||||
|
return { queuedFinal: true };
|
||||||
|
});
|
||||||
|
deliverReplies.mockResolvedValue({ delivered: true });
|
||||||
|
|
||||||
|
await dispatchWithContext({
|
||||||
|
context: createContext(),
|
||||||
|
telegramCfg: { silentErrorReplies: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deliverReplies).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
silent: true,
|
||||||
|
replies: [expect.objectContaining({ isError: true })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps error replies notifying by default", async () => {
|
||||||
|
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
|
||||||
|
await dispatcherOptions.deliver({ text: "oops", isError: true }, { kind: "final" });
|
||||||
|
return { queuedFinal: true };
|
||||||
|
});
|
||||||
|
deliverReplies.mockResolvedValue({ delivered: true });
|
||||||
|
|
||||||
|
await dispatchWithContext({ context: createContext() });
|
||||||
|
|
||||||
|
expect(deliverReplies).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
silent: false,
|
||||||
|
replies: [expect.objectContaining({ isError: true })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps fallback replies silent after an error reply is skipped", async () => {
|
||||||
|
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
|
||||||
|
dispatcherOptions.onSkip?.(
|
||||||
|
{ text: "oops", isError: true },
|
||||||
|
{ kind: "final", reason: "empty" },
|
||||||
|
);
|
||||||
|
return { queuedFinal: false };
|
||||||
|
});
|
||||||
|
deliverReplies.mockResolvedValue({ delivered: true });
|
||||||
|
|
||||||
|
await dispatchWithContext({
|
||||||
|
context: createContext(),
|
||||||
|
telegramCfg: { silentErrorReplies: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deliverReplies).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
silent: true,
|
||||||
|
replies: [expect.objectContaining({ text: expect.any(String) })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps block streaming enabled when session reasoning level is on", async () => {
|
it("keeps block streaming enabled when session reasoning level is on", async () => {
|
||||||
loadSessionStore.mockReturnValue({
|
loadSessionStore.mockReturnValue({
|
||||||
s1: { reasoningLevel: "on" },
|
s1: { reasoningLevel: "on" },
|
||||||
|
|||||||
@ -465,6 +465,7 @@ export const dispatchTelegramMessage = async ({
|
|||||||
linkPreview: telegramCfg.linkPreview,
|
linkPreview: telegramCfg.linkPreview,
|
||||||
replyQuoteText,
|
replyQuoteText,
|
||||||
};
|
};
|
||||||
|
const silentErrorReplies = telegramCfg.silentErrorReplies === true;
|
||||||
const applyTextToPayload = (payload: ReplyPayload, text: string): ReplyPayload => {
|
const applyTextToPayload = (payload: ReplyPayload, text: string): ReplyPayload => {
|
||||||
if (payload.text === text) {
|
if (payload.text === text) {
|
||||||
return payload;
|
return payload;
|
||||||
@ -476,6 +477,7 @@ export const dispatchTelegramMessage = async ({
|
|||||||
...deliveryBaseOptions,
|
...deliveryBaseOptions,
|
||||||
replies: [payload],
|
replies: [payload],
|
||||||
onVoiceRecording: sendRecordVoice,
|
onVoiceRecording: sendRecordVoice,
|
||||||
|
silent: silentErrorReplies && payload.isError === true,
|
||||||
});
|
});
|
||||||
if (result.delivered) {
|
if (result.delivered) {
|
||||||
deliveryState.markDelivered();
|
deliveryState.markDelivered();
|
||||||
@ -513,6 +515,7 @@ export const dispatchTelegramMessage = async ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
let queuedFinal = false;
|
let queuedFinal = false;
|
||||||
|
let hadErrorReplyFailureOrSkip = false;
|
||||||
|
|
||||||
if (statusReactionController) {
|
if (statusReactionController) {
|
||||||
void statusReactionController.setThinking();
|
void statusReactionController.setThinking();
|
||||||
@ -539,6 +542,9 @@ export const dispatchTelegramMessage = async ({
|
|||||||
...prefixOptions,
|
...prefixOptions,
|
||||||
typingCallbacks,
|
typingCallbacks,
|
||||||
deliver: async (payload, info) => {
|
deliver: async (payload, info) => {
|
||||||
|
if (payload.isError === true) {
|
||||||
|
hadErrorReplyFailureOrSkip = true;
|
||||||
|
}
|
||||||
if (info.kind === "final") {
|
if (info.kind === "final") {
|
||||||
// Assistant callbacks are fire-and-forget; ensure queued boundary
|
// Assistant callbacks are fire-and-forget; ensure queued boundary
|
||||||
// rotations/partials are applied before final delivery mapping.
|
// rotations/partials are applied before final delivery mapping.
|
||||||
@ -652,7 +658,10 @@ export const dispatchTelegramMessage = async ({
|
|||||||
await flushBufferedFinalAnswer();
|
await flushBufferedFinalAnswer();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSkip: (_payload, info) => {
|
onSkip: (payload, info) => {
|
||||||
|
if (payload.isError === true) {
|
||||||
|
hadErrorReplyFailureOrSkip = true;
|
||||||
|
}
|
||||||
if (info.reason !== "silent") {
|
if (info.reason !== "silent") {
|
||||||
deliveryState.markNonSilentSkip();
|
deliveryState.markNonSilentSkip();
|
||||||
}
|
}
|
||||||
@ -809,6 +818,7 @@ export const dispatchTelegramMessage = async ({
|
|||||||
const result = await deliverReplies({
|
const result = await deliverReplies({
|
||||||
replies: [{ text: fallbackText }],
|
replies: [{ text: fallbackText }],
|
||||||
...deliveryBaseOptions,
|
...deliveryBaseOptions,
|
||||||
|
silent: silentErrorReplies && (dispatchError != null || hadErrorReplyFailureOrSkip),
|
||||||
});
|
});
|
||||||
sentFallback = result.delivered;
|
sentFallback = result.delivered;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -187,18 +187,20 @@ function registerAndResolveStatusHandler(params: {
|
|||||||
cfg: OpenClawConfig;
|
cfg: OpenClawConfig;
|
||||||
allowFrom?: string[];
|
allowFrom?: string[];
|
||||||
groupAllowFrom?: string[];
|
groupAllowFrom?: string[];
|
||||||
|
telegramCfg?: RegisterTelegramNativeCommandsParams["telegramCfg"];
|
||||||
resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"];
|
resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"];
|
||||||
}): {
|
}): {
|
||||||
handler: TelegramCommandHandler;
|
handler: TelegramCommandHandler;
|
||||||
sendMessage: ReturnType<typeof vi.fn>;
|
sendMessage: ReturnType<typeof vi.fn>;
|
||||||
} {
|
} {
|
||||||
const { cfg, allowFrom, groupAllowFrom, resolveTelegramGroupConfig } = params;
|
const { cfg, allowFrom, groupAllowFrom, telegramCfg, resolveTelegramGroupConfig } = params;
|
||||||
return registerAndResolveCommandHandlerBase({
|
return registerAndResolveCommandHandlerBase({
|
||||||
commandName: "status",
|
commandName: "status",
|
||||||
cfg,
|
cfg,
|
||||||
allowFrom: allowFrom ?? ["*"],
|
allowFrom: allowFrom ?? ["*"],
|
||||||
groupAllowFrom: groupAllowFrom ?? [],
|
groupAllowFrom: groupAllowFrom ?? [],
|
||||||
useAccessGroups: true,
|
useAccessGroups: true,
|
||||||
|
telegramCfg,
|
||||||
resolveTelegramGroupConfig,
|
resolveTelegramGroupConfig,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -209,6 +211,7 @@ function registerAndResolveCommandHandlerBase(params: {
|
|||||||
allowFrom: string[];
|
allowFrom: string[];
|
||||||
groupAllowFrom: string[];
|
groupAllowFrom: string[];
|
||||||
useAccessGroups: boolean;
|
useAccessGroups: boolean;
|
||||||
|
telegramCfg?: RegisterTelegramNativeCommandsParams["telegramCfg"];
|
||||||
resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"];
|
resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"];
|
||||||
}): {
|
}): {
|
||||||
handler: TelegramCommandHandler;
|
handler: TelegramCommandHandler;
|
||||||
@ -220,6 +223,7 @@ function registerAndResolveCommandHandlerBase(params: {
|
|||||||
allowFrom,
|
allowFrom,
|
||||||
groupAllowFrom,
|
groupAllowFrom,
|
||||||
useAccessGroups,
|
useAccessGroups,
|
||||||
|
telegramCfg,
|
||||||
resolveTelegramGroupConfig,
|
resolveTelegramGroupConfig,
|
||||||
} = params;
|
} = params;
|
||||||
const commandHandlers = new Map<string, TelegramCommandHandler>();
|
const commandHandlers = new Map<string, TelegramCommandHandler>();
|
||||||
@ -239,6 +243,7 @@ function registerAndResolveCommandHandlerBase(params: {
|
|||||||
allowFrom,
|
allowFrom,
|
||||||
groupAllowFrom,
|
groupAllowFrom,
|
||||||
useAccessGroups,
|
useAccessGroups,
|
||||||
|
telegramCfg,
|
||||||
resolveTelegramGroupConfig,
|
resolveTelegramGroupConfig,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@ -254,6 +259,7 @@ function registerAndResolveCommandHandler(params: {
|
|||||||
allowFrom?: string[];
|
allowFrom?: string[];
|
||||||
groupAllowFrom?: string[];
|
groupAllowFrom?: string[];
|
||||||
useAccessGroups?: boolean;
|
useAccessGroups?: boolean;
|
||||||
|
telegramCfg?: RegisterTelegramNativeCommandsParams["telegramCfg"];
|
||||||
resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"];
|
resolveTelegramGroupConfig?: RegisterTelegramHandlerParams["resolveTelegramGroupConfig"];
|
||||||
}): {
|
}): {
|
||||||
handler: TelegramCommandHandler;
|
handler: TelegramCommandHandler;
|
||||||
@ -265,6 +271,7 @@ function registerAndResolveCommandHandler(params: {
|
|||||||
allowFrom,
|
allowFrom,
|
||||||
groupAllowFrom,
|
groupAllowFrom,
|
||||||
useAccessGroups,
|
useAccessGroups,
|
||||||
|
telegramCfg,
|
||||||
resolveTelegramGroupConfig,
|
resolveTelegramGroupConfig,
|
||||||
} = params;
|
} = params;
|
||||||
return registerAndResolveCommandHandlerBase({
|
return registerAndResolveCommandHandlerBase({
|
||||||
@ -273,6 +280,7 @@ function registerAndResolveCommandHandler(params: {
|
|||||||
allowFrom: allowFrom ?? [],
|
allowFrom: allowFrom ?? [],
|
||||||
groupAllowFrom: groupAllowFrom ?? [],
|
groupAllowFrom: groupAllowFrom ?? [],
|
||||||
useAccessGroups: useAccessGroups ?? true,
|
useAccessGroups: useAccessGroups ?? true,
|
||||||
|
telegramCfg,
|
||||||
resolveTelegramGroupConfig,
|
resolveTelegramGroupConfig,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -443,6 +451,31 @@ describe("registerTelegramNativeCommands — session metadata", () => {
|
|||||||
expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled();
|
expect(deliveryMocks.deliverReplies).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends native command error replies silently when silentErrorReplies is enabled", async () => {
|
||||||
|
replyMocks.dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(
|
||||||
|
async ({ dispatcherOptions }: DispatchReplyWithBufferedBlockDispatcherParams) => {
|
||||||
|
await dispatcherOptions.deliver({ text: "oops", isError: true }, { kind: "final" });
|
||||||
|
return dispatchReplyResult;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const { handler } = registerAndResolveStatusHandler({
|
||||||
|
cfg: {},
|
||||||
|
telegramCfg: { silentErrorReplies: true },
|
||||||
|
});
|
||||||
|
await handler(buildStatusCommandContext());
|
||||||
|
|
||||||
|
const deliveredCall = deliveryMocks.deliverReplies.mock.calls[0]?.[0] as
|
||||||
|
| DeliverRepliesParams
|
||||||
|
| undefined;
|
||||||
|
expect(deliveredCall).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
silent: true,
|
||||||
|
replies: [expect.objectContaining({ isError: true })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("routes Telegram native commands through configured ACP topic bindings", async () => {
|
it("routes Telegram native commands through configured ACP topic bindings", async () => {
|
||||||
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
|
const boundSessionKey = "agent:codex:acp:binding:telegram:default:feedface";
|
||||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(
|
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReturnValue(
|
||||||
|
|||||||
@ -12,6 +12,13 @@ type GetPluginCommandSpecsFn =
|
|||||||
type MatchPluginCommandFn = typeof import("../../../src/plugins/commands.js").matchPluginCommand;
|
type MatchPluginCommandFn = typeof import("../../../src/plugins/commands.js").matchPluginCommand;
|
||||||
type ExecutePluginCommandFn =
|
type ExecutePluginCommandFn =
|
||||||
typeof import("../../../src/plugins/commands.js").executePluginCommand;
|
typeof import("../../../src/plugins/commands.js").executePluginCommand;
|
||||||
|
type DispatchReplyWithBufferedBlockDispatcherFn =
|
||||||
|
typeof import("../../../src/auto-reply/reply/provider-dispatcher.js").dispatchReplyWithBufferedBlockDispatcher;
|
||||||
|
type DispatchReplyWithBufferedBlockDispatcherResult = Awaited<
|
||||||
|
ReturnType<DispatchReplyWithBufferedBlockDispatcherFn>
|
||||||
|
>;
|
||||||
|
type RecordInboundSessionMetaSafeFn =
|
||||||
|
typeof import("../../../src/channels/session-meta.js").recordInboundSessionMetaSafe;
|
||||||
type AnyMock = MockFn<(...args: unknown[]) => unknown>;
|
type AnyMock = MockFn<(...args: unknown[]) => unknown>;
|
||||||
type AnyAsyncMock = MockFn<(...args: unknown[]) => Promise<unknown>>;
|
type AnyAsyncMock = MockFn<(...args: unknown[]) => Promise<unknown>>;
|
||||||
type NativeCommandHarness = {
|
type NativeCommandHarness = {
|
||||||
@ -43,6 +50,37 @@ vi.mock("../../../src/plugins/commands.js", () => ({
|
|||||||
executePluginCommand: pluginCommandMocks.executePluginCommand,
|
executePluginCommand: pluginCommandMocks.executePluginCommand,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const replyPipelineMocks = vi.hoisted(() => {
|
||||||
|
const dispatchReplyResult: DispatchReplyWithBufferedBlockDispatcherResult = {
|
||||||
|
queuedFinal: false,
|
||||||
|
counts: {} as DispatchReplyWithBufferedBlockDispatcherResult["counts"],
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
finalizeInboundContext: vi.fn((ctx: unknown) => ctx),
|
||||||
|
dispatchReplyWithBufferedBlockDispatcher: vi.fn<DispatchReplyWithBufferedBlockDispatcherFn>(
|
||||||
|
async () => dispatchReplyResult,
|
||||||
|
),
|
||||||
|
createReplyPrefixOptions: vi.fn(() => ({ onModelSelected: () => {} })),
|
||||||
|
recordInboundSessionMetaSafe: vi.fn<RecordInboundSessionMetaSafeFn>(async () => undefined),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
export const dispatchReplyWithBufferedBlockDispatcher =
|
||||||
|
replyPipelineMocks.dispatchReplyWithBufferedBlockDispatcher;
|
||||||
|
|
||||||
|
vi.mock("../../../src/auto-reply/reply/inbound-context.js", () => ({
|
||||||
|
finalizeInboundContext: replyPipelineMocks.finalizeInboundContext,
|
||||||
|
}));
|
||||||
|
vi.mock("../../../src/auto-reply/reply/provider-dispatcher.js", () => ({
|
||||||
|
dispatchReplyWithBufferedBlockDispatcher:
|
||||||
|
replyPipelineMocks.dispatchReplyWithBufferedBlockDispatcher,
|
||||||
|
}));
|
||||||
|
vi.mock("../../../src/channels/reply-prefix.js", () => ({
|
||||||
|
createReplyPrefixOptions: replyPipelineMocks.createReplyPrefixOptions,
|
||||||
|
}));
|
||||||
|
vi.mock("../../../src/channels/session-meta.js", () => ({
|
||||||
|
recordInboundSessionMetaSafe: replyPipelineMocks.recordInboundSessionMetaSafe,
|
||||||
|
}));
|
||||||
|
|
||||||
const deliveryMocks = vi.hoisted(() => ({
|
const deliveryMocks = vi.hoisted(() => ({
|
||||||
deliverReplies: vi.fn(async () => {}),
|
deliverReplies: vi.fn(async () => {}),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@ -290,4 +290,56 @@ describe("registerTelegramNativeCommands", () => {
|
|||||||
);
|
);
|
||||||
expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found.");
|
expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found.");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sends plugin command error replies silently when silentErrorReplies is enabled", async () => {
|
||||||
|
const commandHandlers = new Map<string, (ctx: unknown) => Promise<void>>();
|
||||||
|
|
||||||
|
pluginCommandMocks.getPluginCommandSpecs.mockReturnValue([
|
||||||
|
{
|
||||||
|
name: "plug",
|
||||||
|
description: "Plugin command",
|
||||||
|
},
|
||||||
|
] as never);
|
||||||
|
pluginCommandMocks.matchPluginCommand.mockReturnValue({
|
||||||
|
command: { key: "plug", requireAuth: false },
|
||||||
|
args: undefined,
|
||||||
|
} as never);
|
||||||
|
pluginCommandMocks.executePluginCommand.mockResolvedValue({
|
||||||
|
text: "plugin failed",
|
||||||
|
isError: true,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
registerTelegramNativeCommands({
|
||||||
|
...buildParams({}),
|
||||||
|
bot: {
|
||||||
|
api: {
|
||||||
|
setMyCommands: vi.fn().mockResolvedValue(undefined),
|
||||||
|
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
command: vi.fn((name: string, cb: (ctx: unknown) => Promise<void>) => {
|
||||||
|
commandHandlers.set(name, cb);
|
||||||
|
}),
|
||||||
|
} as unknown as Parameters<typeof registerTelegramNativeCommands>[0]["bot"],
|
||||||
|
telegramCfg: { silentErrorReplies: true } as TelegramAccountConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handler = commandHandlers.get("plug");
|
||||||
|
expect(handler).toBeTruthy();
|
||||||
|
await handler?.({
|
||||||
|
match: "",
|
||||||
|
message: {
|
||||||
|
message_id: 1,
|
||||||
|
date: Math.floor(Date.now() / 1000),
|
||||||
|
chat: { id: 123, type: "private" },
|
||||||
|
from: { id: 456, username: "alice" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
silent: true,
|
||||||
|
replies: [expect.objectContaining({ isError: true })],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -363,6 +363,7 @@ export const registerTelegramNativeCommands = ({
|
|||||||
shouldSkipUpdate,
|
shouldSkipUpdate,
|
||||||
opts,
|
opts,
|
||||||
}: RegisterTelegramNativeCommandsParams) => {
|
}: RegisterTelegramNativeCommandsParams) => {
|
||||||
|
const silentErrorReplies = telegramCfg.silentErrorReplies === true;
|
||||||
const boundRoute =
|
const boundRoute =
|
||||||
nativeEnabled && nativeSkillsEnabled
|
nativeEnabled && nativeSkillsEnabled
|
||||||
? resolveAgentRoute({ cfg, channel: "telegram", accountId })
|
? resolveAgentRoute({ cfg, channel: "telegram", accountId })
|
||||||
@ -734,7 +735,6 @@ export const registerTelegramNativeCommands = ({
|
|||||||
typeof telegramCfg.blockStreaming === "boolean"
|
typeof telegramCfg.blockStreaming === "boolean"
|
||||||
? !telegramCfg.blockStreaming
|
? !telegramCfg.blockStreaming
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const deliveryState = {
|
const deliveryState = {
|
||||||
delivered: false,
|
delivered: false,
|
||||||
skippedNonSilent: 0,
|
skippedNonSilent: 0,
|
||||||
@ -766,6 +766,7 @@ export const registerTelegramNativeCommands = ({
|
|||||||
const result = await deliverReplies({
|
const result = await deliverReplies({
|
||||||
replies: [payload],
|
replies: [payload],
|
||||||
...deliveryBaseOptions,
|
...deliveryBaseOptions,
|
||||||
|
silent: silentErrorReplies && payload.isError === true,
|
||||||
});
|
});
|
||||||
if (result.delivered) {
|
if (result.delivered) {
|
||||||
deliveryState.delivered = true;
|
deliveryState.delivered = true;
|
||||||
@ -885,6 +886,7 @@ export const registerTelegramNativeCommands = ({
|
|||||||
await deliverReplies({
|
await deliverReplies({
|
||||||
replies: [result],
|
replies: [result],
|
||||||
...deliveryBaseOptions,
|
...deliveryBaseOptions,
|
||||||
|
silent: silentErrorReplies && result.isError === true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import { rm } from "node:fs/promises";
|
import { rm } from "node:fs/promises";
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../src/channels/plugins/contracts/suites.js";
|
||||||
import {
|
import {
|
||||||
clearPluginInteractiveHandlers,
|
clearPluginInteractiveHandlers,
|
||||||
registerPluginInteractiveHandler,
|
registerPluginInteractiveHandler,
|
||||||
} from "../../../src/plugins/interactive.js";
|
} from "../../../src/plugins/interactive.js";
|
||||||
import type { PluginInteractiveTelegramHandlerContext } from "../../../src/plugins/types.js";
|
import type { PluginInteractiveTelegramHandlerContext } from "../../../src/plugins/types.js";
|
||||||
import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js";
|
import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js";
|
||||||
import { expectInboundContextContract } from "../../../test/helpers/inbound-contract.js";
|
|
||||||
import {
|
import {
|
||||||
answerCallbackQuerySpy,
|
answerCallbackQuerySpy,
|
||||||
commandSpy,
|
commandSpy,
|
||||||
|
|||||||
@ -103,6 +103,7 @@ async function deliverTextReply(params: {
|
|||||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||||
replyQuoteText?: string;
|
replyQuoteText?: string;
|
||||||
linkPreview?: boolean;
|
linkPreview?: boolean;
|
||||||
|
silent?: boolean;
|
||||||
replyToId?: number;
|
replyToId?: number;
|
||||||
replyToMode: ReplyToMode;
|
replyToMode: ReplyToMode;
|
||||||
progress: DeliveryProgress;
|
progress: DeliveryProgress;
|
||||||
@ -129,6 +130,7 @@ async function deliverTextReply(params: {
|
|||||||
textMode: "html",
|
textMode: "html",
|
||||||
plainText: chunk.text,
|
plainText: chunk.text,
|
||||||
linkPreview: params.linkPreview,
|
linkPreview: params.linkPreview,
|
||||||
|
silent: params.silent,
|
||||||
replyMarkup,
|
replyMarkup,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -149,6 +151,7 @@ async function sendPendingFollowUpText(params: {
|
|||||||
text: string;
|
text: string;
|
||||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||||
linkPreview?: boolean;
|
linkPreview?: boolean;
|
||||||
|
silent?: boolean;
|
||||||
replyToId?: number;
|
replyToId?: number;
|
||||||
replyToMode: ReplyToMode;
|
replyToMode: ReplyToMode;
|
||||||
progress: DeliveryProgress;
|
progress: DeliveryProgress;
|
||||||
@ -167,6 +170,7 @@ async function sendPendingFollowUpText(params: {
|
|||||||
textMode: "html",
|
textMode: "html",
|
||||||
plainText: chunk.text,
|
plainText: chunk.text,
|
||||||
linkPreview: params.linkPreview,
|
linkPreview: params.linkPreview,
|
||||||
|
silent: params.silent,
|
||||||
replyMarkup,
|
replyMarkup,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@ -196,6 +200,7 @@ async function sendTelegramVoiceFallbackText(opts: {
|
|||||||
replyToId?: number;
|
replyToId?: number;
|
||||||
thread?: TelegramThreadSpec | null;
|
thread?: TelegramThreadSpec | null;
|
||||||
linkPreview?: boolean;
|
linkPreview?: boolean;
|
||||||
|
silent?: boolean;
|
||||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||||
replyQuoteText?: string;
|
replyQuoteText?: string;
|
||||||
}): Promise<number | undefined> {
|
}): Promise<number | undefined> {
|
||||||
@ -213,6 +218,7 @@ async function sendTelegramVoiceFallbackText(opts: {
|
|||||||
textMode: "html",
|
textMode: "html",
|
||||||
plainText: chunk.text,
|
plainText: chunk.text,
|
||||||
linkPreview: opts.linkPreview,
|
linkPreview: opts.linkPreview,
|
||||||
|
silent: opts.silent,
|
||||||
replyMarkup: !appliedReplyTo ? opts.replyMarkup : undefined,
|
replyMarkup: !appliedReplyTo ? opts.replyMarkup : undefined,
|
||||||
});
|
});
|
||||||
if (firstDeliveredMessageId == null) {
|
if (firstDeliveredMessageId == null) {
|
||||||
@ -237,6 +243,7 @@ async function deliverMediaReply(params: {
|
|||||||
chunkText: ChunkTextFn;
|
chunkText: ChunkTextFn;
|
||||||
onVoiceRecording?: () => Promise<void> | void;
|
onVoiceRecording?: () => Promise<void> | void;
|
||||||
linkPreview?: boolean;
|
linkPreview?: boolean;
|
||||||
|
silent?: boolean;
|
||||||
replyQuoteText?: string;
|
replyQuoteText?: string;
|
||||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||||
replyToId?: number;
|
replyToId?: number;
|
||||||
@ -282,6 +289,7 @@ async function deliverMediaReply(params: {
|
|||||||
...buildTelegramSendParams({
|
...buildTelegramSendParams({
|
||||||
replyToMessageId,
|
replyToMessageId,
|
||||||
thread: params.thread,
|
thread: params.thread,
|
||||||
|
silent: params.silent,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
if (isGif) {
|
if (isGif) {
|
||||||
@ -375,6 +383,7 @@ async function deliverMediaReply(params: {
|
|||||||
replyToId: voiceFallbackReplyTo,
|
replyToId: voiceFallbackReplyTo,
|
||||||
thread: params.thread,
|
thread: params.thread,
|
||||||
linkPreview: params.linkPreview,
|
linkPreview: params.linkPreview,
|
||||||
|
silent: params.silent,
|
||||||
replyMarkup: params.replyMarkup,
|
replyMarkup: params.replyMarkup,
|
||||||
replyQuoteText: params.replyQuoteText,
|
replyQuoteText: params.replyQuoteText,
|
||||||
});
|
});
|
||||||
@ -404,6 +413,7 @@ async function deliverMediaReply(params: {
|
|||||||
replyToId: undefined,
|
replyToId: undefined,
|
||||||
thread: params.thread,
|
thread: params.thread,
|
||||||
linkPreview: params.linkPreview,
|
linkPreview: params.linkPreview,
|
||||||
|
silent: params.silent,
|
||||||
replyMarkup: params.replyMarkup,
|
replyMarkup: params.replyMarkup,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -451,6 +461,7 @@ async function deliverMediaReply(params: {
|
|||||||
text: pendingFollowUpText,
|
text: pendingFollowUpText,
|
||||||
replyMarkup: params.replyMarkup,
|
replyMarkup: params.replyMarkup,
|
||||||
linkPreview: params.linkPreview,
|
linkPreview: params.linkPreview,
|
||||||
|
silent: params.silent,
|
||||||
replyToId: params.replyToId,
|
replyToId: params.replyToId,
|
||||||
replyToMode: params.replyToMode,
|
replyToMode: params.replyToMode,
|
||||||
progress: params.progress,
|
progress: params.progress,
|
||||||
@ -557,6 +568,8 @@ export async function deliverReplies(params: {
|
|||||||
onVoiceRecording?: () => Promise<void> | void;
|
onVoiceRecording?: () => Promise<void> | void;
|
||||||
/** Controls whether link previews are shown. Default: true (previews enabled). */
|
/** Controls whether link previews are shown. Default: true (previews enabled). */
|
||||||
linkPreview?: boolean;
|
linkPreview?: boolean;
|
||||||
|
/** When true, messages are sent with disable_notification. */
|
||||||
|
silent?: boolean;
|
||||||
/** Optional quote text for Telegram reply_parameters. */
|
/** Optional quote text for Telegram reply_parameters. */
|
||||||
replyQuoteText?: string;
|
replyQuoteText?: string;
|
||||||
}): Promise<{ delivered: boolean }> {
|
}): Promise<{ delivered: boolean }> {
|
||||||
@ -637,6 +650,7 @@ export async function deliverReplies(params: {
|
|||||||
replyMarkup,
|
replyMarkup,
|
||||||
replyQuoteText: params.replyQuoteText,
|
replyQuoteText: params.replyQuoteText,
|
||||||
linkPreview: params.linkPreview,
|
linkPreview: params.linkPreview,
|
||||||
|
silent: params.silent,
|
||||||
replyToId,
|
replyToId,
|
||||||
replyToMode: params.replyToMode,
|
replyToMode: params.replyToMode,
|
||||||
progress,
|
progress,
|
||||||
@ -654,6 +668,7 @@ export async function deliverReplies(params: {
|
|||||||
chunkText,
|
chunkText,
|
||||||
onVoiceRecording: params.onVoiceRecording,
|
onVoiceRecording: params.onVoiceRecording,
|
||||||
linkPreview: params.linkPreview,
|
linkPreview: params.linkPreview,
|
||||||
|
silent: params.silent,
|
||||||
replyQuoteText: params.replyQuoteText,
|
replyQuoteText: params.replyQuoteText,
|
||||||
replyMarkup,
|
replyMarkup,
|
||||||
replyToId,
|
replyToId,
|
||||||
|
|||||||
@ -76,6 +76,7 @@ export async function sendTelegramWithThreadFallback<T>(params: {
|
|||||||
export function buildTelegramSendParams(opts?: {
|
export function buildTelegramSendParams(opts?: {
|
||||||
replyToMessageId?: number;
|
replyToMessageId?: number;
|
||||||
thread?: TelegramThreadSpec | null;
|
thread?: TelegramThreadSpec | null;
|
||||||
|
silent?: boolean;
|
||||||
}): Record<string, unknown> {
|
}): Record<string, unknown> {
|
||||||
const threadParams = buildTelegramThreadParams(opts?.thread);
|
const threadParams = buildTelegramThreadParams(opts?.thread);
|
||||||
const params: Record<string, unknown> = {};
|
const params: Record<string, unknown> = {};
|
||||||
@ -85,6 +86,9 @@ export function buildTelegramSendParams(opts?: {
|
|||||||
if (threadParams) {
|
if (threadParams) {
|
||||||
params.message_thread_id = threadParams.message_thread_id;
|
params.message_thread_id = threadParams.message_thread_id;
|
||||||
}
|
}
|
||||||
|
if (opts?.silent === true) {
|
||||||
|
params.disable_notification = true;
|
||||||
|
}
|
||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -100,12 +104,14 @@ export async function sendTelegramText(
|
|||||||
textMode?: "markdown" | "html";
|
textMode?: "markdown" | "html";
|
||||||
plainText?: string;
|
plainText?: string;
|
||||||
linkPreview?: boolean;
|
linkPreview?: boolean;
|
||||||
|
silent?: boolean;
|
||||||
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
||||||
},
|
},
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
const baseParams = buildTelegramSendParams({
|
const baseParams = buildTelegramSendParams({
|
||||||
replyToMessageId: opts?.replyToMessageId,
|
replyToMessageId: opts?.replyToMessageId,
|
||||||
thread: opts?.thread,
|
thread: opts?.thread,
|
||||||
|
silent: opts?.silent,
|
||||||
});
|
});
|
||||||
// Add link_preview_options when link preview is disabled.
|
// Add link_preview_options when link preview is disabled.
|
||||||
const linkPreviewEnabled = opts?.linkPreview ?? true;
|
const linkPreviewEnabled = opts?.linkPreview ?? true;
|
||||||
|
|||||||
@ -211,6 +211,30 @@ describe("deliverReplies", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sets disable_notification when silent is true", async () => {
|
||||||
|
const runtime = createRuntime();
|
||||||
|
const sendMessage = vi.fn().mockResolvedValue({
|
||||||
|
message_id: 5,
|
||||||
|
chat: { id: "123" },
|
||||||
|
});
|
||||||
|
const bot = createBot({ sendMessage });
|
||||||
|
|
||||||
|
await deliverWith({
|
||||||
|
replies: [{ text: "hello" }],
|
||||||
|
runtime,
|
||||||
|
bot,
|
||||||
|
silent: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendMessage).toHaveBeenCalledWith(
|
||||||
|
"123",
|
||||||
|
expect.any(String),
|
||||||
|
expect.objectContaining({
|
||||||
|
disable_notification: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("emits internal message:sent when session hook context is available", async () => {
|
it("emits internal message:sent when session hook context is available", async () => {
|
||||||
const runtime = createRuntime(false);
|
const runtime = createRuntime(false);
|
||||||
const sendMessage = vi.fn().mockResolvedValue({ message_id: 9, chat: { id: "123" } });
|
const sendMessage = vi.fn().mockResolvedValue({ message_id: 9, chat: { id: "123" } });
|
||||||
@ -645,6 +669,36 @@ describe("deliverReplies", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps disable_notification on voice fallback text when silent is true", async () => {
|
||||||
|
const runtime = createRuntime();
|
||||||
|
const sendVoice = vi.fn().mockRejectedValue(createVoiceMessagesForbiddenError());
|
||||||
|
const sendMessage = vi.fn().mockResolvedValue({
|
||||||
|
message_id: 5,
|
||||||
|
chat: { id: "123" },
|
||||||
|
});
|
||||||
|
const bot = createBot({ sendVoice, sendMessage });
|
||||||
|
|
||||||
|
mockMediaLoad("note.ogg", "audio/ogg", "voice");
|
||||||
|
|
||||||
|
await deliverWith({
|
||||||
|
replies: [
|
||||||
|
{ mediaUrl: "https://example.com/note.ogg", text: "Hello there", audioAsVoice: true },
|
||||||
|
],
|
||||||
|
runtime,
|
||||||
|
bot,
|
||||||
|
silent: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendVoice).toHaveBeenCalledTimes(1);
|
||||||
|
expect(sendMessage).toHaveBeenCalledWith(
|
||||||
|
"123",
|
||||||
|
expect.stringContaining("Hello there"),
|
||||||
|
expect.objectContaining({
|
||||||
|
disable_notification: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("voice fallback applies reply-to only on first chunk when replyToMode is first", async () => {
|
it("voice fallback applies reply-to only on first chunk when replyToMode is first", async () => {
|
||||||
const { runtime, sendVoice, sendMessage, bot } = createVoiceFailureHarness({
|
const { runtime, sendVoice, sendMessage, bot } = createVoiceFailureHarness({
|
||||||
voiceError: createVoiceMessagesForbiddenError(),
|
voiceError: createVoiceMessagesForbiddenError(),
|
||||||
|
|||||||
@ -4,10 +4,13 @@ import type {
|
|||||||
OpenClawConfig,
|
OpenClawConfig,
|
||||||
PluginRuntime,
|
PluginRuntime,
|
||||||
} from "openclaw/plugin-sdk/telegram";
|
} from "openclaw/plugin-sdk/telegram";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { createRuntimeEnv } from "../../test-utils/runtime-env.js";
|
import { createRuntimeEnv } from "../../test-utils/runtime-env.js";
|
||||||
import type { ResolvedTelegramAccount } from "./accounts.js";
|
import type { ResolvedTelegramAccount } from "./accounts.js";
|
||||||
|
import * as auditModule from "./audit.js";
|
||||||
import { telegramPlugin } from "./channel.js";
|
import { telegramPlugin } from "./channel.js";
|
||||||
|
import * as monitorModule from "./monitor.js";
|
||||||
|
import * as probeModule from "./probe.js";
|
||||||
import { setTelegramRuntime } from "./runtime.js";
|
import { setTelegramRuntime } from "./runtime.js";
|
||||||
|
|
||||||
function createCfg(): OpenClawConfig {
|
function createCfg(): OpenClawConfig {
|
||||||
@ -53,32 +56,34 @@ function createStartAccountCtx(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function installGatewayRuntime(params?: { probeOk?: boolean; botUsername?: string }) {
|
function installGatewayRuntime(params?: { probeOk?: boolean; botUsername?: string }) {
|
||||||
const monitorTelegramProvider = vi.fn(async () => undefined);
|
const monitorTelegramProvider = vi
|
||||||
const probeTelegram = vi.fn(async () =>
|
.spyOn(monitorModule, "monitorTelegramProvider")
|
||||||
params?.probeOk ? { ok: true, bot: { username: params.botUsername ?? "bot" } } : { ok: false },
|
.mockImplementation(async () => undefined);
|
||||||
);
|
const probeTelegram = vi
|
||||||
const collectUnmentionedGroupIds = vi.fn(() => ({
|
.spyOn(probeModule, "probeTelegram")
|
||||||
groupIds: [] as string[],
|
.mockImplementation(async () =>
|
||||||
unresolvedGroups: 0,
|
params?.probeOk
|
||||||
hasWildcardUnmentionedGroups: false,
|
? { ok: true, bot: { username: params.botUsername ?? "bot" }, elapsedMs: 0 }
|
||||||
}));
|
: { ok: false, elapsedMs: 0 },
|
||||||
const auditGroupMembership = vi.fn(async () => ({
|
);
|
||||||
ok: true,
|
const collectUnmentionedGroupIds = vi
|
||||||
checkedGroups: 0,
|
.spyOn(auditModule, "collectTelegramUnmentionedGroupIds")
|
||||||
unresolvedGroups: 0,
|
.mockImplementation(() => ({
|
||||||
hasWildcardUnmentionedGroups: false,
|
groupIds: [] as string[],
|
||||||
groups: [],
|
unresolvedGroups: 0,
|
||||||
elapsedMs: 0,
|
hasWildcardUnmentionedGroups: false,
|
||||||
}));
|
}));
|
||||||
|
const auditGroupMembership = vi
|
||||||
|
.spyOn(auditModule, "auditTelegramGroupMembership")
|
||||||
|
.mockImplementation(async () => ({
|
||||||
|
ok: true,
|
||||||
|
checkedGroups: 0,
|
||||||
|
unresolvedGroups: 0,
|
||||||
|
hasWildcardUnmentionedGroups: false,
|
||||||
|
groups: [],
|
||||||
|
elapsedMs: 0,
|
||||||
|
}));
|
||||||
setTelegramRuntime({
|
setTelegramRuntime({
|
||||||
channel: {
|
|
||||||
telegram: {
|
|
||||||
monitorTelegramProvider,
|
|
||||||
probeTelegram,
|
|
||||||
collectUnmentionedGroupIds,
|
|
||||||
auditGroupMembership,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
logging: {
|
logging: {
|
||||||
shouldLogVerbose: () => false,
|
shouldLogVerbose: () => false,
|
||||||
},
|
},
|
||||||
@ -115,6 +120,10 @@ function installSendMessageRuntime(
|
|||||||
return sendMessageTelegram;
|
return sendMessageTelegram;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
describe("telegramPlugin duplicate token guard", () => {
|
describe("telegramPlugin duplicate token guard", () => {
|
||||||
it("marks secondary account as not configured when token is shared", async () => {
|
it("marks secondary account as not configured when token is shared", async () => {
|
||||||
const cfg = createCfg();
|
const cfg = createCfg();
|
||||||
|
|||||||
@ -47,15 +47,17 @@ import {
|
|||||||
type ResolvedTelegramAccount,
|
type ResolvedTelegramAccount,
|
||||||
} from "./accounts.js";
|
} from "./accounts.js";
|
||||||
import { buildTelegramExecApprovalButtons } from "./approval-buttons.js";
|
import { buildTelegramExecApprovalButtons } from "./approval-buttons.js";
|
||||||
|
import { auditTelegramGroupMembership, collectTelegramUnmentionedGroupIds } from "./audit.js";
|
||||||
import { buildTelegramGroupPeerId } from "./bot/helpers.js";
|
import { buildTelegramGroupPeerId } from "./bot/helpers.js";
|
||||||
import {
|
import {
|
||||||
isTelegramExecApprovalClientEnabled,
|
isTelegramExecApprovalClientEnabled,
|
||||||
resolveTelegramExecApprovalTarget,
|
resolveTelegramExecApprovalTarget,
|
||||||
} from "./exec-approvals.js";
|
} from "./exec-approvals.js";
|
||||||
|
import { monitorTelegramProvider } from "./monitor.js";
|
||||||
import { looksLikeTelegramTargetId, normalizeTelegramMessagingTarget } from "./normalize.js";
|
import { looksLikeTelegramTargetId, normalizeTelegramMessagingTarget } from "./normalize.js";
|
||||||
import { sendTelegramPayloadMessages } from "./outbound-adapter.js";
|
import { sendTelegramPayloadMessages } from "./outbound-adapter.js";
|
||||||
import { parseTelegramReplyToMessageId, parseTelegramThreadId } from "./outbound-params.js";
|
import { parseTelegramReplyToMessageId, parseTelegramThreadId } from "./outbound-params.js";
|
||||||
import type { TelegramProbe } from "./probe.js";
|
import { probeTelegram, type TelegramProbe } from "./probe.js";
|
||||||
import { getTelegramRuntime } from "./runtime.js";
|
import { getTelegramRuntime } from "./runtime.js";
|
||||||
import { sendTypingTelegram } from "./send.js";
|
import { sendTypingTelegram } from "./send.js";
|
||||||
import { telegramSetupAdapter } from "./setup-core.js";
|
import { telegramSetupAdapter } from "./setup-core.js";
|
||||||
@ -697,7 +699,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
|||||||
collectStatusIssues: collectTelegramStatusIssues,
|
collectStatusIssues: collectTelegramStatusIssues,
|
||||||
buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot),
|
buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot),
|
||||||
probeAccount: async ({ account, timeoutMs }) =>
|
probeAccount: async ({ account, timeoutMs }) =>
|
||||||
getTelegramRuntime().channel.telegram.probeTelegram(account.token, timeoutMs, {
|
probeTelegram(account.token, timeoutMs, {
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
proxyUrl: account.config.proxy,
|
proxyUrl: account.config.proxy,
|
||||||
network: account.config.network,
|
network: account.config.network,
|
||||||
@ -731,7 +733,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
|||||||
cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ??
|
cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ??
|
||||||
cfg.channels?.telegram?.groups;
|
cfg.channels?.telegram?.groups;
|
||||||
const { groupIds, unresolvedGroups, hasWildcardUnmentionedGroups } =
|
const { groupIds, unresolvedGroups, hasWildcardUnmentionedGroups } =
|
||||||
getTelegramRuntime().channel.telegram.collectUnmentionedGroupIds(groups);
|
collectTelegramUnmentionedGroupIds(groups);
|
||||||
if (!groupIds.length && unresolvedGroups === 0 && !hasWildcardUnmentionedGroups) {
|
if (!groupIds.length && unresolvedGroups === 0 && !hasWildcardUnmentionedGroups) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -746,7 +748,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
|||||||
elapsedMs: 0,
|
elapsedMs: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const audit = await getTelegramRuntime().channel.telegram.auditGroupMembership({
|
const audit = await auditTelegramGroupMembership({
|
||||||
token: account.token,
|
token: account.token,
|
||||||
botId,
|
botId,
|
||||||
groupIds,
|
groupIds,
|
||||||
@ -815,7 +817,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
|||||||
const token = (account.token ?? "").trim();
|
const token = (account.token ?? "").trim();
|
||||||
let telegramBotLabel = "";
|
let telegramBotLabel = "";
|
||||||
try {
|
try {
|
||||||
const probe = await getTelegramRuntime().channel.telegram.probeTelegram(token, 2500, {
|
const probe = await probeTelegram(token, 2500, {
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
proxyUrl: account.config.proxy,
|
proxyUrl: account.config.proxy,
|
||||||
network: account.config.network,
|
network: account.config.network,
|
||||||
@ -830,7 +832,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx.log?.info(`[${account.accountId}] starting provider${telegramBotLabel}`);
|
ctx.log?.info(`[${account.accountId}] starting provider${telegramBotLabel}`);
|
||||||
return getTelegramRuntime().channel.telegram.monitorTelegramProvider({
|
return monitorTelegramProvider({
|
||||||
token,
|
token,
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
config: ctx.cfg,
|
config: ctx.cfg,
|
||||||
|
|||||||
@ -1,13 +0,0 @@
|
|||||||
import { describe } from "vitest";
|
|
||||||
import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../../src/test-utils/runtime-group-policy-contract.js";
|
|
||||||
import { resolveTelegramRuntimeGroupPolicy } from "./group-access.js";
|
|
||||||
|
|
||||||
describe("resolveTelegramRuntimeGroupPolicy", () => {
|
|
||||||
installProviderRuntimeGroupPolicyFallbackSuite({
|
|
||||||
resolve: resolveTelegramRuntimeGroupPolicy,
|
|
||||||
configuredLabel: "keeps open fallback when channels.telegram is configured",
|
|
||||||
defaultGroupPolicyUnderTest: "disabled",
|
|
||||||
missingConfigLabel: "fails closed when channels.telegram is missing and no defaults are set",
|
|
||||||
missingDefaultLabel: "ignores explicit defaults when provider config is missing",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
||||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||||
|
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||||
|
|
||||||
const { setRuntime: setTelegramRuntime, getRuntime: getTelegramRuntime } =
|
const { setRuntime: setTelegramRuntime, getRuntime: getTelegramRuntime } =
|
||||||
createPluginRuntimeStore<PluginRuntime>("Telegram runtime not initialized");
|
createPluginRuntimeStore<PluginRuntime>("Telegram runtime not initialized");
|
||||||
|
|||||||
@ -1,15 +1,20 @@
|
|||||||
import {
|
import {
|
||||||
buildVllmProvider,
|
|
||||||
configureOpenAICompatibleSelfHostedProviderNonInteractive,
|
|
||||||
discoverOpenAICompatibleSelfHostedProvider,
|
|
||||||
emptyPluginConfigSchema,
|
emptyPluginConfigSchema,
|
||||||
promptAndConfigureOpenAICompatibleSelfHostedProviderAuth,
|
|
||||||
type OpenClawPluginApi,
|
type OpenClawPluginApi,
|
||||||
type ProviderAuthMethodNonInteractiveContext,
|
type ProviderAuthMethodNonInteractiveContext,
|
||||||
} from "openclaw/plugin-sdk/core";
|
} from "openclaw/plugin-sdk/core";
|
||||||
|
import {
|
||||||
|
VLLM_DEFAULT_API_KEY_ENV_VAR,
|
||||||
|
VLLM_DEFAULT_BASE_URL,
|
||||||
|
VLLM_MODEL_PLACEHOLDER,
|
||||||
|
VLLM_PROVIDER_LABEL,
|
||||||
|
} from "../../src/agents/vllm-defaults.js";
|
||||||
|
|
||||||
const PROVIDER_ID = "vllm";
|
const PROVIDER_ID = "vllm";
|
||||||
const DEFAULT_BASE_URL = "http://127.0.0.1:8000/v1";
|
|
||||||
|
async function loadProviderSetup() {
|
||||||
|
return await import("openclaw/plugin-sdk/self-hosted-provider-setup");
|
||||||
|
}
|
||||||
|
|
||||||
const vllmPlugin = {
|
const vllmPlugin = {
|
||||||
id: "vllm",
|
id: "vllm",
|
||||||
@ -25,38 +30,44 @@ const vllmPlugin = {
|
|||||||
auth: [
|
auth: [
|
||||||
{
|
{
|
||||||
id: "custom",
|
id: "custom",
|
||||||
label: "vLLM",
|
label: VLLM_PROVIDER_LABEL,
|
||||||
hint: "Local/self-hosted OpenAI-compatible server",
|
hint: "Local/self-hosted OpenAI-compatible server",
|
||||||
kind: "custom",
|
kind: "custom",
|
||||||
run: async (ctx) =>
|
run: async (ctx) => {
|
||||||
promptAndConfigureOpenAICompatibleSelfHostedProviderAuth({
|
const providerSetup = await loadProviderSetup();
|
||||||
|
return await providerSetup.promptAndConfigureOpenAICompatibleSelfHostedProviderAuth({
|
||||||
cfg: ctx.config,
|
cfg: ctx.config,
|
||||||
prompter: ctx.prompter,
|
prompter: ctx.prompter,
|
||||||
providerId: PROVIDER_ID,
|
providerId: PROVIDER_ID,
|
||||||
providerLabel: "vLLM",
|
providerLabel: VLLM_PROVIDER_LABEL,
|
||||||
defaultBaseUrl: DEFAULT_BASE_URL,
|
defaultBaseUrl: VLLM_DEFAULT_BASE_URL,
|
||||||
defaultApiKeyEnvVar: "VLLM_API_KEY",
|
defaultApiKeyEnvVar: VLLM_DEFAULT_API_KEY_ENV_VAR,
|
||||||
modelPlaceholder: "meta-llama/Meta-Llama-3-8B-Instruct",
|
modelPlaceholder: VLLM_MODEL_PLACEHOLDER,
|
||||||
}),
|
});
|
||||||
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) =>
|
},
|
||||||
configureOpenAICompatibleSelfHostedProviderNonInteractive({
|
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => {
|
||||||
|
const providerSetup = await loadProviderSetup();
|
||||||
|
return await providerSetup.configureOpenAICompatibleSelfHostedProviderNonInteractive({
|
||||||
ctx,
|
ctx,
|
||||||
providerId: PROVIDER_ID,
|
providerId: PROVIDER_ID,
|
||||||
providerLabel: "vLLM",
|
providerLabel: VLLM_PROVIDER_LABEL,
|
||||||
defaultBaseUrl: DEFAULT_BASE_URL,
|
defaultBaseUrl: VLLM_DEFAULT_BASE_URL,
|
||||||
defaultApiKeyEnvVar: "VLLM_API_KEY",
|
defaultApiKeyEnvVar: VLLM_DEFAULT_API_KEY_ENV_VAR,
|
||||||
modelPlaceholder: "meta-llama/Meta-Llama-3-8B-Instruct",
|
modelPlaceholder: VLLM_MODEL_PLACEHOLDER,
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
discovery: {
|
discovery: {
|
||||||
order: "late",
|
order: "late",
|
||||||
run: async (ctx) =>
|
run: async (ctx) => {
|
||||||
discoverOpenAICompatibleSelfHostedProvider({
|
const providerSetup = await loadProviderSetup();
|
||||||
|
return await providerSetup.discoverOpenAICompatibleSelfHostedProvider({
|
||||||
ctx,
|
ctx,
|
||||||
providerId: PROVIDER_ID,
|
providerId: PROVIDER_ID,
|
||||||
buildProvider: buildVllmProvider,
|
buildProvider: providerSetup.buildVllmProvider,
|
||||||
}),
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
wizard: {
|
wizard: {
|
||||||
setup: {
|
setup: {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/core";
|
||||||
import { whatsappPlugin } from "./src/channel.js";
|
import { whatsappPlugin } from "./src/channel.js";
|
||||||
import { setWhatsAppRuntime } from "./src/runtime.js";
|
import { setWhatsAppRuntime } from "./src/runtime.js";
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import fs from "node:fs/promises";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { expectInboundContextContract } from "../../../../../test/helpers/inbound-contract.js";
|
import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../../../src/channels/plugins/contracts/suites.js";
|
||||||
|
|
||||||
let capturedCtx: unknown;
|
let capturedCtx: unknown;
|
||||||
let capturedDispatchParams: unknown;
|
let capturedDispatchParams: unknown;
|
||||||
|
|||||||
@ -1,13 +0,0 @@
|
|||||||
import { describe } from "vitest";
|
|
||||||
import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../../../src/test-utils/runtime-group-policy-contract.js";
|
|
||||||
import { __testing } from "./access-control.js";
|
|
||||||
|
|
||||||
describe("resolveWhatsAppRuntimeGroupPolicy", () => {
|
|
||||||
installProviderRuntimeGroupPolicyFallbackSuite({
|
|
||||||
resolve: __testing.resolveWhatsAppRuntimeGroupPolicy,
|
|
||||||
configuredLabel: "keeps open fallback when channels.whatsapp is configured",
|
|
||||||
defaultGroupPolicyUnderTest: "disabled",
|
|
||||||
missingConfigLabel: "fails closed when channels.whatsapp is missing and no defaults are set",
|
|
||||||
missingDefaultLabel: "ignores explicit default policy when provider config is missing",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,40 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { ReplyPayload } from "../../../src/auto-reply/types.js";
|
|
||||||
import {
|
|
||||||
installSendPayloadContractSuite,
|
|
||||||
primeSendMock,
|
|
||||||
} from "../../../src/test-utils/send-payload-contract.js";
|
|
||||||
import { whatsappOutbound } from "./outbound-adapter.js";
|
import { whatsappOutbound } from "./outbound-adapter.js";
|
||||||
|
|
||||||
function createHarness(params: {
|
|
||||||
payload: ReplyPayload;
|
|
||||||
sendResults?: Array<{ messageId: string }>;
|
|
||||||
}) {
|
|
||||||
const sendWhatsApp = vi.fn();
|
|
||||||
primeSendMock(sendWhatsApp, { messageId: "wa-1" }, params.sendResults);
|
|
||||||
const ctx = {
|
|
||||||
cfg: {},
|
|
||||||
to: "5511999999999@c.us",
|
|
||||||
text: "",
|
|
||||||
payload: params.payload,
|
|
||||||
deps: {
|
|
||||||
sendWhatsApp,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
run: async () => await whatsappOutbound.sendPayload!(ctx),
|
|
||||||
sendMock: sendWhatsApp,
|
|
||||||
to: ctx.to,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("whatsappOutbound sendPayload", () => {
|
describe("whatsappOutbound sendPayload", () => {
|
||||||
installSendPayloadContractSuite({
|
|
||||||
channel: "whatsapp",
|
|
||||||
chunking: { mode: "split", longTextLength: 5000, maxChunkLength: 4000 },
|
|
||||||
createHarness,
|
|
||||||
});
|
|
||||||
|
|
||||||
it("trims leading whitespace for direct text sends", async () => {
|
it("trims leading whitespace for direct text sends", async () => {
|
||||||
const sendWhatsApp = vi.fn(async () => ({ messageId: "wa-1", toJid: "jid" }));
|
const sendWhatsApp = vi.fn(async () => ({ messageId: "wa-1", toJid: "jid" }));
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
||||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||||
|
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||||
|
|
||||||
const { setRuntime: setWhatsAppRuntime, getRuntime: getWhatsAppRuntime } =
|
const { setRuntime: setWhatsAppRuntime, getRuntime: getWhatsAppRuntime } =
|
||||||
createPluginRuntimeStore<PluginRuntime>("WhatsApp runtime not initialized");
|
createPluginRuntimeStore<PluginRuntime>("WhatsApp runtime not initialized");
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { normalizeProviderId } from "../../src/agents/model-selection.js";
|
import { normalizeProviderId } from "../../src/agents/provider-id.js";
|
||||||
import {
|
import {
|
||||||
createPluginBackedWebSearchProvider,
|
createPluginBackedWebSearchProvider,
|
||||||
getScopedCredentialValue,
|
getScopedCredentialValue,
|
||||||
|
|||||||
@ -1,44 +0,0 @@
|
|||||||
import type { ReplyPayload } from "openclaw/plugin-sdk/zalo";
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import {
|
|
||||||
installSendPayloadContractSuite,
|
|
||||||
primeSendMock,
|
|
||||||
} from "../../../src/test-utils/send-payload-contract.js";
|
|
||||||
import { zaloPlugin } from "./channel.js";
|
|
||||||
|
|
||||||
vi.mock("./send.js", () => ({
|
|
||||||
sendMessageZalo: vi.fn().mockResolvedValue({ ok: true, messageId: "zl-1" }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
function baseCtx(payload: ReplyPayload) {
|
|
||||||
return {
|
|
||||||
cfg: {},
|
|
||||||
to: "123456789",
|
|
||||||
text: "",
|
|
||||||
payload,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("zaloPlugin outbound sendPayload", () => {
|
|
||||||
let mockedSend: ReturnType<typeof vi.mocked<(typeof import("./send.js"))["sendMessageZalo"]>>;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const mod = await import("./send.js");
|
|
||||||
mockedSend = vi.mocked(mod.sendMessageZalo);
|
|
||||||
mockedSend.mockClear();
|
|
||||||
mockedSend.mockResolvedValue({ ok: true, messageId: "zl-1" });
|
|
||||||
});
|
|
||||||
|
|
||||||
installSendPayloadContractSuite({
|
|
||||||
channel: "zalo",
|
|
||||||
chunking: { mode: "split", longTextLength: 3000, maxChunkLength: 2000 },
|
|
||||||
createHarness: ({ payload, sendResults }) => {
|
|
||||||
primeSendMock(mockedSend, { ok: true, messageId: "zl-1" }, sendResults);
|
|
||||||
return {
|
|
||||||
run: async () => await zaloPlugin.outbound!.sendPayload!(baseCtx(payload)),
|
|
||||||
sendMock: mockedSend,
|
|
||||||
to: "123456789",
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -2,18 +2,6 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { __testing } from "./monitor.js";
|
import { __testing } from "./monitor.js";
|
||||||
|
|
||||||
describe("zalo group policy access", () => {
|
describe("zalo group policy access", () => {
|
||||||
it("defaults missing provider config to allowlist", () => {
|
|
||||||
const resolved = __testing.resolveZaloRuntimeGroupPolicy({
|
|
||||||
providerConfigPresent: false,
|
|
||||||
groupPolicy: undefined,
|
|
||||||
defaultGroupPolicy: "open",
|
|
||||||
});
|
|
||||||
expect(resolved).toEqual({
|
|
||||||
groupPolicy: "allowlist",
|
|
||||||
providerMissingFallbackApplied: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("blocks all group messages when policy is disabled", () => {
|
it("blocks all group messages when policy is disabled", () => {
|
||||||
const decision = __testing.evaluateZaloGroupAccess({
|
const decision = __testing.evaluateZaloGroupAccess({
|
||||||
providerConfigPresent: true,
|
providerConfigPresent: true,
|
||||||
|
|||||||
@ -1,10 +1,7 @@
|
|||||||
import type { ReplyPayload } from "openclaw/plugin-sdk/zalouser";
|
import type { ReplyPayload } from "openclaw/plugin-sdk/zalouser";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import "./accounts.test-mocks.js";
|
import "./accounts.test-mocks.js";
|
||||||
import {
|
import { primeChannelOutboundSendMock } from "../../../src/channels/plugins/contracts/suites.js";
|
||||||
installSendPayloadContractSuite,
|
|
||||||
primeSendMock,
|
|
||||||
} from "../../../src/test-utils/send-payload-contract.js";
|
|
||||||
import { zalouserPlugin } from "./channel.js";
|
import { zalouserPlugin } from "./channel.js";
|
||||||
import { setZalouserRuntime } from "./runtime.js";
|
import { setZalouserRuntime } from "./runtime.js";
|
||||||
|
|
||||||
@ -36,8 +33,7 @@ describe("zalouserPlugin outbound sendPayload", () => {
|
|||||||
} as never);
|
} as never);
|
||||||
const mod = await import("./send.js");
|
const mod = await import("./send.js");
|
||||||
mockedSend = vi.mocked(mod.sendMessageZalouser);
|
mockedSend = vi.mocked(mod.sendMessageZalouser);
|
||||||
mockedSend.mockClear();
|
primeChannelOutboundSendMock(mockedSend, { ok: true, messageId: "zlu-1" });
|
||||||
mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-1" });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("group target delegates with isGroup=true and stripped threadId", async () => {
|
it("group target delegates with isGroup=true and stripped threadId", async () => {
|
||||||
@ -110,19 +106,6 @@ describe("zalouserPlugin outbound sendPayload", () => {
|
|||||||
);
|
);
|
||||||
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-code" });
|
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-code" });
|
||||||
});
|
});
|
||||||
|
|
||||||
installSendPayloadContractSuite({
|
|
||||||
channel: "zalouser",
|
|
||||||
chunking: { mode: "passthrough", longTextLength: 3000 },
|
|
||||||
createHarness: ({ payload, sendResults }) => {
|
|
||||||
primeSendMock(mockedSend, { ok: true, messageId: "zlu-1" }, sendResults);
|
|
||||||
return {
|
|
||||||
run: async () => await zalouserPlugin.outbound!.sendPayload!(baseCtx(payload)),
|
|
||||||
sendMock: mockedSend,
|
|
||||||
to: "987654321",
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("zalouserPlugin messaging target normalization", () => {
|
describe("zalouserPlugin messaging target normalization", () => {
|
||||||
|
|||||||
21
openclaw.mjs
21
openclaw.mjs
@ -1,6 +1,7 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import module from "node:module";
|
import module from "node:module";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const MIN_NODE_MAJOR = 22;
|
const MIN_NODE_MAJOR = 22;
|
||||||
const MIN_NODE_MINOR = 12;
|
const MIN_NODE_MINOR = 12;
|
||||||
@ -47,6 +48,20 @@ if (module.enableCompileCache && !process.env.NODE_DISABLE_COMPILE_CACHE) {
|
|||||||
const isModuleNotFoundError = (err) =>
|
const isModuleNotFoundError = (err) =>
|
||||||
err && typeof err === "object" && "code" in err && err.code === "ERR_MODULE_NOT_FOUND";
|
err && typeof err === "object" && "code" in err && err.code === "ERR_MODULE_NOT_FOUND";
|
||||||
|
|
||||||
|
const isDirectModuleNotFoundError = (err, specifier) => {
|
||||||
|
if (!isModuleNotFoundError(err)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedUrl = new URL(specifier, import.meta.url);
|
||||||
|
if ("url" in err && err.url === expectedUrl.href) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = "message" in err && typeof err.message === "string" ? err.message : "";
|
||||||
|
return message.includes(fileURLToPath(expectedUrl));
|
||||||
|
};
|
||||||
|
|
||||||
const installProcessWarningFilter = async () => {
|
const installProcessWarningFilter = async () => {
|
||||||
// Keep bootstrap warnings consistent with the TypeScript runtime.
|
// Keep bootstrap warnings consistent with the TypeScript runtime.
|
||||||
for (const specifier of ["./dist/warning-filter.js", "./dist/warning-filter.mjs"]) {
|
for (const specifier of ["./dist/warning-filter.js", "./dist/warning-filter.mjs"]) {
|
||||||
@ -57,7 +72,7 @@ const installProcessWarningFilter = async () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isModuleNotFoundError(err)) {
|
if (isDirectModuleNotFoundError(err, specifier)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
@ -72,8 +87,8 @@ const tryImport = async (specifier) => {
|
|||||||
await import(specifier);
|
await import(specifier);
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Only swallow missing-module errors; rethrow real runtime errors.
|
// Only swallow direct entry misses; rethrow transitive resolution failures.
|
||||||
if (isModuleNotFoundError(err)) {
|
if (isDirectModuleNotFoundError(err, specifier)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
20
package.json
20
package.json
@ -29,6 +29,8 @@
|
|||||||
"assets/",
|
"assets/",
|
||||||
"dist/",
|
"dist/",
|
||||||
"docs/",
|
"docs/",
|
||||||
|
"!docs/.generated/**",
|
||||||
|
"!docs/.i18n/zh-CN.tm.jsonl",
|
||||||
"extensions/",
|
"extensions/",
|
||||||
"skills/"
|
"skills/"
|
||||||
],
|
],
|
||||||
@ -48,6 +50,22 @@
|
|||||||
"types": "./dist/plugin-sdk/compat.d.ts",
|
"types": "./dist/plugin-sdk/compat.d.ts",
|
||||||
"default": "./dist/plugin-sdk/compat.js"
|
"default": "./dist/plugin-sdk/compat.js"
|
||||||
},
|
},
|
||||||
|
"./plugin-sdk/ollama-setup": {
|
||||||
|
"types": "./dist/plugin-sdk/ollama-setup.d.ts",
|
||||||
|
"default": "./dist/plugin-sdk/ollama-setup.js"
|
||||||
|
},
|
||||||
|
"./plugin-sdk/provider-setup": {
|
||||||
|
"types": "./dist/plugin-sdk/provider-setup.d.ts",
|
||||||
|
"default": "./dist/plugin-sdk/provider-setup.js"
|
||||||
|
},
|
||||||
|
"./plugin-sdk/sandbox": {
|
||||||
|
"types": "./dist/plugin-sdk/sandbox.d.ts",
|
||||||
|
"default": "./dist/plugin-sdk/sandbox.js"
|
||||||
|
},
|
||||||
|
"./plugin-sdk/self-hosted-provider-setup": {
|
||||||
|
"types": "./dist/plugin-sdk/self-hosted-provider-setup.d.ts",
|
||||||
|
"default": "./dist/plugin-sdk/self-hosted-provider-setup.js"
|
||||||
|
},
|
||||||
"./plugin-sdk/routing": {
|
"./plugin-sdk/routing": {
|
||||||
"types": "./dist/plugin-sdk/routing.d.ts",
|
"types": "./dist/plugin-sdk/routing.d.ts",
|
||||||
"default": "./dist/plugin-sdk/routing.js"
|
"default": "./dist/plugin-sdk/routing.js"
|
||||||
@ -311,6 +329,7 @@
|
|||||||
"test:all": "pnpm lint && pnpm build && pnpm test && pnpm test:e2e && pnpm test:live && pnpm test:docker:all",
|
"test:all": "pnpm lint && pnpm build && pnpm test && pnpm test:e2e && pnpm test:live && pnpm test:docker:all",
|
||||||
"test:auth:compat": "vitest run --config vitest.gateway.config.ts src/gateway/server.auth.compat-baseline.test.ts src/gateway/client.test.ts src/gateway/reconnect-gating.test.ts src/gateway/protocol/connect-error-details.test.ts",
|
"test:auth:compat": "vitest run --config vitest.gateway.config.ts src/gateway/server.auth.compat-baseline.test.ts src/gateway/client.test.ts src/gateway/reconnect-gating.test.ts src/gateway/protocol/connect-error-details.test.ts",
|
||||||
"test:channels": "vitest run --config vitest.channels.config.ts",
|
"test:channels": "vitest run --config vitest.channels.config.ts",
|
||||||
|
"test:contracts": "pnpm test -- src/channels/plugins/contracts src/plugins/contracts",
|
||||||
"test:coverage": "vitest run --config vitest.unit.config.ts --coverage",
|
"test:coverage": "vitest run --config vitest.unit.config.ts --coverage",
|
||||||
"test:docker:all": "pnpm test:docker:live-models && pnpm test:docker:live-gateway && pnpm test:docker:onboard && pnpm test:docker:gateway-network && pnpm test:docker:qr && pnpm test:docker:doctor-switch && pnpm test:docker:plugins && pnpm test:docker:cleanup",
|
"test:docker:all": "pnpm test:docker:live-models && pnpm test:docker:live-gateway && pnpm test:docker:onboard && pnpm test:docker:gateway-network && pnpm test:docker:qr && pnpm test:docker:doctor-switch && pnpm test:docker:plugins && pnpm test:docker:cleanup",
|
||||||
"test:docker:cleanup": "bash scripts/test-cleanup-docker.sh",
|
"test:docker:cleanup": "bash scripts/test-cleanup-docker.sh",
|
||||||
@ -323,6 +342,7 @@
|
|||||||
"test:docker:qr": "bash scripts/e2e/qr-import-docker.sh",
|
"test:docker:qr": "bash scripts/e2e/qr-import-docker.sh",
|
||||||
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
||||||
"test:e2e:openshell": "OPENCLAW_E2E_OPENSHELL=1 vitest run --config vitest.e2e.config.ts test/openshell-sandbox.e2e.test.ts",
|
"test:e2e:openshell": "OPENCLAW_E2E_OPENSHELL=1 vitest run --config vitest.e2e.config.ts test/openshell-sandbox.e2e.test.ts",
|
||||||
|
"test:extension": "node scripts/test-extension.mjs",
|
||||||
"test:extensions": "vitest run --config vitest.extensions.config.ts",
|
"test:extensions": "vitest run --config vitest.extensions.config.ts",
|
||||||
"test:fast": "vitest run --config vitest.unit.config.ts",
|
"test:fast": "vitest run --config vitest.unit.config.ts",
|
||||||
"test:force": "node --import tsx scripts/test-force.ts",
|
"test:force": "node --import tsx scripts/test-force.ts",
|
||||||
|
|||||||
@ -63,11 +63,12 @@ const cases = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function parseMaxRssMb(stderr) {
|
function parseMaxRssMb(stderr) {
|
||||||
const match = stderr.match(new RegExp(`^${MAX_RSS_MARKER}(\\d+)\\s*$`, "m"));
|
const matches = [...stderr.matchAll(new RegExp(`^${MAX_RSS_MARKER}(\\d+)\\s*$`, "gm"))];
|
||||||
if (!match) {
|
const lastMatch = matches.at(-1);
|
||||||
|
if (!lastMatch) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return Number(match[1]) / 1024;
|
return Number(lastMatch[1]) / 1024;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBenchEnv() {
|
function buildBenchEnv() {
|
||||||
@ -98,6 +99,9 @@ function buildBenchEnv() {
|
|||||||
// one-shot compile cache overhead, which varies across runner builds.
|
// one-shot compile cache overhead, which varies across runner builds.
|
||||||
env.NODE_DISABLE_COMPILE_CACHE = "1";
|
env.NODE_DISABLE_COMPILE_CACHE = "1";
|
||||||
}
|
}
|
||||||
|
// Keep the benchmark on a single process so RSS reflects the actual command
|
||||||
|
// path rather than the warning-suppression respawn wrapper.
|
||||||
|
env.OPENCLAW_NO_RESPAWN = "1";
|
||||||
|
|
||||||
return env;
|
return env;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,10 @@
|
|||||||
"index",
|
"index",
|
||||||
"core",
|
"core",
|
||||||
"compat",
|
"compat",
|
||||||
|
"ollama-setup",
|
||||||
|
"provider-setup",
|
||||||
|
"sandbox",
|
||||||
|
"self-hosted-provider-setup",
|
||||||
"routing",
|
"routing",
|
||||||
"telegram",
|
"telegram",
|
||||||
"discord",
|
"discord",
|
||||||
|
|||||||
283
scripts/test-extension.mjs
Normal file
283
scripts/test-extension.mjs
Normal file
@ -0,0 +1,283 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { execFileSync, spawn } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
import { channelTestRoots } from "../vitest.channel-paths.mjs";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const repoRoot = path.resolve(__dirname, "..");
|
||||||
|
const pnpm = "pnpm";
|
||||||
|
|
||||||
|
function normalizeRelative(inputPath) {
|
||||||
|
return inputPath.split(path.sep).join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTestFile(filePath) {
|
||||||
|
return filePath.endsWith(".test.ts") || filePath.endsWith(".test.tsx");
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectTestFiles(rootPath) {
|
||||||
|
const results = [];
|
||||||
|
const stack = [rootPath];
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const current = stack.pop();
|
||||||
|
if (!current || !fs.existsSync(current)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||||
|
const fullPath = path.join(current, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (entry.name === "node_modules" || entry.name === "dist") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
stack.push(fullPath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.isFile() && isTestFile(fullPath)) {
|
||||||
|
results.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results.toSorted((left, right) => left.localeCompare(right));
|
||||||
|
}
|
||||||
|
|
||||||
|
function listChangedPaths(base, head = "HEAD") {
|
||||||
|
if (!base) {
|
||||||
|
throw new Error("A git base revision is required to list changed extensions.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return execFileSync("git", ["diff", "--name-only", base, head], {
|
||||||
|
cwd: repoRoot,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
encoding: "utf8",
|
||||||
|
})
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasExtensionPackage(extensionId) {
|
||||||
|
return fs.existsSync(path.join(repoRoot, "extensions", extensionId, "package.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectChangedExtensionIds(changedPaths) {
|
||||||
|
const extensionIds = new Set();
|
||||||
|
|
||||||
|
for (const rawPath of changedPaths) {
|
||||||
|
const relativePath = normalizeRelative(String(rawPath).trim());
|
||||||
|
if (!relativePath) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extensionMatch = relativePath.match(/^extensions\/([^/]+)(?:\/|$)/);
|
||||||
|
if (extensionMatch) {
|
||||||
|
extensionIds.add(extensionMatch[1]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pairedCoreMatch = relativePath.match(/^src\/([^/]+)(?:\/|$)/);
|
||||||
|
if (pairedCoreMatch && hasExtensionPackage(pairedCoreMatch[1])) {
|
||||||
|
extensionIds.add(pairedCoreMatch[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...extensionIds].toSorted((left, right) => left.localeCompare(right));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listChangedExtensionIds(params = {}) {
|
||||||
|
const base = params.base;
|
||||||
|
const head = params.head ?? "HEAD";
|
||||||
|
return detectChangedExtensionIds(listChangedPaths(base, head));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveExtensionDirectory(targetArg, cwd = process.cwd()) {
|
||||||
|
if (targetArg) {
|
||||||
|
const asGiven = path.resolve(cwd, targetArg);
|
||||||
|
if (fs.existsSync(path.join(asGiven, "package.json"))) {
|
||||||
|
return asGiven;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byName = path.join(repoRoot, "extensions", targetArg);
|
||||||
|
if (fs.existsSync(path.join(byName, "package.json"))) {
|
||||||
|
return byName;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Unknown extension target "${targetArg}". Use an extension name like "slack" or a path under extensions/.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = cwd;
|
||||||
|
while (true) {
|
||||||
|
if (
|
||||||
|
normalizeRelative(path.relative(repoRoot, current)).startsWith("extensions/") &&
|
||||||
|
fs.existsSync(path.join(current, "package.json"))
|
||||||
|
) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
const parent = path.dirname(current);
|
||||||
|
if (parent === current) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
"No extension target provided, and current working directory is not inside extensions/.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveExtensionTestPlan(params = {}) {
|
||||||
|
const cwd = params.cwd ?? process.cwd();
|
||||||
|
const targetArg = params.targetArg;
|
||||||
|
const extensionDir = resolveExtensionDirectory(targetArg, cwd);
|
||||||
|
const extensionId = path.basename(extensionDir);
|
||||||
|
const relativeExtensionDir = normalizeRelative(path.relative(repoRoot, extensionDir));
|
||||||
|
|
||||||
|
const roots = [relativeExtensionDir];
|
||||||
|
const pairedCoreRoot = path.join(repoRoot, "src", extensionId);
|
||||||
|
if (fs.existsSync(pairedCoreRoot)) {
|
||||||
|
const pairedRelativeRoot = normalizeRelative(path.relative(repoRoot, pairedCoreRoot));
|
||||||
|
if (collectTestFiles(pairedCoreRoot).length > 0) {
|
||||||
|
roots.push(pairedRelativeRoot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const usesChannelConfig = roots.some((root) => channelTestRoots.includes(root));
|
||||||
|
const config = usesChannelConfig ? "vitest.channels.config.ts" : "vitest.extensions.config.ts";
|
||||||
|
const testFiles = roots.flatMap((root) => collectTestFiles(path.join(repoRoot, root)));
|
||||||
|
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
extensionDir: relativeExtensionDir,
|
||||||
|
extensionId,
|
||||||
|
roots,
|
||||||
|
testFiles: testFiles.map((filePath) => normalizeRelative(path.relative(repoRoot, filePath))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function printUsage() {
|
||||||
|
console.error("Usage: pnpm test:extension <extension-name|path> [vitest args...]");
|
||||||
|
console.error(" node scripts/test-extension.mjs [extension-name|path] [vitest args...]");
|
||||||
|
console.error(
|
||||||
|
" node scripts/test-extension.mjs --list-changed --base <git-ref> [--head <git-ref>]",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const rawArgs = process.argv.slice(2);
|
||||||
|
const dryRun = rawArgs.includes("--dry-run");
|
||||||
|
const json = rawArgs.includes("--json");
|
||||||
|
const listChanged = rawArgs.includes("--list-changed");
|
||||||
|
const args = rawArgs.filter(
|
||||||
|
(arg) => arg !== "--" && arg !== "--dry-run" && arg !== "--json" && arg !== "--list-changed",
|
||||||
|
);
|
||||||
|
|
||||||
|
let base = "";
|
||||||
|
let head = "HEAD";
|
||||||
|
const passthroughArgs = [];
|
||||||
|
|
||||||
|
if (listChanged) {
|
||||||
|
for (let index = 0; index < args.length; index += 1) {
|
||||||
|
const arg = args[index];
|
||||||
|
if (arg === "--base") {
|
||||||
|
base = args[index + 1] ?? "";
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (arg === "--head") {
|
||||||
|
head = args[index + 1] ?? "HEAD";
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
passthroughArgs.push(arg);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
passthroughArgs.push(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listChanged) {
|
||||||
|
let extensionIds;
|
||||||
|
try {
|
||||||
|
extensionIds = listChangedExtensionIds({ base, head });
|
||||||
|
} catch (error) {
|
||||||
|
printUsage();
|
||||||
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (json) {
|
||||||
|
process.stdout.write(`${JSON.stringify({ base, head, extensionIds }, null, 2)}\n`);
|
||||||
|
} else {
|
||||||
|
for (const extensionId of extensionIds) {
|
||||||
|
console.log(extensionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetArg;
|
||||||
|
if (passthroughArgs[0] && !passthroughArgs[0].startsWith("-")) {
|
||||||
|
targetArg = passthroughArgs.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
let plan;
|
||||||
|
try {
|
||||||
|
plan = resolveExtensionTestPlan({ cwd: process.cwd(), targetArg });
|
||||||
|
} catch (error) {
|
||||||
|
printUsage();
|
||||||
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plan.testFiles.length === 0) {
|
||||||
|
console.error(`No tests found for ${plan.extensionDir}.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
if (json) {
|
||||||
|
process.stdout.write(`${JSON.stringify(plan, null, 2)}\n`);
|
||||||
|
} else {
|
||||||
|
console.log(`[test-extension] ${plan.extensionId}`);
|
||||||
|
console.log(`config: ${plan.config}`);
|
||||||
|
console.log(`roots: ${plan.roots.join(", ")}`);
|
||||||
|
console.log(`tests: ${plan.testFiles.length}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[test-extension] Running ${plan.testFiles.length} test files for ${plan.extensionId} with ${plan.config}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const child = spawn(
|
||||||
|
pnpm,
|
||||||
|
["exec", "vitest", "run", "--config", plan.config, ...plan.testFiles, ...passthroughArgs],
|
||||||
|
{
|
||||||
|
cwd: repoRoot,
|
||||||
|
stdio: "inherit",
|
||||||
|
shell: process.platform === "win32",
|
||||||
|
env: process.env,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
child.on("exit", (code, signal) => {
|
||||||
|
if (signal) {
|
||||||
|
process.kill(process.pid, signal);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
process.exit(code ?? 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryHref = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : "";
|
||||||
|
|
||||||
|
if (import.meta.url === entryHref) {
|
||||||
|
await run();
|
||||||
|
}
|
||||||
@ -3,14 +3,13 @@ import { resolveStateDir } from "../config/paths.js";
|
|||||||
import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
|
import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
|
||||||
import { resolveUserPath } from "../utils.js";
|
import { resolveUserPath } from "../utils.js";
|
||||||
|
|
||||||
export function resolveOpenClawAgentDir(): string {
|
export function resolveOpenClawAgentDir(env: NodeJS.ProcessEnv = process.env): string {
|
||||||
const override =
|
const override = env.OPENCLAW_AGENT_DIR?.trim() || env.PI_CODING_AGENT_DIR?.trim();
|
||||||
process.env.OPENCLAW_AGENT_DIR?.trim() || process.env.PI_CODING_AGENT_DIR?.trim();
|
|
||||||
if (override) {
|
if (override) {
|
||||||
return resolveUserPath(override);
|
return resolveUserPath(override, env);
|
||||||
}
|
}
|
||||||
const defaultAgentDir = path.join(resolveStateDir(), "agents", DEFAULT_AGENT_ID, "agent");
|
const defaultAgentDir = path.join(resolveStateDir(env), "agents", DEFAULT_AGENT_ID, "agent");
|
||||||
return resolveUserPath(defaultAgentDir);
|
return resolveUserPath(defaultAgentDir, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ensureOpenClawAgentEnv(): string {
|
export function ensureOpenClawAgentEnv(): string {
|
||||||
|
|||||||
@ -327,12 +327,16 @@ export function resolveAgentIdByWorkspacePath(
|
|||||||
return resolveAgentIdsByWorkspacePath(cfg, workspacePath)[0];
|
return resolveAgentIdsByWorkspacePath(cfg, workspacePath)[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveAgentDir(cfg: OpenClawConfig, agentId: string) {
|
export function resolveAgentDir(
|
||||||
|
cfg: OpenClawConfig,
|
||||||
|
agentId: string,
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
) {
|
||||||
const id = normalizeAgentId(agentId);
|
const id = normalizeAgentId(agentId);
|
||||||
const configured = resolveAgentConfig(cfg, id)?.agentDir?.trim();
|
const configured = resolveAgentConfig(cfg, id)?.agentDir?.trim();
|
||||||
if (configured) {
|
if (configured) {
|
||||||
return resolveUserPath(configured);
|
return resolveUserPath(configured, env);
|
||||||
}
|
}
|
||||||
const root = resolveStateDir(process.env);
|
const root = resolveStateDir(env);
|
||||||
return path.join(root, "agents", id, "agent");
|
return path.join(root, "agents", id, "agent");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,7 +47,10 @@ describe("auth profiles read-only external CLI sync", () => {
|
|||||||
|
|
||||||
const loaded = loadAuthProfileStoreForRuntime(agentDir, { readOnly: true });
|
const loaded = loadAuthProfileStoreForRuntime(agentDir, { readOnly: true });
|
||||||
|
|
||||||
expect(mocks.syncExternalCliCredentials).toHaveBeenCalled();
|
expect(mocks.syncExternalCliCredentials).toHaveBeenCalledWith(
|
||||||
|
expect.any(Object),
|
||||||
|
expect.objectContaining({ log: false }),
|
||||||
|
);
|
||||||
expect(loaded.profiles["qwen-portal:default"]).toMatchObject({
|
expect(loaded.profiles["qwen-portal:default"]).toMatchObject({
|
||||||
type: "oauth",
|
type: "oauth",
|
||||||
provider: "qwen-portal",
|
provider: "qwen-portal",
|
||||||
|
|||||||
@ -14,6 +14,10 @@ import type { AuthProfileCredential, AuthProfileStore, OAuthCredential } from ".
|
|||||||
|
|
||||||
const OPENAI_CODEX_DEFAULT_PROFILE_ID = "openai-codex:default";
|
const OPENAI_CODEX_DEFAULT_PROFILE_ID = "openai-codex:default";
|
||||||
|
|
||||||
|
type ExternalCliSyncOptions = {
|
||||||
|
log?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
function shallowEqualOAuthCredentials(a: OAuthCredential | undefined, b: OAuthCredential): boolean {
|
function shallowEqualOAuthCredentials(a: OAuthCredential | undefined, b: OAuthCredential): boolean {
|
||||||
if (!a) {
|
if (!a) {
|
||||||
return false;
|
return false;
|
||||||
@ -60,6 +64,7 @@ function syncExternalCliCredentialsForProvider(
|
|||||||
provider: string,
|
provider: string,
|
||||||
readCredentials: () => OAuthCredential | null,
|
readCredentials: () => OAuthCredential | null,
|
||||||
now: number,
|
now: number,
|
||||||
|
options: ExternalCliSyncOptions,
|
||||||
): boolean {
|
): boolean {
|
||||||
const existing = store.profiles[profileId];
|
const existing = store.profiles[profileId];
|
||||||
const shouldSync =
|
const shouldSync =
|
||||||
@ -78,10 +83,12 @@ function syncExternalCliCredentialsForProvider(
|
|||||||
|
|
||||||
if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, creds)) {
|
if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, creds)) {
|
||||||
store.profiles[profileId] = creds;
|
store.profiles[profileId] = creds;
|
||||||
log.info(`synced ${provider} credentials from external cli`, {
|
if (options.log !== false) {
|
||||||
profileId,
|
log.info(`synced ${provider} credentials from external cli`, {
|
||||||
expires: new Date(creds.expires).toISOString(),
|
profileId,
|
||||||
});
|
expires: new Date(creds.expires).toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -94,7 +101,10 @@ function syncExternalCliCredentialsForProvider(
|
|||||||
*
|
*
|
||||||
* Returns true if any credentials were updated.
|
* Returns true if any credentials were updated.
|
||||||
*/
|
*/
|
||||||
export function syncExternalCliCredentials(store: AuthProfileStore): boolean {
|
export function syncExternalCliCredentials(
|
||||||
|
store: AuthProfileStore,
|
||||||
|
options: ExternalCliSyncOptions = {},
|
||||||
|
): boolean {
|
||||||
let mutated = false;
|
let mutated = false;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
@ -119,10 +129,12 @@ export function syncExternalCliCredentials(store: AuthProfileStore): boolean {
|
|||||||
if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, qwenCreds)) {
|
if (shouldUpdate && !shallowEqualOAuthCredentials(existingOAuth, qwenCreds)) {
|
||||||
store.profiles[QWEN_CLI_PROFILE_ID] = qwenCreds;
|
store.profiles[QWEN_CLI_PROFILE_ID] = qwenCreds;
|
||||||
mutated = true;
|
mutated = true;
|
||||||
log.info("synced qwen credentials from qwen cli", {
|
if (options.log !== false) {
|
||||||
profileId: QWEN_CLI_PROFILE_ID,
|
log.info("synced qwen credentials from qwen cli", {
|
||||||
expires: new Date(qwenCreds.expires).toISOString(),
|
profileId: QWEN_CLI_PROFILE_ID,
|
||||||
});
|
expires: new Date(qwenCreds.expires).toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -134,6 +146,7 @@ export function syncExternalCliCredentials(store: AuthProfileStore): boolean {
|
|||||||
"minimax-portal",
|
"minimax-portal",
|
||||||
() => readMiniMaxCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }),
|
() => readMiniMaxCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }),
|
||||||
now,
|
now,
|
||||||
|
options,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
mutated = true;
|
mutated = true;
|
||||||
@ -145,6 +158,7 @@ export function syncExternalCliCredentials(store: AuthProfileStore): boolean {
|
|||||||
"openai-codex",
|
"openai-codex",
|
||||||
() => readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }),
|
() => readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }),
|
||||||
now,
|
now,
|
||||||
|
options,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
mutated = true;
|
mutated = true;
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { normalizeStringEntries } from "../../shared/string-normalization.js";
|
import { normalizeStringEntries } from "../../shared/string-normalization.js";
|
||||||
import { normalizeSecretInput } from "../../utils/normalize-secret-input.js";
|
import { normalizeSecretInput } from "../../utils/normalize-secret-input.js";
|
||||||
import { normalizeProviderId, normalizeProviderIdForAuth } from "../model-selection.js";
|
import { normalizeProviderId, normalizeProviderIdForAuth } from "../provider-id.js";
|
||||||
import {
|
import {
|
||||||
ensureAuthProfileStore,
|
ensureAuthProfileStore,
|
||||||
saveAuthProfileStore,
|
saveAuthProfileStore,
|
||||||
|
|||||||
@ -381,7 +381,7 @@ function loadAuthProfileStoreForAgent(
|
|||||||
if (asStore) {
|
if (asStore) {
|
||||||
// Runtime secret activation must remain read-only:
|
// Runtime secret activation must remain read-only:
|
||||||
// sync external CLI credentials in-memory, but never persist while readOnly.
|
// sync external CLI credentials in-memory, but never persist while readOnly.
|
||||||
const synced = syncExternalCliCredentials(asStore);
|
const synced = syncExternalCliCredentials(asStore, { log: !readOnly });
|
||||||
if (synced && !readOnly) {
|
if (synced && !readOnly) {
|
||||||
saveJsonFile(authPath, asStore);
|
saveJsonFile(authPath, asStore);
|
||||||
}
|
}
|
||||||
@ -413,7 +413,7 @@ function loadAuthProfileStoreForAgent(
|
|||||||
|
|
||||||
const mergedOAuth = mergeOAuthFileIntoStore(store);
|
const mergedOAuth = mergeOAuthFileIntoStore(store);
|
||||||
// Keep external CLI credentials visible in runtime even during read-only loads.
|
// Keep external CLI credentials visible in runtime even during read-only loads.
|
||||||
const syncedCli = syncExternalCliCredentials(store);
|
const syncedCli = syncExternalCliCredentials(store, { log: !readOnly });
|
||||||
const forceReadOnly = process.env.OPENCLAW_AUTH_STORE_READONLY === "1";
|
const forceReadOnly = process.env.OPENCLAW_AUTH_STORE_READONLY === "1";
|
||||||
const shouldWrite = !readOnly && !forceReadOnly && (legacy !== null || mergedOAuth || syncedCli);
|
const shouldWrite = !readOnly && !forceReadOnly && (legacy !== null || mergedOAuth || syncedCli);
|
||||||
if (shouldWrite) {
|
if (shouldWrite) {
|
||||||
|
|||||||
56
src/agents/auth-profiles/upsert-with-lock.ts
Normal file
56
src/agents/auth-profiles/upsert-with-lock.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { withFileLock } from "../../infra/file-lock.js";
|
||||||
|
import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js";
|
||||||
|
import { AUTH_STORE_LOCK_OPTIONS, AUTH_STORE_VERSION } from "./constants.js";
|
||||||
|
import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js";
|
||||||
|
import type { AuthProfileCredential, AuthProfileStore, ProfileUsageStats } from "./types.js";
|
||||||
|
|
||||||
|
function coerceAuthProfileStore(raw: unknown): AuthProfileStore {
|
||||||
|
const record = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
|
||||||
|
const profiles =
|
||||||
|
record.profiles && typeof record.profiles === "object" && !Array.isArray(record.profiles)
|
||||||
|
? { ...(record.profiles as Record<string, AuthProfileCredential>) }
|
||||||
|
: {};
|
||||||
|
const order =
|
||||||
|
record.order && typeof record.order === "object" && !Array.isArray(record.order)
|
||||||
|
? (record.order as Record<string, string[]>)
|
||||||
|
: undefined;
|
||||||
|
const lastGood =
|
||||||
|
record.lastGood && typeof record.lastGood === "object" && !Array.isArray(record.lastGood)
|
||||||
|
? (record.lastGood as Record<string, string>)
|
||||||
|
: undefined;
|
||||||
|
const usageStats =
|
||||||
|
record.usageStats && typeof record.usageStats === "object" && !Array.isArray(record.usageStats)
|
||||||
|
? (record.usageStats as Record<string, ProfileUsageStats>)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
version:
|
||||||
|
typeof record.version === "number" && Number.isFinite(record.version)
|
||||||
|
? record.version
|
||||||
|
: AUTH_STORE_VERSION,
|
||||||
|
profiles,
|
||||||
|
...(order ? { order } : {}),
|
||||||
|
...(lastGood ? { lastGood } : {}),
|
||||||
|
...(usageStats ? { usageStats } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertAuthProfileWithLock(params: {
|
||||||
|
profileId: string;
|
||||||
|
credential: AuthProfileCredential;
|
||||||
|
agentDir?: string;
|
||||||
|
}): Promise<AuthProfileStore | null> {
|
||||||
|
const authPath = resolveAuthStorePath(params.agentDir);
|
||||||
|
ensureAuthStoreFile(authPath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await withFileLock(authPath, AUTH_STORE_LOCK_OPTIONS, async () => {
|
||||||
|
const store = coerceAuthProfileStore(loadJsonFile(authPath));
|
||||||
|
store.profiles[params.profileId] = params.credential;
|
||||||
|
saveJsonFile(authPath, store);
|
||||||
|
return store;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,7 +5,12 @@ import type { Api, Model } from "@mariozechner/pi-ai";
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { withEnvAsync } from "../test-utils/env.js";
|
import { withEnvAsync } from "../test-utils/env.js";
|
||||||
import { ensureAuthProfileStore } from "./auth-profiles.js";
|
import { ensureAuthProfileStore } from "./auth-profiles.js";
|
||||||
import { getApiKeyForModel, resolveApiKeyForProvider, resolveEnvApiKey } from "./model-auth.js";
|
import {
|
||||||
|
getApiKeyForModel,
|
||||||
|
hasAvailableAuthForProvider,
|
||||||
|
resolveApiKeyForProvider,
|
||||||
|
resolveEnvApiKey,
|
||||||
|
} from "./model-auth.js";
|
||||||
|
|
||||||
const envVar = (...parts: string[]) => parts.join("_");
|
const envVar = (...parts: string[]) => parts.join("_");
|
||||||
|
|
||||||
@ -206,6 +211,40 @@ describe("getApiKeyForModel", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hasAvailableAuthForProvider('google') accepts GOOGLE_API_KEY fallback", async () => {
|
||||||
|
await withEnvAsync(
|
||||||
|
{
|
||||||
|
GEMINI_API_KEY: undefined,
|
||||||
|
GOOGLE_API_KEY: "google-test-key", // pragma: allowlist secret
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
await expect(
|
||||||
|
hasAvailableAuthForProvider({
|
||||||
|
provider: "google",
|
||||||
|
store: { version: 1, profiles: {} },
|
||||||
|
}),
|
||||||
|
).resolves.toBe(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hasAvailableAuthForProvider returns false when no provider auth is available", async () => {
|
||||||
|
await withEnvAsync(
|
||||||
|
{
|
||||||
|
ZAI_API_KEY: undefined,
|
||||||
|
Z_AI_API_KEY: undefined,
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
await expect(
|
||||||
|
hasAvailableAuthForProvider({
|
||||||
|
provider: "zai",
|
||||||
|
store: { version: 1, profiles: {} },
|
||||||
|
}),
|
||||||
|
).resolves.toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("resolves Synthetic API key from env", async () => {
|
it("resolves Synthetic API key from env", async () => {
|
||||||
await withEnvAsync({ [envVar("SYNTHETIC", "API", "KEY")]: "synthetic-test-key" }, async () => {
|
await withEnvAsync({ [envVar("SYNTHETIC", "API", "KEY")]: "synthetic-test-key" }, async () => {
|
||||||
// pragma: allowlist secret
|
// pragma: allowlist secret
|
||||||
|
|||||||
@ -487,6 +487,56 @@ export function resolveModelAuthMode(
|
|||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function hasAvailableAuthForProvider(params: {
|
||||||
|
provider: string;
|
||||||
|
cfg?: OpenClawConfig;
|
||||||
|
preferredProfile?: string;
|
||||||
|
store?: AuthProfileStore;
|
||||||
|
agentDir?: string;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const { provider, cfg, preferredProfile } = params;
|
||||||
|
const store = params.store ?? ensureAuthProfileStore(params.agentDir);
|
||||||
|
|
||||||
|
const authOverride = resolveProviderAuthOverride(cfg, provider);
|
||||||
|
if (authOverride === "aws-sdk") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = resolveAuthProfileOrder({
|
||||||
|
cfg,
|
||||||
|
store,
|
||||||
|
provider,
|
||||||
|
preferredProfile,
|
||||||
|
});
|
||||||
|
for (const candidate of order) {
|
||||||
|
try {
|
||||||
|
const resolved = await resolveApiKeyForProfile({
|
||||||
|
cfg,
|
||||||
|
store,
|
||||||
|
profileId: candidate,
|
||||||
|
agentDir: params.agentDir,
|
||||||
|
});
|
||||||
|
if (resolved) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.debug?.(`auth profile "${candidate}" failed for provider "${provider}": ${String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resolveEnvApiKey(provider)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (resolveUsableCustomProviderApiKey({ cfg, provider })) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (resolveSyntheticLocalProviderAuth({ cfg, provider })) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return authOverride === undefined && normalizeProviderId(provider) === "amazon-bedrock";
|
||||||
|
}
|
||||||
|
|
||||||
export async function getApiKeyForModel(params: {
|
export async function getApiKeyForModel(params: {
|
||||||
model: Model<Api>;
|
model: Model<Api>;
|
||||||
cfg?: OpenClawConfig;
|
cfg?: OpenClawConfig;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user