Compare commits

..

3 Commits

Author SHA1 Message Date
Vincent Koc
fb93eb1b6f fix(providers): scope attribution defaults 2026-04-02 12:46:35 +09:00
Vincent Koc
8b7e9985cf Merge branch 'main' into codex/llm-user-agent-header 2026-04-02 11:43:52 +09:00
C.C. Fan
038355df3f Providers: add default LLM user agent 2026-04-02 10:40:41 +08:00
281 changed files with 2100 additions and 17195 deletions

4
.github/labeler.yml vendored
View File

@@ -226,10 +226,6 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/open-prose/**"
"extensions: webhooks":
- changed-files:
- any-glob-to-any-file:
- "extensions/webhooks/**"
"extensions: device-pair":
- changed-files:
- any-glob-to-any-file:

View File

@@ -7,47 +7,14 @@ Docs: https://docs.openclaw.ai
### Changes
- Agents/compaction: add `agents.defaults.compaction.notifyUser` so the `🧹 Compacting context...` start notice is opt-in instead of always being shown. (#54251) Thanks @oguricap0327.
- Plugins/hooks: add `before_agent_reply` so plugins can short-circuit the LLM with synthetic replies after inline actions. (#20067) Thanks @JoshuaLelon
- Plugins/hooks: add `before_agent_reply` so plugins can short-circuit the LLM with synthetic replies after inline actions. (#20067) thanks @JoshuaLelon
- Providers/runtime: add provider-owned replay hook surfaces for transcript policy, replay cleanup, and reasoning-mode dispatch. (#59143) Thanks @jalehman.
- Diffs: add plugin-owned `viewerBaseUrl` so viewer links can use a stable proxy/public origin without passing `baseUrl` on every tool call. (#59341) Related #59227. Thanks @gumadeiras.
- Matrix/plugin: emit spec-compliant `m.mentions` metadata across text sends, media captions, edits, poll fallback text, and action-driven edits so Matrix mentions notify reliably in clients like Element. (#59323) Thanks @gumadeiras.
- Agents/compaction: resolve `agents.defaults.compaction.model` consistently for manual `/compact` and other context-engine compaction paths, so engine-owned compaction uses the configured override model across runtime entrypoints. (#56710) Thanks @oliviareid-svg
- Channels/session routing: move provider-specific session conversation grammar into plugin-owned session-key surfaces, preserving Telegram topic routing and Feishu scoped inheritance across bootstrap, model override, restart, and tool-policy paths.
- WhatsApp/reactions: add `reactionLevel` guidance for agent reactions. Thanks @mcaxtr.
- Feishu/comments: add a dedicated Drive comment-event flow with comment-thread context resolution, in-thread replies, and `feishu_drive` comment actions for document collaboration workflows. (#58497) thanks @wittam-01.
- Tasks/TaskFlow: restore the core TaskFlow substrate with managed-vs-mirrored sync modes, durable flow state/revision tracking, and `openclaw flows` inspection/recovery primitives so background orchestration can persist and be operated separately from plugin authoring layers. (#58930) Thanks @mbelinky.
- Tasks/TaskFlow: add managed child task spawning plus sticky cancel intent, so external orchestrators can stop scheduling immediately and let parent TaskFlows settle to `cancelled` once active child tasks finish. (#59610) Thanks @mbelinky.
- Plugins/TaskFlow: add a bound `api.runtime.taskFlow` seam so plugins and trusted authoring layers can create and drive managed TaskFlows from host-resolved OpenClaw context without passing owner identifiers on each call. (#59622) Thanks @mbelinky.
- Plugins/webhooks: add a bundled webhook ingress plugin so external automation can create and drive bound TaskFlows through per-route shared-secret endpoints. Thanks @mbelinky.
### Fixes
- Agents/output sanitization: strip namespaced `antml:thinking` blocks from user-visible text so Anthropic-style internal monologue tags do not leak into replies. (#59550) Thanks @obviyus.
- Slack/mrkdwn formatting: add built-in Slack mrkdwn guidance in inbound context so Slack replies stop falling back to generic Markdown patterns that render poorly in Slack. (#59100) Thanks @jadewon.
- Providers/OpenAI attribution: centralize versioned attribution header formatting and reuse the shared provider-attribution policy in OpenAI-compatible transcription helpers without broadening attribution to unverified providers. Thanks @fanweixiao and @vincentkoc.
- Slack/mrkdwn formatting: add built-in Slack mrkdwn guidance in inbound context so Slack replies stop falling back to generic Markdown patterns that render poorly in Slack. Thanks @jadewon and @vincentkoc.
- Gateway/exec loopback: restore legacy-role fallback for empty paired-device token maps and allow silent local role upgrades so local exec and node clients stop failing with pairing-required errors after `2026.3.31`. (#59092) Thanks @openperf.
- WhatsApp/media: add HTML, XML, and CSS to the MIME map and fall back gracefully for unknown media types instead of dropping the attachment. (#51562) Thanks @bobbyt74.
- Plugins/runtime: keep LINE reply directives and browser-backed cleanup/reset flows working even when those plugins are disabled while tightening bundled plugin activation guards. (#59412) Thanks @vincentkoc.
- WhatsApp/presence: send `unavailable` presence on connect in self-chat mode so personal-phone users stop losing all push notifications while the gateway is running. (#59410) Thanks @mcaxtr.
- Providers/OpenAI-compatible routing: centralize native-vs-proxy request policy so hidden attribution and related OpenAI-family defaults only apply on verified native endpoints across stream, websocket, and shared audio HTTP paths. (#59433) Thanks @vincentkoc.
- Providers/media HTTP: centralize base URL normalization, default auth/header injection, and explicit header override handling across shared OpenAI-compatible audio, Deepgram audio, Gemini media/image, and Moonshot video request paths. (#59469) Thanks @vincentkoc.
- Exec approvals/doctor: report host policy sources from the real approvals file path and ignore malformed host override values when attributing effective policy conflicts. (#59367) Thanks @gumadeiras.
- Matrix/onboarding: restore guided setup in `openclaw channels add` and `openclaw configure --section channels`, while keeping custom plugin wizards on the shared `setupWizard` seam. (#59462) Thanks @gumadeiras.
- Feishu/comment threads: harden document comment-thread delivery so whole-document comments fall back to `add_comment`, delayed reply lookups retry more reliably, and user-visible replies avoid reasoning/planning spillover. (#59129) Thanks @wittam-01.
- Matrix/streaming: keep live partial previews for the current assistant block while preserving completed block updates as separate messages when `channels.matrix.blockStreaming` is enabled. (#59384) thanks @gumadeiras
- Kimi Coding/tools: normalize Anthropic tool payloads into the OpenAI-compatible function shape Kimi Coding expects so tool calls stop losing required arguments. (#59440) Thanks @obviyus.
- ACP/gateway reconnects: keep ACP prompts alive across transient websocket drops while still failing boundedly when reconnect recovery does not complete. (#59473) Thanks @obviyus.
- Exec approvals/config: strip invalid `security`, `ask`, and `askFallback` values from `~/.openclaw/exec-approvals.json` during normalization so malformed policy enums fall back cleanly to the documented defaults instead of corrupting runtime policy resolution. (#59112) Thanks @openperf.
- Mattermost/probes: route status probes through the SSRF guard and honor `allowPrivateNetwork` so connectivity checks stay safe for self-hosted Mattermost deployments. (#58529) Thanks @mappel-nv.
- QQBot/structured payloads: restrict local file paths to QQ Bot-owned media storage, block traversal outside that root, reduce path leakage in logs, and keep inline image data URLs working. (#58453) Thanks @jacobtomlinson.
- Providers/streaming headers: centralize default and attribution header merging across OpenAI websocket, embedded-runner, and proxy stream paths so provider-specific headers stay consistent and caller overrides only win where intended. (#59542) Thanks @vincentkoc.
- Providers/Anthropic routing: centralize native-vs-proxy endpoint classification for direct Anthropic `service_tier` handling so spoofed or proxied hosts do not inherit native Anthropic defaults. (#59608) Thanks @vincentkoc.
- Browser/host inspection: keep static Chrome inspection helpers out of the activated browser runtime so `openclaw doctor browser` and related checks do not eagerly load the bundled browser plugin. (#59471) Thanks @vincentkoc.
- Image tool/paths: resolve relative local media paths against the agent `workspaceDir` instead of `process.cwd()` so inputs like `inbox/receipt.png` pass the local-path allowlist reliably. (#57222) Thanks Priyansh Gupta.
- Podman/launch: remove noisy container output from `scripts/run-openclaw-podman.sh` and align the Podman install guidance with the quieter startup flow. (#59368) Thanks @sallyom.
- MS Teams/streaming: strip already-streamed text from fallback block delivery when replies exceed the 4000-character streaming limit so long responses stop duplicating content. (#59297) Thanks @bradgroux.
- MS Teams/logging: format non-`Error` failures with the shared unknown-error helper so logs stop collapsing caught SDK or Axios objects into `[object Object]`. (#59321) Thanks @bradgroux.
- Slack/thread context: filter thread starter and history by the effective conversation allowlist without dropping valid open-room, DM, or group DM context. (#58380) Thanks @jacobtomlinson.
- ACP/gateway reconnects: reject stale pre-ack ACP prompts after reconnect grace expiry so callers fail cleanly instead of hanging indefinitely when the gateway never confirms the run.
## 2026.4.1-beta.1
@@ -124,17 +91,15 @@ Docs: https://docs.openclaw.ai
- Plugins/install: forward `--dangerously-force-unsafe-install` through archive and npm-spec plugin installs so the documented override reaches the security scanner on those install paths. (#58879) Thanks @ryanlee-gemini.
- Auto-reply/commands: strip inbound metadata before slash command detection so wrapped `/model`, `/new`, and `/status` commands are recognized. (#58725) Thanks @Mlightsnow.
- Agents/Anthropic: preserve thinking blocks and signatures across replay, cache-control patching, and context pruning so compacted Anthropic sessions continue working instead of failing on later turns. (#58916) Thanks @obviyus
- Agents/Anthropic: recover cleanly after a crash leaves the latest assistant turn with incomplete thinking blocks, dropping or retrying the corrupted turn instead of getting stuck on later Anthropic requests. Thanks @explainanalyze. Maintainer refresh: vincentkoc.
- Agents/failover: unify structured and raw provider error classification so provider-specific `400`/`422` payloads no longer get forced into generic format failures before retry, billing, or compaction logic can inspect them. (#58856) Thanks @aaron-he-zhu.
- Auth profiles/store: coerce misplaced SecretRef objects out of plaintext `key` and `token` fields during store load so agents without ACP runtime stop crashing on `.trim()` after upgrade. (#58923) Thanks @openperf.
- ACPX/runtime: repair `queue owner unavailable` session recovery by replacing dead named sessions and resuming the backend session when ACPX exposes a stable session id, so the first ACP prompt no longer inherits a dead handle. (#58669) Thanks @neeravmakwana
- ACPX/runtime: retry dead-session queue-owner repair without `--resume-session` when the reported ACPX session id is stale, so recovery still creates a fresh named session instead of failing session init. Thanks @obviyus.
- Auth/OpenAI Codex: persist plugin-refreshed OAuth credentials to `auth-profiles.json` before returning them, so rotated Codex refresh tokens survive restart and stop falling into `refresh_token_reused` loops. (#53082)
- Agents/Anthropic: honor explicit `cacheRetention` for custom providers using `anthropic-messages`, so Anthropic-compatible proxy providers can reuse prompt caching when they opt in. (#59049) Thanks @wwerst and @vincentkoc.
- Discord/gateway: hand reconnect ownership back to Carbon, keep runtime status aligned with close/reconnect state, and force-stop sockets that open without reaching READY so Discord monitors recover promptly instead of waiting on stale health timeouts. (#59019) Thanks @obviyus
- QQBot/voice: lazy-load `silk-wasm` in `audio-convert.ts` so qqbot still starts when the optional voice dependency is missing, while voice encode/decode degrades gracefully instead of crashing at module load time. (#58829) Thanks @WideLee.
- Config/Telegram: migrate removed `channels.telegram.groupMentionsOnly` into `channels.telegram.groups["*"].requireMention` on load so legacy configs no longer crash at startup. (#55336) thanks @jameslcowan.
- Control UI/build: stop `pnpm ui:build` from reinstalling the UI with production-only dependencies, so fresh self-healing UI builds keep `vite` available instead of failing before asset generation. (#59267) Thanks @juliabush.
### Fixes
## 2026.3.31
@@ -151,7 +116,6 @@ Docs: https://docs.openclaw.ai
- ACP/plugins: add an explicit default-off ACPX plugin-tools MCP bridge config, document the trust boundary, and harden the built-in bridge packaging/logging path so global installs and stdio MCP sessions work reliably. (#56867) Thanks @joe2643.
- Agents/LLM: add a configurable idle-stream timeout for embedded runner requests so stalled model streams abort cleanly instead of hanging until the broader run timeout fires. (#55072) Thanks @liuy.
- Docs/plugins: update the community wecom and qqbot plugin listing to the docs catalog. (#57641) Thanks @sliverp.
- Agents/MCP: materialize bundle MCP tools with provider-safe names (`serverName__toolName`), support optional `streamable-http` transport selection plus per-server connection timeouts, and preserve real tool results from aborted/error turns unless truncation explicitly drops them. (#49505) Thanks @ziomancer.
- Android/notifications: add notification-forwarding controls with package filtering, quiet hours, rate limiting, and safer picker behavior for forwarded notification events. (#40175) Thanks @nimbleenigma.
- Background tasks: turn tasks into a real shared background-run control plane instead of ACP-only bookkeeping by unifying ACP, subagent, cron, and background CLI execution under one SQLite-backed ledger, routing detached lifecycle updates through the executor seam, adding audit/maintenance/status visibility, tightening auto-cleanup and lost-run recovery, improving task awareness in internal status/tool surfaces, and clarifying the split between heartbeat/main-session automation and detached scheduled runs. Thanks @mbelinky and @vincentkoc.
@@ -1067,7 +1031,6 @@ Docs: https://docs.openclaw.ai
- Exec: harden host env override handling across gateway and node (#51207) Thanks @gladiator9797 and @joshavant.
- Voice Call: enforce spoken-output contract and fix stream TTS silence regression (#51500) Thanks @joshavant.
- xAI/models: rename the bundled Grok 4.20 catalog entries to the GA IDs and normalize saved deprecated beta IDs at runtime so existing configs and sessions keep resolving. (#50772) thanks @Jaaneek
- WhatsApp/outbound media: fix HTML, XML, and CSS files being silently dropped on outbound send by adding missing MIME entries and falling back to `application/octet-stream` for unknown media types. (#51562) Thanks @bobbyt74
- Agents/bootstrap warnings: move bootstrap truncation warnings out of the system prompt and into the per-turn prompt body so prompt-cache reuse stays stable when truncation warnings appear or disappear. (#48753) Thanks @scoootscooob and @obviyus.
- Telegram/DM topic session keys: route named-account DM topics through the same per-account base session key across inbound messages, native commands, and session-state lookups so `/status` and thread recovery stop creating phantom `agent:main:main:thread:...` sessions. (#48204) Thanks @vincentkoc.
- ACP/configured bindings: reinitialize configured ACP sessions that are stuck in `error` state instead of reusing the failed runtime.

View File

@@ -1086,20 +1086,6 @@
"help": "Optional provider/model override used only for compaction summarization. Set this when you want compaction to run on a different model than the session default, and leave it unset to keep using the primary agent model.",
"hasChildren": false
},
{
"path": "agents.defaults.compaction.notifyUser",
"kind": "core",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Compaction Notify User",
"help": "When enabled, sends a brief compaction notice to the user (e.g. '🧹 Compacting context...') when compaction starts. Disabled by default to keep compaction silent and non-intrusive.",
"hasChildren": false
},
{
"path": "agents.defaults.compaction.postCompactionSections",
"kind": "core",
@@ -2214,6 +2200,16 @@
"tags": [],
"hasChildren": false
},
{
"path": "agents.defaults.memorySearch.notifyUser",
"kind": "core",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{
"path": "agents.defaults.memorySearch.outputDimensionality",
"kind": "core",
@@ -49614,20 +49610,6 @@
"help": "Allow non-loopback access to diff viewer URLs when the token path is known.",
"hasChildren": false
},
{
"path": "plugins.entries.diffs.config.viewerBaseUrl",
"kind": "plugin",
"type": "string",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [
"advanced"
],
"label": "Viewer Base URL",
"help": "Persistent gateway base URL used for returned viewer links when a tool call does not pass baseUrl.",
"hasChildren": false
},
{
"path": "plugins.entries.diffs.enabled",
"kind": "plugin",

View File

@@ -1,4 +1,4 @@
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5767}
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5766}
{"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":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@@ -92,7 +92,6 @@
{"recordType":"path","path":"agents.defaults.compaction.memoryFlush.systemPrompt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Compaction Memory Flush System Prompt","help":"System-prompt override for the pre-compaction memory flush turn to control extraction style and safety constraints. Use carefully so custom instructions do not reduce memory quality or leak sensitive context.","hasChildren":false}
{"recordType":"path","path":"agents.defaults.compaction.mode","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Compaction Mode","help":"Compaction strategy mode: \"default\" uses baseline behavior, while \"safeguard\" applies stricter guardrails to preserve recent context. Keep \"default\" unless you observe aggressive history loss near limit boundaries.","hasChildren":false}
{"recordType":"path","path":"agents.defaults.compaction.model","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["models"],"label":"Compaction Model Override","help":"Optional provider/model override used only for compaction summarization. Set this when you want compaction to run on a different model than the session default, and leave it unset to keep using the primary agent model.","hasChildren":false}
{"recordType":"path","path":"agents.defaults.compaction.notifyUser","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Compaction Notify User","help":"When enabled, sends a brief compaction notice to the user (e.g. '🧹 Compacting context...') when compaction starts. Disabled by default to keep compaction silent and non-intrusive.","hasChildren":false}
{"recordType":"path","path":"agents.defaults.compaction.postCompactionSections","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Post-Compaction Context Sections","help":"AGENTS.md H2/H3 section names re-injected after compaction so the agent reruns critical startup guidance. Leave unset to use \"Session Startup\"/\"Red Lines\" with legacy fallback to \"Every Session\"/\"Safety\"; set to [] to disable reinjection entirely.","hasChildren":true}
{"recordType":"path","path":"agents.defaults.compaction.postCompactionSections.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.defaults.compaction.postIndexSync","kind":"core","type":"string","required":false,"enumValues":["off","async","await"],"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Compaction Post-Index Sync","help":"Controls post-compaction session memory reindex mode: \"off\", \"async\", or \"await\" (default: \"async\"). Use \"await\" for strongest freshness, \"async\" for lower compaction latency, and \"off\" only when session-memory sync is handled elsewhere.","hasChildren":false}
@@ -186,6 +185,7 @@
{"recordType":"path","path":"agents.defaults.memorySearch.multimodal.maxFileBytes","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance","storage"],"label":"Memory Search Multimodal Max File Bytes","help":"Sets the maximum bytes allowed per multimodal file before it is skipped during memory indexing. Use this to cap upload cost and indexing latency, or raise it for short high-quality audio clips.","hasChildren":false}
{"recordType":"path","path":"agents.defaults.memorySearch.multimodal.modalities","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Memory Search Multimodal Modalities","help":"Selects which multimodal file types are indexed from extraPaths: \"image\", \"audio\", or \"all\". Keep this narrow to avoid indexing large binary corpora unintentionally.","hasChildren":true}
{"recordType":"path","path":"agents.defaults.memorySearch.multimodal.modalities.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.defaults.memorySearch.notifyUser","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.defaults.memorySearch.outputDimensionality","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Memory Search Output Dimensionality","help":"Gemini embedding-2 only: chooses the output vector size for memory embeddings. Use 768, 1536, or 3072 (default), and expect a full reindex when you change it because stored vector dimensions must stay consistent.","hasChildren":false}
{"recordType":"path","path":"agents.defaults.memorySearch.provider","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Memory Search Provider","help":"Selects the embedding backend used to build/query memory vectors: \"openai\", \"gemini\", \"voyage\", \"mistral\", \"ollama\", or \"local\". Keep your most reliable provider here and configure fallback for resilience.","hasChildren":false}
{"recordType":"path","path":"agents.defaults.memorySearch.qmd","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Memory Search QMD Collections","help":"Use this when one agent should query another agent's transcript collections; QMD-specific extra collections let you opt into cross-agent memory search without flattening everything into one shared namespace.","hasChildren":true}
@@ -4333,7 +4333,6 @@
{"recordType":"path","path":"plugins.entries.diffs.config.defaults.wordWrap","kind":"plugin","type":"boolean","required":false,"defaultValue":true,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Default Word Wrap","help":"Wrap long lines by default.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.diffs.config.security","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"plugins.entries.diffs.config.security.allowRemoteViewer","kind":"plugin","type":"boolean","required":false,"defaultValue":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Remote Viewer","help":"Allow non-loopback access to diff viewer URLs when the token path is known.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.diffs.config.viewerBaseUrl","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Viewer Base URL","help":"Persistent gateway base URL used for returned viewer links when a tool call does not pass baseUrl.","hasChildren":false}
{"recordType":"path","path":"plugins.entries.diffs.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable Diffs","hasChildren":false}
{"recordType":"path","path":"plugins.entries.diffs.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true}
{"recordType":"path","path":"plugins.entries.diffs.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,8 +1,50 @@
---
summary: "Redirect to TaskFlow"
summary: "Compatibility note for older ClawFlow references in release notes and docs"
read_when:
- You encounter ClawFlow or openclaw flows in older release notes or docs
- You want to understand what ClawFlow terminology maps to in the current CLI
- You want to translate older flow references into the supported task commands
title: "ClawFlow"
---
# ClawFlow
ClawFlow was renamed to [TaskFlow](/automation/taskflow). See [TaskFlow](/automation/taskflow) for the current documentation.
`ClawFlow` appears in some older OpenClaw release notes and documentation as if it were a user-facing runtime with its own `openclaw flows` command surface.
That is not the current operator-facing surface in this repository.
Today, the supported CLI surface for inspecting and managing detached work is [`openclaw tasks`](/automation/tasks).
## What to use today
- `openclaw tasks list` shows tracked detached runs
- `openclaw tasks show <lookup>` shows one task by task id, run id, or session key
- `openclaw tasks cancel <lookup>` cancels a running task
- `openclaw tasks audit` surfaces stale or broken task runs
```bash
openclaw tasks list
openclaw tasks show <lookup>
openclaw tasks cancel <lookup>
```
## What this means for older references
If you see `ClawFlow` or `openclaw flows` in:
- old release notes
- issue threads
- stale search results
- outdated local notes
translate those instructions to the current task CLI:
- `openclaw flows list` -> `openclaw tasks list`
- `openclaw flows show <lookup>` -> `openclaw tasks show <lookup>`
- `openclaw flows cancel <lookup>` -> `openclaw tasks cancel <lookup>`
## Related
- [Background Tasks](/automation/tasks) — detached work ledger
- [CLI: flows](/cli/flows) — compatibility note for the mistaken command name
- [Cron Jobs](/automation/cron-jobs) — scheduled jobs that may create tasks

View File

@@ -54,13 +54,15 @@ The most effective setups combine multiple mechanisms:
See [Cron vs Heartbeat](/automation/cron-vs-heartbeat) for a detailed comparison of the two scheduling mechanisms.
## TaskFlow
## Older ClawFlow references
[TaskFlow](/automation/taskflow) is the flow orchestration substrate above background tasks. It manages durable multi-step flows with managed and mirrored sync modes, and exposes `openclaw flows list|show|cancel` for inspection and recovery. See [TaskFlow](/automation/taskflow) for details.
Older release notes and docs may mention `ClawFlow` or `openclaw flows`, but the current CLI surface in this repo is `openclaw tasks`.
See [Background Tasks](/automation/tasks) for the supported task ledger commands, plus [ClawFlow](/automation/clawflow) and [CLI: flows](/cli/flows) for compatibility notes.
## Related
- [Cron vs Heartbeat](/automation/cron-vs-heartbeat) — detailed comparison guide
- [TaskFlow](/automation/taskflow) — flow orchestration above tasks
- [ClawFlow](/automation/clawflow) — compatibility note for older docs and release notes
- [Troubleshooting](/automation/troubleshooting) — debugging automation issues
- [Configuration Reference](/gateway/configuration-reference) — all config keys

View File

@@ -1,51 +0,0 @@
---
summary: "TaskFlow flow orchestration layer above background tasks"
read_when:
- You want to understand how TaskFlow relates to background tasks
- You encounter TaskFlow or openclaw flows in release notes or docs
- You want to inspect or manage durable flow state
title: "TaskFlow"
---
# TaskFlow
TaskFlow is the flow orchestration substrate that sits above [background tasks](/automation/tasks). It manages durable multi-step flows with their own state, revision tracking, and sync semantics while individual tasks remain the unit of detached work.
## Sync modes
TaskFlow supports two sync modes:
- **Managed** — TaskFlow owns the lifecycle end-to-end, creating and driving tasks as flow steps progress.
- **Mirrored** — TaskFlow observes externally created tasks and keeps flow state in sync without taking ownership of task creation.
## Durable state and revision tracking
Each flow persists its own state and tracks revisions so progress survives gateway restarts. Revision tracking enables conflict detection when multiple sources attempt to advance the same flow.
## CLI commands
```bash
# List active and recent flows
openclaw flows list
# Show details for a specific flow
openclaw flows show <lookup>
# Cancel a running flow
openclaw flows cancel <lookup>
```
- `openclaw flows list` — shows tracked flows with status and sync mode
- `openclaw flows show <lookup>` — inspect one flow by flow id or lookup key
- `openclaw flows cancel <lookup>` — cancel a running flow and its active tasks
## How flows relate to tasks
Flows coordinate tasks, not replace them. A single flow may drive multiple background tasks over its lifetime. Use `openclaw tasks` to inspect individual task records and `openclaw flows` to inspect the orchestrating flow.
## Related
- [Background Tasks](/automation/tasks) — the detached work ledger that flows coordinate
- [CLI: flows](/cli/flows) — CLI command reference for `openclaw flows`
- [Automation Overview](/automation) — all automation mechanisms at a glance
- [Cron Jobs](/automation/cron-jobs) — scheduled jobs that may feed into flows

View File

@@ -224,11 +224,11 @@ A sweeper runs every **60 seconds** and handles three things:
## How tasks relate to other systems
### Tasks and TaskFlow
### Tasks and older flow references
[TaskFlow](/automation/taskflow) is the flow orchestration layer above background tasks. A single flow may coordinate multiple tasks over its lifetime using managed or mirrored sync modes. Use `openclaw tasks` to inspect individual task records and `openclaw flows` to inspect the orchestrating flow.
Some older OpenClaw release notes and docs referred to task management as `ClawFlow` and documented an `openclaw flows` command surface.
See [TaskFlow](/automation/taskflow) and [CLI: flows](/cli/flows) for details.
In the current codebase, the supported operator surface is `openclaw tasks`. See [ClawFlow](/automation/clawflow) and [CLI: flows](/cli/flows) for compatibility notes that map those older references to the current task commands.
### Tasks and cron
@@ -253,9 +253,9 @@ A task's `runId` links to the agent run doing the work. Agent lifecycle events (
## Related
- [Automation Overview](/automation) — all automation mechanisms at a glance
- [TaskFlow](/automation/taskflow) — flow orchestration above tasks
- [ClawFlow](/automation/clawflow) — compatibility note for older docs and release notes
- [Cron Jobs](/automation/cron-jobs) — scheduling background work
- [Cron vs Heartbeat](/automation/cron-vs-heartbeat) — choosing the right mechanism
- [Heartbeat](/gateway/heartbeat) — periodic main-session turns
- [CLI: flows](/cli/flows) — CLI reference for `openclaw flows`
- [CLI: flows](/cli/flows) — compatibility note for the mistaken command name
- [CLI: Tasks](/cli/index#tasks) — CLI command reference

View File

@@ -192,7 +192,7 @@ In group `120363403215116621@g.us` with agents `["alfred", "baerbel"]`:
```
Session: agent:alfred:whatsapp:group:120363403215116621@g.us
History: [user message, alfred's previous responses]
Workspace: /Users/user/openclaw-alfred/
Workspace: /Users/pascal/openclaw-alfred/
Tools: read, write, exec
```
@@ -201,7 +201,7 @@ Tools: read, write, exec
```
Session: agent:baerbel:whatsapp:group:120363403215116621@g.us
History: [user message, baerbel's previous responses]
Workspace: /Users/user/openclaw-baerbel/
Workspace: /Users/pascal/openclaw-baerbel/
Tools: read only
```

View File

@@ -183,9 +183,9 @@ done:
```
- `streaming: "off"` is the default. OpenClaw waits for the final reply and sends it once.
- `streaming: "partial"` creates one editable preview message for the current assistant block instead of sending multiple partial messages.
- `blockStreaming: true` enables separate Matrix progress messages. With `streaming: "partial"`, Matrix keeps the live draft for the current block and preserves completed blocks as separate messages.
- When `streaming: "partial"` and `blockStreaming` is off, Matrix only edits the live draft and sends the completed reply once that block or turn finishes.
- `streaming: "partial"` creates one editable preview message instead of sending multiple partial messages.
- `blockStreaming: true` enables separate Matrix progress messages instead of final-only delivery when `streaming` is off.
- When `streaming: "partial"`, Matrix disables shared block streaming so draft edits do not double-send.
- If the preview no longer fits in one Matrix event, OpenClaw stops preview streaming and falls back to normal final delivery.
- Media replies still send attachments normally. If a stale preview can no longer be reused safely, OpenClaw redacts it before sending the final media reply.
- Preview edits cost extra Matrix API calls. Leave streaming off if you want the most conservative rate-limit behavior.

View File

@@ -1,43 +1,36 @@
---
summary: "CLI reference for `openclaw flows` commands"
summary: "Compatibility note for the mistakenly documented `openclaw flows` command"
read_when:
- You want to list, inspect, or cancel TaskFlow flows from the CLI
- You encounter openclaw flows in release notes or docs
- You encounter openclaw flows in older release notes, issue threads, or search results
- You want to know what command replaced openclaw flows
title: "flows"
---
# `openclaw flows`
Inspect and manage [TaskFlow](/automation/taskflow) flows from the command line.
`openclaw flows` is **not** a current OpenClaw CLI command.
## Commands
### `flows list`
Some older release notes and docs mistakenly documented a `flows` command surface. The supported operator surface is [`openclaw tasks`](/automation/tasks).
```bash
openclaw flows list [--json]
openclaw tasks list
openclaw tasks show <lookup>
openclaw tasks cancel <lookup>
```
List active and recent flows with status and sync mode.
## Use instead
### `flows show`
- `openclaw tasks list` — list tracked background tasks
- `openclaw tasks show <lookup>` — inspect one task by task id, run id, or session key
- `openclaw tasks cancel <lookup>` — cancel a running background task
- `openclaw tasks notify <lookup> <policy>` — change task notification behavior
- `openclaw tasks audit` — surface stale or broken task runs
```bash
openclaw flows show <lookup>
```
## Why this page exists
Show details for a specific flow by flow id or lookup key, including state, revision history, and associated tasks.
### `flows cancel`
```bash
openclaw flows cancel <lookup>
```
Cancel a running flow and its active tasks.
This page stays in place so existing links from older changelog entries, issue threads, and search results have a clear correction instead of a dead end.
## Related
- [TaskFlow](/automation/taskflow) — flow orchestration overview
- [Background Tasks](/automation/tasks) — the detached work ledger
- [Background Tasks](/automation/tasks) — detached work ledger
- [CLI reference](/cli/index) — full command tree

View File

@@ -46,7 +46,6 @@ This page describes the current CLI behavior. If commands change, update this do
- [`browser`](/cli/browser)
- [`cron`](/cli/cron)
- [`tasks`](/cli/index#tasks)
- [`flows`](/cli/flows)
- [`dns`](/cli/dns)
- [`docs`](/cli/docs)
- [`hooks`](/cli/hooks)
@@ -173,10 +172,6 @@ openclaw [--dev] [--profile <name>] <command>
show
notify
cancel
flows
list
show
cancel
gateway
call
health

View File

@@ -879,10 +879,6 @@
{
"source": "/gateway/trusted-proxy",
"destination": "/gateway/trusted-proxy-auth"
},
{
"source": "/automation/clawflow",
"destination": "/automation/taskflow"
}
],
"navigation": {
@@ -1126,7 +1122,7 @@
"automation/cron-jobs",
"automation/cron-vs-heartbeat",
"automation/tasks",
"automation/taskflow",
"automation/clawflow",
"automation/troubleshooting",
"automation/webhook",
"automation/gmail-pubsub",

View File

@@ -127,7 +127,7 @@ When set, `OPENCLAW_HOME` replaces the system home directory (`$HOME` / `os.home
<key>EnvironmentVariables</key>
<dict>
<key>OPENCLAW_HOME</key>
<string>/Users/user</string>
<string>/Users/kira</string>
</dict>
```

View File

@@ -96,8 +96,6 @@ Run a persistent OpenClaw Gateway on Oracle Cloud's **Always Free** ARM tier (up
systemctl --user restart openclaw-gateway
```
`gateway.trustedProxies=["127.0.0.1"]` is for the local Tailscale Serve proxy. Diff viewer routes keep fail-closed behavior in this setup: raw `127.0.0.1` viewer requests without forwarded proxy headers can return `Diff not found`. Use `mode=file` / `mode=both` for attachments, or intentionally enable remote viewers and set `plugins.entries.diffs.config.viewerBaseUrl` (or pass a proxy `baseUrl`) if you need shareable viewer links.
</Step>
<Step title="Lock down VCN security">

View File

@@ -98,9 +98,73 @@ openclaw channels login
```
On macOS, Podman machine may make the browser appear non-local to the gateway.
If the Control UI reports device-auth errors after launch, use the Tailscale guidance in
If the Control UI reports device-auth errors after launch, prefer the SSH
tunnel flow in [macOS Podman SSH tunnel](#macos-podman-ssh-tunnel). For
remote HTTPS access, use the Tailscale guidance in
[Podman + Tailscale](#podman--tailscale).
## macOS Podman SSH tunnel
On macOS, Podman machine can make the browser appear non-local to the gateway even when the published port is only on `127.0.0.1`.
For local browser access, use an SSH tunnel into the Podman VM and open the tunneled localhost port instead.
Recommended local tunnel port:
- `28889` on the Mac host
- forwarded to `127.0.0.1:18789` inside the Podman VM
Start the tunnel in a separate terminal:
```bash
ssh -N \
-i ~/.local/share/containers/podman/machine/machine \
-p <podman-vm-ssh-port> \
-L 28889:127.0.0.1:18789 \
core@127.0.0.1
```
In that command, `<podman-vm-ssh-port>` is the Podman VM's SSH port on the Mac host. Check your current value with:
```bash
podman system connection list
```
Allow the tunneled browser origin once. This is required the first time you use the tunnel because the launcher can auto-seed the Podman-published port, but it cannot infer your chosen browser tunnel port:
```bash
OPENCLAW_CONTAINER=openclaw openclaw config set gateway.controlUi.allowedOrigins \
'["http://127.0.0.1:18789","http://localhost:18789","http://127.0.0.1:28889","http://localhost:28889"]' \
--strict-json
podman restart openclaw
```
That is a one-time step for the default `28889` tunnel.
Then open:
```text
http://127.0.0.1:28889/
```
Notes:
- `18789` is usually already occupied on the Mac host by the Podman-published gateway port, so the tunnel uses `28889` as the local browser port.
- If the UI asks for pairing approval, prefer explicit container-targeted or explicit-URL commands so the host CLI does not fall back to local pairing files:
```bash
openclaw --container openclaw devices list
openclaw --container openclaw devices approve --latest
```
- Equivalent explicit-URL form:
```bash
openclaw devices list \
--url ws://127.0.0.1:28889 \
--token "$(sed -n 's/^OPENCLAW_GATEWAY_TOKEN=//p' ~/.openclaw/.env | head -n1)"
```
<a id="podman--tailscale"></a>
## Podman + Tailscale
@@ -111,7 +175,7 @@ Podman-specific note:
- Keep the Podman publish host at `127.0.0.1`.
- Prefer host-managed `tailscale serve` over `openclaw gateway --tailscale serve`.
- On macOS, if local browser device-auth context is unreliable, use Tailscale access instead of ad hoc local tunnel workarounds.
- For local macOS browser access without HTTPS, prefer the SSH tunnel section above.
See:

View File

@@ -126,8 +126,6 @@ openclaw config set gateway.trustedProxies '["127.0.0.1"]'
systemctl --user restart openclaw-gateway
```
`gateway.trustedProxies=["127.0.0.1"]` is for the local Tailscale Serve proxy. Diff viewer routes keep fail-closed behavior in this setup: raw `127.0.0.1` viewer requests without forwarded proxy headers can return `Diff not found`. Use `mode=file` / `mode=both` for attachments, or intentionally enable remote viewers and set `plugins.entries.diffs.config.viewerBaseUrl` (or pass a proxy `baseUrl`) if you need shareable viewer links.
## 7) Verify
```bash

View File

@@ -77,19 +77,18 @@ Connect OpenClaw to QQ via the QQ Bot API. Supports private chats, group
mentions, channel messages, and rich media including voice, images, videos,
and files.
- **npm:** `@tencent-connect/openclaw-qqbot`
- **repo:** [github.com/tencent-connect/openclaw-qqbot](https://github.com/tencent-connect/openclaw-qqbot)
- **npm:** `@sliverp/qqbot`
- **repo:** [github.com/sliverp/qqbot](https://github.com/sliverp/qqbot)
```bash
openclaw plugins install @tencent-connect/openclaw-qqbot
openclaw plugins install @sliverp/qqbot
```
### wecom
WeCom channel plugin for OpenClaw by the Tencent WeCom team. Powered by
WeCom Bot WebSocket persistent connections, it supports direct messages & group
chats, streaming replies, proactive messaging, image/file processing, Markdown
formatting, built-in access control, and document/meeting/messaging skills.
OpenClaw Enterprise WeCom Channel Plugin.
A bot plugin powered by WeCom AI Bot WebSocket persistent connections,
supports direct messages & group chats, streaming replies, and proactive messaging.
- **npm:** `@wecom/wecom-openclaw-plugin`
- **repo:** [github.com/WecomTeam/wecom-openclaw-plugin](https://github.com/WecomTeam/wecom-openclaw-plugin)

View File

@@ -115,40 +115,6 @@ await api.runtime.subagent.deleteSession({
Untrusted plugins can still run subagents, but override requests are rejected.
</Warning>
### `api.runtime.taskFlow`
Bind a TaskFlow runtime to an existing OpenClaw session key or trusted tool
context, then create and manage TaskFlows without passing an owner on every call.
```typescript
const taskFlow = api.runtime.taskFlow.fromToolContext(ctx);
const created = taskFlow.createManaged({
controllerId: "my-plugin/review-batch",
goal: "Review new pull requests",
});
const child = taskFlow.runTask({
flowId: created.flowId,
runtime: "acp",
childSessionKey: "agent:main:subagent:reviewer",
task: "Review PR #123",
status: "running",
startedAt: Date.now(),
});
const waiting = taskFlow.setWaiting({
flowId: created.flowId,
expectedRevision: created.revision,
currentStep: "await-human-reply",
waitJson: { kind: "reply", channel: "telegram" },
});
```
Use `bindSession({ sessionKey, requesterOrigin })` when you already have a
trusted OpenClaw session key from your own binding layer. Do not bind from raw
user input.
### `api.runtime.tts`
Text-to-speech synthesis.

View File

@@ -119,7 +119,7 @@ All fields are optional unless noted:
- `fileScale` (`number`): device scale override (`1`-`4`).
- `fileMaxWidth` (`number`): max render width in CSS pixels (`640`-`2400`).
- `ttlSeconds` (`number`): viewer artifact TTL in seconds. Default 1800, max 21600.
- `baseUrl` (`string`): viewer URL origin override. Overrides plugin `viewerBaseUrl`. Must be `http` or `https`, no query/hash.
- `baseUrl` (`string`): viewer URL origin override. Must be `http` or `https`, no query/hash.
Validation and limits:
@@ -231,29 +231,6 @@ Supported defaults:
Explicit tool parameters override these defaults.
Persistent viewer URL config:
- `viewerBaseUrl` (`string`, optional)
- Plugin-owned fallback for returned viewer links when a tool call does not pass `baseUrl`.
- Must be `http` or `https`, no query/hash.
Example:
```json5
{
plugins: {
entries: {
diffs: {
enabled: true,
config: {
viewerBaseUrl: "https://gateway.example.com/openclaw",
},
},
},
},
}
```
## Security config
- `security.allowRemoteViewer` (`boolean`, default `false`)
@@ -308,9 +285,8 @@ The viewer document resolves those assets relative to the viewer URL, so an opti
URL construction behavior:
- If tool-call `baseUrl` is provided, it is used after strict validation.
- Else if plugin `viewerBaseUrl` is configured, it is used.
- Without either override, viewer URL defaults to loopback `127.0.0.1`.
- If `baseUrl` is provided, it is used after strict validation.
- Without `baseUrl`, viewer URL defaults to loopback `127.0.0.1`.
- If gateway bind mode is `custom` and `gateway.customBindHost` is set, that host is used.
`baseUrl` rules:
@@ -377,13 +353,8 @@ Viewer accessibility issues:
- Viewer URL resolves to `127.0.0.1` by default.
- For remote access scenarios, either:
- set plugin `viewerBaseUrl`, or
- pass `baseUrl` per tool call, or
- use `gateway.bind=custom` and `gateway.customBindHost`
- If `gateway.trustedProxies` includes loopback for a same-host proxy (for example Tailscale Serve), raw loopback viewer requests without forwarded client-IP headers fail closed by design.
- For that proxy topology:
- prefer `mode: "file"` or `mode: "both"` when you only need an attachment, or
- intentionally enable `security.allowRemoteViewer` and set plugin `viewerBaseUrl` or pass a proxy/public `baseUrl` when you need a shareable viewer URL
- Enable `security.allowRemoteViewer` only when you intend external viewer access.
Unmodified-lines row has no expand button:

View File

@@ -10,7 +10,7 @@ read_when:
Lobster is a workflow shell that lets OpenClaw run multi-step tool sequences as a single, deterministic operation with explicit approval checkpoints.
Lobster is one authoring layer above detached background work. For flow orchestration above individual tasks, see [TaskFlow](/automation/taskflow) (`openclaw flows`). For the task activity ledger, see [`openclaw tasks`](/automation/tasks).
Lobster is one authoring layer above detached background work. If you run into older `ClawFlow` terminology, treat it as historical naming around the same task-oriented runtime area; the current operator-facing CLI surface is [`openclaw tasks`](/automation/tasks).
## Hook

View File

@@ -29,10 +29,4 @@ describe("anthropic vertex region helpers", () => {
"global",
);
});
it("does not infer a Vertex region from custom proxy hosts", () => {
expect(
resolveAnthropicVertexRegionFromBaseUrl("https://proxy.example.com/google/aiplatform"),
).toBeUndefined();
});
});

View File

@@ -1,7 +1,6 @@
import { existsSync, readFileSync } from "node:fs";
import { homedir, platform } from "node:os";
import { join } from "node:path";
import { resolveProviderEndpoint } from "openclaw/plugin-sdk/provider-http";
const ANTHROPIC_VERTEX_DEFAULT_REGION = "global";
const ANTHROPIC_VERTEX_REGION_RE = /^[a-z0-9-]+$/;
@@ -48,8 +47,21 @@ export function resolveAnthropicVertexProjectId(
}
export function resolveAnthropicVertexRegionFromBaseUrl(baseUrl?: string): string | undefined {
const endpoint = resolveProviderEndpoint(baseUrl);
return endpoint.endpointClass === "google-vertex" ? endpoint.googleVertexRegion : undefined;
const trimmed = baseUrl?.trim();
if (!trimmed) {
return undefined;
}
try {
const host = new URL(trimmed).hostname.toLowerCase();
if (host === "aiplatform.googleapis.com") {
return "global";
}
const match = /^([a-z0-9-]+)-aiplatform\.googleapis\.com$/.exec(host);
return match?.[1];
} catch {
return undefined;
}
}
export function resolveAnthropicVertexClientRegion(params?: {

View File

@@ -1,6 +1,5 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import { streamSimple } from "@mariozechner/pi-ai";
import { resolveProviderEndpoint } from "openclaw/plugin-sdk/provider-http";
import { streamWithPayloadPatch } from "openclaw/plugin-sdk/provider-stream";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
@@ -59,7 +58,12 @@ function isAnthropicPublicApiBaseUrl(baseUrl: unknown): boolean {
if (typeof baseUrl !== "string" || !baseUrl.trim()) {
return true;
}
return resolveProviderEndpoint(baseUrl).endpointClass === "anthropic-public";
try {
return new URL(baseUrl).hostname.toLowerCase() === "api.anthropic.com";
} catch {
return baseUrl.toLowerCase().includes("api.anthropic.com");
}
}
function resolveAnthropicFastServiceTier(enabled: boolean): AnthropicServiceTier {

View File

@@ -4,8 +4,8 @@ import type {
} from "openclaw/plugin-sdk/media-understanding";
import {
assertOkOrThrowHttpError,
normalizeBaseUrl,
postTranscriptionRequest,
resolveProviderHttpRequestConfig,
requireTranscriptionText,
} from "openclaw/plugin-sdk/provider-http";
@@ -31,19 +31,9 @@ export async function transcribeDeepgramAudio(
params: AudioTranscriptionRequest,
): Promise<AudioTranscriptionResult> {
const fetchFn = params.fetchFn ?? fetch;
const baseUrl = normalizeBaseUrl(params.baseUrl, DEFAULT_DEEPGRAM_AUDIO_BASE_URL);
const allowPrivate = Boolean(params.baseUrl?.trim());
const model = resolveModel(params.model);
const { baseUrl, allowPrivateNetwork, headers } = resolveProviderHttpRequestConfig({
baseUrl: params.baseUrl,
defaultBaseUrl: DEFAULT_DEEPGRAM_AUDIO_BASE_URL,
headers: params.headers,
defaultHeaders: {
authorization: `Token ${params.apiKey}`,
"content-type": params.mime ?? "application/octet-stream",
},
provider: "deepgram",
capability: "audio",
transport: "media-understanding",
});
const url = new URL(`${baseUrl}/listen`);
url.searchParams.set("model", model);
@@ -59,14 +49,23 @@ export async function transcribeDeepgramAudio(
}
}
const headers = new Headers(params.headers);
if (!headers.has("authorization")) {
headers.set("authorization", `Token ${params.apiKey}`);
}
if (!headers.has("content-type")) {
headers.set("content-type", params.mime ?? "application/octet-stream");
}
const body = new Uint8Array(params.buffer);
const { response: res, release } = await postTranscriptionRequest({
url: url.toString(),
provider: "deepgram",
headers,
body,
timeoutMs: params.timeoutMs,
fetchFn,
allowPrivateNetwork,
allowPrivateNetwork: allowPrivate,
});
try {

View File

@@ -63,7 +63,6 @@ Useful options:
- `title`: explicit viewer title
- `ttlSeconds`: artifact lifetime
- `baseUrl`: override the gateway base URL used in the returned viewer link (origin or origin+base path only; no query/hash)
- `viewerBaseUrl` plugin config: persistent fallback used when a tool call omits `baseUrl`
Input safety limits:
@@ -110,24 +109,6 @@ Explicit tool parameters still win over these defaults.
Security options:
- `security.allowRemoteViewer` (default `false`): allows non-loopback access to `/plugins/diffs/view/...` token URLs
- `viewerBaseUrl` (optional): persistent viewer-link origin/path fallback for shareable URLs
Example:
```json5
{
plugins: {
entries: {
diffs: {
enabled: true,
config: {
viewerBaseUrl: "https://gateway.example.com/openclaw",
},
},
},
},
}
```
## Example Agent Prompts
@@ -196,9 +177,7 @@ diff --git a/src/example.ts b/src/example.ts
- The viewer is hosted locally through the gateway under `/plugins/diffs/...`.
- Artifacts are ephemeral and stored in the plugin temp subfolder (`$TMPDIR/openclaw-diffs`).
- Default viewer URLs use loopback (`127.0.0.1`) unless you set plugin `viewerBaseUrl`, pass `baseUrl`, or use `gateway.bind=custom` + `gateway.customBindHost`.
- If `gateway.trustedProxies` includes loopback for a same-host proxy (for example Tailscale Serve), raw `127.0.0.1` viewer requests without forwarded client-IP headers fail closed by design.
- In that topology, prefer `mode=file` / `mode=both` for attachments, or intentionally enable remote viewers and set plugin `viewerBaseUrl` (or pass a proxy/public `baseUrl`) when you need a shareable viewer URL.
- Default viewer URLs use loopback (`127.0.0.1`) unless you set `baseUrl` (or use `gateway.bind=custom` + `gateway.customBindHost`).
- Remote viewer misses are throttled to reduce token-guess abuse.
- PNG or PDF rendering requires a Chromium-compatible browser. Set `browser.executablePath` if auto-detection is not enough.
- If your delivery channel compresses images heavily (for example Telegram or WhatsApp), prefer `fileFormat: "pdf"` to preserve readability.

View File

@@ -8,7 +8,6 @@ import {
diffsPluginConfigSchema,
resolveDiffsPluginDefaults,
resolveDiffsPluginSecurity,
resolveDiffsPluginViewerBaseUrl,
} from "./src/config.js";
import { createDiffsHttpHandler } from "./src/http.js";
import { DIFFS_AGENT_GUIDANCE } from "./src/prompt-guidance.js";
@@ -23,18 +22,14 @@ export default definePluginEntry({
register(api: OpenClawPluginApi) {
const defaults = resolveDiffsPluginDefaults(api.pluginConfig);
const security = resolveDiffsPluginSecurity(api.pluginConfig);
const viewerBaseUrl = resolveDiffsPluginViewerBaseUrl(api.pluginConfig);
const store = new DiffArtifactStore({
rootDir: path.join(resolvePreferredOpenClawTmpDir(), "openclaw-diffs"),
logger: api.logger,
});
api.registerTool(
(ctx) => createDiffsTool({ api, store, defaults, viewerBaseUrl, context: ctx }),
{
name: "diffs",
},
);
api.registerTool((ctx) => createDiffsTool({ api, store, defaults, context: ctx }), {
name: "diffs",
});
api.registerHttpRoute({
path: "/plugins/diffs",
auth: "plugin",

View File

@@ -4,10 +4,6 @@
"description": "Read-only diff viewer and file renderer for agents.",
"skills": ["./skills"],
"uiHints": {
"viewerBaseUrl": {
"label": "Viewer Base URL",
"help": "Persistent gateway base URL used for returned viewer links when a tool call does not pass baseUrl."
},
"defaults.fontFamily": {
"label": "Default Font",
"help": "Preferred font family name for diff content and headers."
@@ -73,9 +69,6 @@
"type": "object",
"additionalProperties": false,
"properties": {
"viewerBaseUrl": {
"type": "string"
},
"defaults": {
"type": "object",
"additionalProperties": false,

View File

@@ -8,7 +8,6 @@ When you need to show edits as a real diff, prefer the `diffs` tool instead of w
The `diffs` tool accepts either `before` + `after` text, or a unified `patch` string.
Use `mode=view` when you want an interactive gateway-hosted viewer. After the tool returns, use `details.viewerUrl` with the canvas tool via `canvas present` or `canvas navigate`.
If the deployment uses a loopback trusted proxy (for example Tailscale Serve with `gateway.trustedProxies` including `127.0.0.1`), raw loopback viewer requests can fail closed without forwarded client-IP headers. In that topology, prefer `mode=file` / `mode=both`, or use a configured `viewerBaseUrl` / explicit proxy/public `baseUrl` when you need a shareable viewer URL.
Use `mode=file` when you need a rendered file artifact. Set `fileFormat=png` (default) or `fileFormat=pdf`. The tool result includes `details.filePath`.

View File

@@ -14,7 +14,6 @@ import {
resolveDiffImageRenderOptions,
resolveDiffsPluginDefaults,
resolveDiffsPluginSecurity,
resolveDiffsPluginViewerBaseUrl,
} from "./config.js";
import { renderDiffDocument } from "./render.js";
import { buildViewerUrl, normalizeViewerBaseUrl } from "./url.js";
@@ -220,25 +219,10 @@ describe("resolveDiffsPluginSecurity", () => {
});
});
describe("resolveDiffsPluginViewerBaseUrl", () => {
it("defaults to undefined when config is missing", () => {
expect(resolveDiffsPluginViewerBaseUrl(undefined)).toBeUndefined();
});
it("normalizes configured viewer base URLs", () => {
expect(
resolveDiffsPluginViewerBaseUrl({
viewerBaseUrl: "https://example.com/openclaw/",
}),
).toBe("https://example.com/openclaw");
});
});
describe("diffs plugin schema surfaces", () => {
it("preserves defaults and security for direct safeParse callers", () => {
expect(
diffsPluginConfigSchema.safeParse?.({
viewerBaseUrl: "https://example.com/openclaw/",
defaults: {
theme: "light",
},
@@ -249,7 +233,6 @@ describe("diffs plugin schema surfaces", () => {
).toMatchObject({
success: true,
data: {
viewerBaseUrl: "https://example.com/openclaw",
defaults: {
fontFamily: "Fira Code",
fontSize: 15,
@@ -294,24 +277,6 @@ describe("diffs plugin schema surfaces", () => {
});
});
it("rejects invalid viewerBaseUrl config values", () => {
expect(
diffsPluginConfigSchema.safeParse?.({
viewerBaseUrl: "javascript:alert(1)",
}),
).toMatchObject({
success: false,
error: {
issues: [
{
path: ["viewerBaseUrl"],
message: "viewerBaseUrl must use http or https: javascript:alert(1)",
},
],
},
});
});
it("keeps the runtime json schema in sync with the manifest config schema", () => {
const manifest = JSON.parse(
fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf8"),
@@ -364,16 +329,6 @@ describe("diffs viewer URL helpers", () => {
).toBe("https://example.com/openclaw/plugins/diffs/view/id/token");
});
it("prefers normalized viewerBaseUrl strings too", () => {
expect(
buildViewerUrl({
config: {},
baseUrl: "https://example.com/openclaw/",
viewerPath: "/plugins/diffs/view/id/token",
}),
).toBe("https://example.com/openclaw/plugins/diffs/view/id/token");
});
it("rejects base URLs with query/hash", () => {
expect(() => normalizeViewerBaseUrl("https://example.com?a=1")).toThrow(
"baseUrl must not include query/hash",
@@ -382,12 +337,6 @@ describe("diffs viewer URL helpers", () => {
"baseUrl must not include query/hash",
);
});
it("uses the configured field name in viewerBaseUrl validation errors", () => {
expect(() => normalizeViewerBaseUrl("https://example.com?a=1", "viewerBaseUrl")).toThrow(
"viewerBaseUrl must not include query/hash",
);
});
});
describe("renderDiffDocument", () => {

View File

@@ -18,10 +18,8 @@ import {
type DiffTheme,
type DiffToolDefaults,
} from "./types.js";
import { normalizeViewerBaseUrl } from "./url.js";
type DiffsPluginConfig = {
viewerBaseUrl?: string;
defaults?: {
fontFamily?: string;
fontSize?: number;
@@ -96,19 +94,6 @@ export const DEFAULT_DIFFS_PLUGIN_SECURITY: DiffsPluginSecurityConfig = {
};
const DiffsPluginJsonSchemaSource = z.strictObject({
viewerBaseUrl: z
.string()
.superRefine((value, ctx) => {
try {
normalizeViewerBaseUrl(value, "viewerBaseUrl");
} catch (error) {
ctx.addIssue({
code: "custom",
message: error instanceof Error ? error.message : "Invalid viewerBaseUrl",
});
}
})
.optional(),
defaults: z
.strictObject({
fontFamily: z.string().default(DEFAULT_DIFFS_TOOL_DEFAULTS.fontFamily).optional(),
@@ -199,9 +184,7 @@ function resolveConfiguredValue<T>(options: {
}
function buildDiffsPluginConfigShape(config: DiffsPluginConfig): DiffsPluginConfig {
const viewerBaseUrl = resolveDiffsPluginViewerBaseUrl(config);
return {
...(viewerBaseUrl !== undefined ? { viewerBaseUrl } : {}),
...(config.defaults !== undefined ? { defaults: resolveDiffsPluginDefaults(config) } : {}),
...(config.security !== undefined ? { security: resolveDiffsPluginSecurity(config) } : {}),
};
@@ -272,20 +255,6 @@ export function resolveDiffsPluginSecurity(config: unknown): DiffsPluginSecurity
};
}
export function resolveDiffsPluginViewerBaseUrl(config: unknown): string | undefined {
if (!config || typeof config !== "object" || Array.isArray(config)) {
return undefined;
}
const viewerBaseUrl = (config as DiffsPluginConfig).viewerBaseUrl;
if (typeof viewerBaseUrl !== "string") {
return undefined;
}
const normalized = viewerBaseUrl.trim();
return normalized ? normalizeViewerBaseUrl(normalized) : undefined;
}
export function toPresentationDefaults(defaults: DiffToolDefaults): DiffPresentationDefaults {
const {
fontFamily,

View File

@@ -340,14 +340,6 @@ describe("createDiffsHttpHandler", () => {
allowRemoteViewer: false,
expectedStatusCode: 404,
},
{
name: "blocks proxied loopback requests when trusted proxies are configured",
request: localReq,
headers: { "x-forwarded-for": "203.0.113.10" },
trustedProxies: ["127.0.0.1"],
allowRemoteViewer: false,
expectedStatusCode: 404,
},
{
name: "allows remote access when allowRemoteViewer is enabled",
request: remoteReq,

View File

@@ -41,57 +41,6 @@ describe("diffs tool", () => {
expect((result?.details as Record<string, unknown>).viewerUrl).toBeDefined();
});
it("uses configured viewerBaseUrl when tool input omits baseUrl", async () => {
const tool = createDiffsTool({
api: createApi({
viewerBaseUrl: "https://example.com/openclaw/",
}),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
viewerBaseUrl: "https://example.com/openclaw",
});
const result = await tool.execute?.("tool-viewer-config", {
before: "one\n",
after: "two\n",
path: "README.md",
mode: "view",
});
expect(readTextContent(result, 0)).toContain(
"https://example.com/openclaw/plugins/diffs/view/",
);
expect((result?.details as Record<string, unknown>).viewerUrl).toEqual(
expect.stringContaining("https://example.com/openclaw/plugins/diffs/view/"),
);
});
it("prefers per-call baseUrl over configured viewerBaseUrl", async () => {
const tool = createDiffsTool({
api: createApi({
viewerBaseUrl: "https://example.com/openclaw",
}),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
viewerBaseUrl: "https://example.com/openclaw",
});
const result = await tool.execute?.("tool-viewer-override", {
before: "one\n",
after: "two\n",
path: "README.md",
mode: "view",
baseUrl: "https://preview.example.com/review",
});
expect(readTextContent(result, 0)).toContain(
"https://preview.example.com/review/plugins/diffs/view/",
);
expect((result?.details as Record<string, unknown>).viewerUrl).toEqual(
expect.stringContaining("https://preview.example.com/review/plugins/diffs/view/"),
);
});
it("does not expose reserved format in the tool schema", async () => {
const tool = createDiffsTool({
api: createApi(),
@@ -471,7 +420,7 @@ describe("diffs tool", () => {
});
});
function createApi(pluginConfig?: Record<string, unknown>): OpenClawPluginApi {
function createApi(): OpenClawPluginApi {
return createTestPluginApi({
id: "diffs",
name: "Diffs",
@@ -483,7 +432,6 @@ function createApi(pluginConfig?: Record<string, unknown>): OpenClawPluginApi {
bind: "loopback",
},
},
pluginConfig,
runtime: {} as OpenClawPluginApi["runtime"],
}) as OpenClawPluginApi;
}

View File

@@ -125,7 +125,7 @@ const DiffsToolSchema = Type.Object(
baseUrl: Type.Optional(
Type.String({
description:
"Optional gateway base URL override used when building the viewer URL. Overrides configured viewerBaseUrl, for example https://gateway.example.com.",
"Optional gateway base URL override used when building the viewer URL, for example https://gateway.example.com.",
}),
),
},
@@ -142,7 +142,6 @@ export function createDiffsTool(params: {
api: OpenClawPluginApi;
store: DiffArtifactStore;
defaults: DiffToolDefaults;
viewerBaseUrl?: string;
screenshotter?: DiffScreenshotter;
context?: OpenClawPluginToolContext;
}): AnyAgentTool {
@@ -238,7 +237,7 @@ export function createDiffsTool(params: {
const viewerUrl = buildViewerUrl({
config: params.api.config,
viewerPath: artifact.viewerPath,
baseUrl: normalizeBaseUrl(toolParams.baseUrl) ?? params.viewerBaseUrl,
baseUrl: normalizeBaseUrl(toolParams.baseUrl),
});
const baseDetails = {

View File

@@ -1,7 +1,6 @@
import type { OpenClawConfig } from "../api.js";
const DEFAULT_GATEWAY_PORT = 18789;
type ViewerBaseUrlFieldName = "baseUrl" | "viewerBaseUrl";
export function buildViewerUrl(params: {
config: OpenClawConfig;
@@ -21,21 +20,18 @@ export function buildViewerUrl(params: {
return parsedBase.toString();
}
export function normalizeViewerBaseUrl(
raw: string,
fieldName: ViewerBaseUrlFieldName = "baseUrl",
): string {
export function normalizeViewerBaseUrl(raw: string): string {
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
throw new Error(`Invalid ${fieldName}: ${raw}`);
throw new Error(`Invalid baseUrl: ${raw}`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`${fieldName} must use http or https: ${raw}`);
throw new Error(`baseUrl must use http or https: ${raw}`);
}
if (parsed.search || parsed.hash) {
throw new Error(`${fieldName} must not include query/hash: ${raw}`);
throw new Error(`baseUrl must not include query/hash: ${raw}`);
}
parsed.search = "";
parsed.hash = "";

View File

@@ -8,7 +8,7 @@ import {
import { resolveFeishuRuntimeAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import type { CommentFileType } from "./comment-target.js";
import { deliverCommentThreadText } from "./drive.js";
import { replyComment } from "./drive.js";
import { getFeishuRuntime } from "./runtime.js";
export type CreateFeishuCommentReplyDispatcherParams = {
@@ -19,7 +19,6 @@ export type CreateFeishuCommentReplyDispatcherParams = {
fileToken: string;
fileType: CommentFileType;
commentId: string;
isWholeComment?: boolean;
};
export function createFeishuCommentReplyDispatcher(
@@ -64,12 +63,11 @@ export function createFeishuCommentReplyDispatcher(
}
const chunks = core.channel.text.chunkTextWithMode(reply.text, textChunkLimit, chunkMode);
for (const chunk of chunks) {
await deliverCommentThreadText(client, {
await replyComment(client, {
file_token: params.fileToken,
file_type: params.fileType,
comment_id: params.commentId,
content: chunk,
is_whole_comment: params.isWholeComment,
});
}
},

View File

@@ -8,7 +8,7 @@ const resolveDriveCommentEventTurnMock = vi.hoisted(() => vi.fn());
const createFeishuCommentReplyDispatcherMock = vi.hoisted(() => vi.fn());
const maybeCreateDynamicAgentMock = vi.hoisted(() => vi.fn());
const createFeishuClientMock = vi.hoisted(() => vi.fn(() => ({ request: vi.fn() })));
const deliverCommentThreadTextMock = vi.hoisted(() => vi.fn());
const replyCommentMock = vi.hoisted(() => vi.fn());
vi.mock("./monitor.comment.js", () => ({
resolveDriveCommentEventTurn: resolveDriveCommentEventTurnMock,
@@ -27,7 +27,7 @@ vi.mock("./client.js", () => ({
}));
vi.mock("./drive.js", () => ({
deliverCommentThreadText: deliverCommentThreadTextMock,
replyComment: replyCommentMock,
}));
function buildConfig(overrides?: Partial<ClawdbotConfig>): ClawdbotConfig {
@@ -66,7 +66,6 @@ describe("handleFeishuCommentEvent", () => {
noticeType: "add_comment",
fileToken: "doc_token_1",
fileType: "docx",
isWholeComment: false,
senderId: "ou_sender",
senderUserId: "on_sender_user",
timestamp: "1774951528000",
@@ -77,10 +76,7 @@ describe("handleFeishuCommentEvent", () => {
rootCommentText: "root comment",
targetReplyText: "latest reply",
});
deliverCommentThreadTextMock.mockResolvedValue({
delivery_mode: "reply_comment",
reply_id: "r1",
});
replyCommentMock.mockResolvedValue({ reply_id: "r1" });
const runtime = createPluginRuntimeMock({
channel: {
@@ -200,7 +196,7 @@ describe("handleFeishuCommentEvent", () => {
typeof vi.fn
>;
expect(dispatchReplyFromConfig).toHaveBeenCalledTimes(1);
expect(deliverCommentThreadTextMock).not.toHaveBeenCalled();
expect(replyCommentMock).not.toHaveBeenCalled();
});
it("issues a pairing challenge in the comment thread when dmPolicy=pairing", async () => {
@@ -236,13 +232,12 @@ describe("handleFeishuCommentEvent", () => {
} as never,
});
expect(deliverCommentThreadTextMock).toHaveBeenCalledWith(
expect(replyCommentMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
file_token: "doc_token_1",
file_type: "docx",
comment_id: "comment_1",
is_whole_comment: false,
}),
);
const dispatchReplyFromConfig = runtime.channel.reply.dispatchReplyFromConfig as ReturnType<
@@ -250,46 +245,4 @@ describe("handleFeishuCommentEvent", () => {
>;
expect(dispatchReplyFromConfig).not.toHaveBeenCalled();
});
it("passes whole-comment metadata to the comment reply dispatcher", async () => {
resolveDriveCommentEventTurnMock.mockResolvedValueOnce({
eventId: "evt_whole",
messageId: "drive-comment:evt_whole",
commentId: "comment_whole",
replyId: "reply_whole",
noticeType: "add_reply",
fileToken: "doc_token_1",
fileType: "docx",
isWholeComment: true,
senderId: "ou_sender",
senderUserId: "on_sender_user",
timestamp: "1774951528000",
isMentioned: false,
documentTitle: "Project review",
prompt: "prompt body",
preview: "prompt body",
rootCommentText: "root comment",
targetReplyText: "reply text",
});
await handleFeishuCommentEvent({
cfg: buildConfig(),
accountId: "default",
event: { event_id: "evt_whole" },
botOpenId: "ou_bot",
runtime: {
log: vi.fn(),
error: vi.fn(),
} as never,
});
expect(createFeishuCommentReplyDispatcherMock).toHaveBeenCalledWith(
expect.objectContaining({
commentId: "comment_whole",
fileToken: "doc_token_1",
fileType: "docx",
isWholeComment: true,
}),
);
});
});

View File

@@ -8,7 +8,7 @@ import { resolveFeishuRuntimeAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { createFeishuCommentReplyDispatcher } from "./comment-dispatcher.js";
import { buildFeishuCommentTarget } from "./comment-target.js";
import { deliverCommentThreadText } from "./drive.js";
import { replyComment } from "./drive.js";
import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
import {
resolveDriveCommentEventTurn,
@@ -108,12 +108,11 @@ export async function handleFeishuCommentEvent(
);
},
sendPairingReply: async (text) => {
await deliverCommentThreadText(client, {
await replyComment(client, {
file_token: turn.fileToken,
file_type: turn.fileType,
comment_id: turn.commentId,
content: text,
is_whole_comment: turn.isWholeComment,
});
},
onReplyError: (err) => {
@@ -222,7 +221,6 @@ export async function handleFeishuCommentEvent(
fileToken: turn.fileToken,
fileType: turn.fileType,
commentId: turn.commentId,
isWholeComment: turn.isWholeComment,
});
log(

View File

@@ -52,26 +52,22 @@ export const FeishuDriveSchema = Type.Union([
Type.Object({
action: Type.Literal("list_comments"),
file_token: Type.String({ description: "Document token" }),
file_type: Type.Optional(CommentFileType),
page_size: Type.Optional(Type.Integer({ minimum: 1, maximum: 100, description: "Page size" })),
file_type: CommentFileType,
page_size: Type.Optional(Type.Integer({ minimum: 1, description: "Page size" })),
page_token: Type.Optional(Type.String({ description: "Comment page token" })),
}),
Type.Object({
action: Type.Literal("list_comment_replies"),
file_token: Type.String({ description: "Document token" }),
file_type: Type.Optional(CommentFileType),
file_type: CommentFileType,
comment_id: Type.String({ description: "Comment id" }),
page_size: Type.Optional(Type.Integer({ minimum: 1, maximum: 100, description: "Page size" })),
page_size: Type.Optional(Type.Integer({ minimum: 1, description: "Page size" })),
page_token: Type.Optional(Type.String({ description: "Reply page token" })),
}),
Type.Object({
action: Type.Literal("add_comment"),
file_token: Type.String({ description: "Document token" }),
file_type: Type.Optional(
Type.Union([Type.Literal("doc"), Type.Literal("docx")], {
description: "Document type. Defaults to docx when omitted.",
}),
),
file_type: Type.Union([Type.Literal("doc"), Type.Literal("docx")]),
content: Type.String({ description: "Comment text content" }),
block_id: Type.Optional(
Type.String({
@@ -83,7 +79,7 @@ export const FeishuDriveSchema = Type.Union([
Type.Object({
action: Type.Literal("reply_comment"),
file_token: Type.String({ description: "Document token" }),
file_type: Type.Optional(CommentFileType),
file_type: CommentFileType,
comment_id: Type.String({ description: "Comment id" }),
content: Type.String({ description: "Reply text content" }),
}),

View File

@@ -206,10 +206,8 @@ describe("registerFeishuDriveTools", () => {
requestMock
.mockResolvedValueOnce({
code: 0,
data: {
items: [{ comment_id: "c1", is_whole: false }],
},
code: 99991663,
msg: "invalid request body",
})
.mockResolvedValueOnce({
code: 0,
@@ -226,18 +224,7 @@ describe("registerFeishuDriveTools", () => {
4,
expect.objectContaining({
method: "POST",
url: "/open-apis/drive/v1/files/doc_1/comments/batch_query?file_type=docx&user_id_type=open_id",
data: {
comment_ids: ["c1"],
},
}),
);
expect(requestMock).toHaveBeenNthCalledWith(
5,
expect.objectContaining({
method: "POST",
url: "/open-apis/drive/v1/files/doc_1/comments/c1/replies",
params: { file_type: "docx" },
url: "/open-apis/drive/v1/files/doc_1/comments/c1/replies?file_type=docx",
data: {
content: {
elements: [
@@ -252,821 +239,18 @@ describe("registerFeishuDriveTools", () => {
},
}),
);
expect(replyCommentResult.details).toEqual(
expect.objectContaining({ success: true, reply_id: "r4" }),
);
});
it("defaults add_comment file_type to docx when omitted", async () => {
const registerTool = vi.fn();
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
registerFeishuDriveTools(
createDriveToolApi({
config: {
channels: {
feishu: {
enabled: true,
appId: "app_id",
appSecret: "app_secret", // pragma: allowlist secret
tools: { drive: true },
},
},
},
registerTool,
}),
);
const toolFactory = registerTool.mock.calls[0]?.[0];
const tool = toolFactory?.({ agentAccountId: undefined });
requestMock.mockResolvedValueOnce({
code: 0,
data: { comment_id: "c-default-docx" },
});
const result = await tool.execute("call-default-docx", {
action: "add_comment",
file_token: "doc_1",
content: "defaulted file type",
});
expect(requestMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "POST",
url: "/open-apis/drive/v1/files/doc_1/new_comments",
data: {
file_type: "docx",
reply_elements: [{ type: "text", text: "defaulted file type" }],
},
}),
);
expect(infoSpy).toHaveBeenCalledWith(
expect.stringContaining("add_comment missing file_type; defaulting to docx"),
);
expect(result.details).toEqual(
expect.objectContaining({ success: true, comment_id: "c-default-docx" }),
);
});
it("defaults list_comments file_type to docx when omitted", async () => {
const registerTool = vi.fn();
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
registerFeishuDriveTools(
createDriveToolApi({
config: {
channels: {
feishu: {
enabled: true,
appId: "app_id",
appSecret: "app_secret", // pragma: allowlist secret
tools: { drive: true },
},
},
},
registerTool,
}),
);
const toolFactory = registerTool.mock.calls[0]?.[0];
const tool = toolFactory?.({ agentAccountId: undefined });
requestMock.mockResolvedValueOnce({
code: 0,
data: { has_more: false, items: [] },
});
await tool.execute("call-list-default-docx", {
action: "list_comments",
file_token: "doc_1",
});
expect(requestMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "GET",
url: "/open-apis/drive/v1/files/doc_1/comments?file_type=docx&user_id_type=open_id",
}),
);
expect(infoSpy).toHaveBeenCalledWith(
expect.stringContaining("list_comments missing file_type; defaulting to docx"),
);
});
it("defaults list_comment_replies file_type to docx when omitted", async () => {
const registerTool = vi.fn();
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
registerFeishuDriveTools(
createDriveToolApi({
config: {
channels: {
feishu: {
enabled: true,
appId: "app_id",
appSecret: "app_secret", // pragma: allowlist secret
tools: { drive: true },
},
},
},
registerTool,
}),
);
const toolFactory = registerTool.mock.calls[0]?.[0];
const tool = toolFactory?.({ agentAccountId: undefined });
requestMock.mockResolvedValueOnce({
code: 0,
data: { has_more: false, items: [] },
});
await tool.execute("call-replies-default-docx", {
action: "list_comment_replies",
file_token: "doc_1",
comment_id: "c1",
});
expect(requestMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "GET",
url: "/open-apis/drive/v1/files/doc_1/comments/c1/replies?file_type=docx&user_id_type=open_id",
}),
);
expect(infoSpy).toHaveBeenCalledWith(
expect.stringContaining("list_comment_replies missing file_type; defaulting to docx"),
);
});
it("surfaces reply_comment HTTP errors when the single supported body fails", async () => {
const registerTool = vi.fn();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
registerFeishuDriveTools(
createDriveToolApi({
config: {
channels: {
feishu: {
enabled: true,
appId: "app_id",
appSecret: "app_secret", // pragma: allowlist secret
tools: { drive: true },
},
},
},
registerTool,
}),
);
const toolFactory = registerTool.mock.calls[0]?.[0];
const tool = toolFactory?.({ agentAccountId: undefined });
requestMock
.mockResolvedValueOnce({
code: 0,
data: {
items: [{ comment_id: "c1", is_whole: false }],
},
})
.mockRejectedValueOnce({
message: "Request failed with status code 400",
code: "ERR_BAD_REQUEST",
config: {
method: "post",
url: "https://open.feishu.cn/open-apis/drive/v1/files/doc_1/comments/c1/replies",
params: { file_type: "docx" },
},
response: {
status: 400,
data: {
code: 99992402,
msg: "field validation failed",
log_id: "log_legacy_400",
},
},
});
const replyCommentResult = await tool.execute("call-throw", {
action: "reply_comment",
file_token: "doc_1",
file_type: "docx",
comment_id: "c1",
content: "inserted successfully",
});
expect(requestMock).toHaveBeenNthCalledWith(
1,
5,
expect.objectContaining({
method: "POST",
url: "/open-apis/drive/v1/files/doc_1/comments/batch_query?file_type=docx&user_id_type=open_id",
url: "/open-apis/drive/v1/files/doc_1/comments/c1/replies?file_type=docx",
data: {
comment_ids: ["c1"],
},
}),
);
expect(requestMock).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
method: "POST",
url: "/open-apis/drive/v1/files/doc_1/comments/c1/replies",
params: { file_type: "docx" },
data: {
content: {
elements: [
{
type: "text_run",
text_run: {
text: "inserted successfully",
},
},
],
},
},
}),
);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("replyComment threw"));
expect(replyCommentResult.details).toEqual(
expect.objectContaining({ error: "Request failed with status code 400" }),
);
});
it("defaults reply_comment target fields from the ambient Feishu comment delivery context", async () => {
const registerTool = vi.fn();
registerFeishuDriveTools(
createDriveToolApi({
config: {
channels: {
feishu: {
enabled: true,
appId: "app_id",
appSecret: "app_secret", // pragma: allowlist secret
tools: { drive: true },
},
},
},
registerTool,
}),
);
const toolFactory = registerTool.mock.calls[0]?.[0];
const tool = toolFactory?.({
agentAccountId: undefined,
deliveryContext: {
channel: "feishu",
to: "comment:docx:doc_1:c1",
},
});
requestMock
.mockResolvedValueOnce({
code: 0,
data: {
items: [{ comment_id: "c1", is_whole: false }],
},
})
.mockResolvedValueOnce({
code: 0,
data: { reply_id: "r6" },
});
const replyCommentResult = await tool.execute("call-ambient", {
action: "reply_comment",
content: "ambient success",
});
expect(requestMock).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
method: "POST",
url: "/open-apis/drive/v1/files/doc_1/comments/batch_query?file_type=docx&user_id_type=open_id",
data: {
comment_ids: ["c1"],
},
}),
);
expect(requestMock).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
method: "POST",
url: "/open-apis/drive/v1/files/doc_1/comments/c1/replies",
params: { file_type: "docx" },
data: {
content: {
elements: [
{
type: "text_run",
text_run: {
text: "ambient success",
},
},
],
},
reply_elements: [{ type: "text", text: "handled" }],
},
}),
);
expect(replyCommentResult.details).toEqual(
expect.objectContaining({ success: true, reply_id: "r6" }),
);
});
it("does not inherit non-doc ambient file types for add_comment", async () => {
const registerTool = vi.fn();
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
registerFeishuDriveTools(
createDriveToolApi({
config: {
channels: {
feishu: {
enabled: true,
appId: "app_id",
appSecret: "app_secret", // pragma: allowlist secret
tools: { drive: true },
},
},
},
registerTool,
}),
);
const toolFactory = registerTool.mock.calls[0]?.[0];
const tool = toolFactory?.({
agentAccountId: undefined,
deliveryContext: {
channel: "feishu",
to: "comment:sheet:sheet_1:c1",
},
});
requestMock.mockResolvedValueOnce({
code: 0,
data: { comment_id: "c-add-docx" },
});
const result = await tool.execute("call-add-ignore-sheet-ambient", {
action: "add_comment",
file_token: "doc_1",
content: "default add comment",
});
expect(requestMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "POST",
url: "/open-apis/drive/v1/files/doc_1/new_comments",
data: {
file_type: "docx",
reply_elements: [{ type: "text", text: "default add comment" }],
},
}),
);
expect(infoSpy).toHaveBeenCalledWith(
expect.stringContaining("add_comment missing file_type; defaulting to docx"),
);
expect(result.details).toEqual(
expect.objectContaining({ success: true, comment_id: "c-add-docx" }),
);
});
it("defaults reply_comment file_type to docx when omitted", async () => {
const registerTool = vi.fn();
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
registerFeishuDriveTools(
createDriveToolApi({
config: {
channels: {
feishu: {
enabled: true,
appId: "app_id",
appSecret: "app_secret", // pragma: allowlist secret
tools: { drive: true },
},
},
},
registerTool,
}),
);
const toolFactory = registerTool.mock.calls[0]?.[0];
const tool = toolFactory?.({ agentAccountId: undefined });
requestMock
.mockResolvedValueOnce({
code: 0,
data: {
items: [{ comment_id: "c1", is_whole: false }],
},
})
.mockResolvedValueOnce({
code: 0,
data: { reply_id: "r-default-docx" },
});
const result = await tool.execute("call-reply-default-docx", {
action: "reply_comment",
file_token: "doc_1",
comment_id: "c1",
content: "default reply docx",
});
expect(requestMock).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
method: "POST",
url: "/open-apis/drive/v1/files/doc_1/comments/batch_query?file_type=docx&user_id_type=open_id",
data: { comment_ids: ["c1"] },
}),
);
expect(requestMock).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
method: "POST",
url: "/open-apis/drive/v1/files/doc_1/comments/c1/replies",
params: { file_type: "docx" },
data: {
content: {
elements: [
{
type: "text_run",
text_run: {
text: "default reply docx",
},
},
],
},
},
}),
);
expect(infoSpy).toHaveBeenCalledWith(
expect.stringContaining("reply_comment missing file_type; defaulting to docx"),
);
expect(result.details).toEqual(
expect.objectContaining({ success: true, reply_id: "r-default-docx" }),
);
});
it("routes whole-document reply_comment requests through add_comment compatibility", async () => {
const registerTool = vi.fn();
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
registerFeishuDriveTools(
createDriveToolApi({
config: {
channels: {
feishu: {
enabled: true,
appId: "app_id",
appSecret: "app_secret", // pragma: allowlist secret
tools: { drive: true },
},
},
},
registerTool,
}),
);
const toolFactory = registerTool.mock.calls[0]?.[0];
const tool = toolFactory?.({ agentAccountId: undefined });
requestMock
.mockResolvedValueOnce({
code: 0,
data: {
items: [{ comment_id: "c1", is_whole: true }],
},
})
.mockResolvedValueOnce({
code: 0,
data: { comment_id: "c2" },
});
const result = await tool.execute("call-whole", {
action: "reply_comment",
file_token: "doc_1",
file_type: "docx",
comment_id: "c1",
content: "whole comment follow-up",
});
expect(requestMock).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
method: "POST",
url: "/open-apis/drive/v1/files/doc_1/comments/batch_query?file_type=docx&user_id_type=open_id",
data: {
comment_ids: ["c1"],
},
}),
);
expect(requestMock).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
method: "POST",
url: "/open-apis/drive/v1/files/doc_1/new_comments",
data: {
file_type: "docx",
reply_elements: [{ type: "text", text: "whole comment follow-up" }],
},
}),
);
expect(infoSpy).toHaveBeenCalledWith(
expect.stringContaining("whole-comment compatibility path"),
);
expect(result.details).toEqual(
expect.objectContaining({
success: true,
comment_id: "c2",
delivery_mode: "add_comment",
}),
);
});
it("continues with reply_comment when comment metadata preflight fails", async () => {
const registerTool = vi.fn();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
registerFeishuDriveTools(
createDriveToolApi({
config: {
channels: {
feishu: {
enabled: true,
appId: "app_id",
appSecret: "app_secret", // pragma: allowlist secret
tools: { drive: true },
},
},
},
registerTool,
}),
);
const toolFactory = registerTool.mock.calls[0]?.[0];
const tool = toolFactory?.({ agentAccountId: undefined });
requestMock.mockRejectedValueOnce(new Error("preflight unavailable")).mockResolvedValueOnce({
code: 0,
data: { reply_id: "r-preflight-fallback" },
});
const result = await tool.execute("call-preflight-fallback", {
action: "reply_comment",
file_token: "doc_1",
file_type: "docx",
comment_id: "c1",
content: "preflight fallback reply",
});
expect(requestMock).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
method: "POST",
url: "/open-apis/drive/v1/files/doc_1/comments/batch_query?file_type=docx&user_id_type=open_id",
data: {
comment_ids: ["c1"],
},
}),
);
expect(requestMock).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
method: "POST",
url: "/open-apis/drive/v1/files/doc_1/comments/c1/replies",
params: { file_type: "docx" },
data: {
content: {
elements: [
{
type: "text_run",
text_run: {
text: "preflight fallback reply",
},
},
],
},
},
}),
);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("comment metadata preflight failed"),
);
expect(result.details).toEqual(
expect.objectContaining({
success: true,
reply_id: "r-preflight-fallback",
delivery_mode: "reply_comment",
}),
);
});
it("continues with reply_comment when batch_query returns no exact comment match", async () => {
const registerTool = vi.fn();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
registerFeishuDriveTools(
createDriveToolApi({
config: {
channels: {
feishu: {
enabled: true,
appId: "app_id",
appSecret: "app_secret", // pragma: allowlist secret
tools: { drive: true },
},
},
},
registerTool,
}),
);
const toolFactory = registerTool.mock.calls[0]?.[0];
const tool = toolFactory?.({ agentAccountId: undefined });
requestMock
.mockResolvedValueOnce({
code: 0,
data: {
items: [{ comment_id: "different_comment", is_whole: true }],
},
})
.mockResolvedValueOnce({
code: 0,
data: { reply_id: "r-no-exact-match" },
});
const result = await tool.execute("call-preflight-no-exact-match", {
action: "reply_comment",
file_token: "doc_1",
file_type: "docx",
comment_id: "c1",
content: "fallback on exact match miss",
});
expect(requestMock).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
method: "POST",
url: "/open-apis/drive/v1/files/doc_1/comments/batch_query?file_type=docx&user_id_type=open_id",
data: {
comment_ids: ["c1"],
},
}),
);
expect(requestMock).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
method: "POST",
url: "/open-apis/drive/v1/files/doc_1/comments/c1/replies",
params: { file_type: "docx" },
data: {
content: {
elements: [
{
type: "text_run",
text_run: {
text: "fallback on exact match miss",
},
},
],
},
},
}),
);
expect(warnSpy).not.toHaveBeenCalledWith(
expect.stringContaining("whole-comment compatibility path"),
);
expect(result.details).toEqual(
expect.objectContaining({
success: true,
reply_id: "r-no-exact-match",
delivery_mode: "reply_comment",
}),
);
});
it("falls back to add_comment when reply_comment returns compatibility code 1069302 even without is_whole metadata", async () => {
const registerTool = vi.fn();
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
registerFeishuDriveTools(
createDriveToolApi({
config: {
channels: {
feishu: {
enabled: true,
appId: "app_id",
appSecret: "app_secret", // pragma: allowlist secret
tools: { drive: true },
},
},
},
registerTool,
}),
);
const toolFactory = registerTool.mock.calls[0]?.[0];
const tool = toolFactory?.({ agentAccountId: undefined });
requestMock
.mockResolvedValueOnce({
code: 0,
data: {
items: [{ comment_id: "c1", is_whole: false }],
},
})
.mockRejectedValueOnce({
message: "Request failed with status code 400",
code: "ERR_BAD_REQUEST",
config: {
method: "post",
url: "https://open.feishu.cn/open-apis/drive/v1/files/doc_1/comments/c1/replies",
params: { file_type: "docx" },
},
response: {
status: 400,
data: {
code: 1069302,
msg: "param error",
log_id: "log_reply_forbidden",
},
},
})
.mockResolvedValueOnce({
code: 0,
data: { comment_id: "c3" },
});
const result = await tool.execute("call-reply-forbidden", {
action: "reply_comment",
file_token: "doc_1",
file_type: "docx",
comment_id: "c1",
content: "compat follow-up",
});
expect(requestMock).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
method: "POST",
url: "/open-apis/drive/v1/files/doc_1/new_comments",
data: {
file_type: "docx",
reply_elements: [{ type: "text", text: "compat follow-up" }],
},
}),
);
expect(infoSpy).toHaveBeenCalledWith(
expect.stringContaining("reply-not-allowed compatibility path"),
);
expect(result.details).toEqual(
expect.objectContaining({
success: true,
comment_id: "c3",
delivery_mode: "add_comment",
}),
);
});
it("clamps comment list page sizes to the Feishu API maximum", async () => {
const registerTool = vi.fn();
registerFeishuDriveTools(
createDriveToolApi({
config: {
channels: {
feishu: {
enabled: true,
appId: "app_id",
appSecret: "app_secret", // pragma: allowlist secret
tools: { drive: true },
},
},
},
registerTool,
}),
);
const toolFactory = registerTool.mock.calls[0]?.[0];
const tool = toolFactory?.({ agentAccountId: undefined });
requestMock.mockResolvedValueOnce({ code: 0, data: { has_more: false, items: [] } });
await tool.execute("call-list", {
action: "list_comments",
file_token: "doc_1",
file_type: "docx",
page_size: 200,
});
expect(requestMock).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
method: "GET",
url: "/open-apis/drive/v1/files/doc_1/comments?file_type=docx&page_size=100&user_id_type=open_id",
}),
);
requestMock.mockResolvedValueOnce({ code: 0, data: { has_more: false, items: [] } });
await tool.execute("call-replies", {
action: "list_comment_replies",
file_token: "doc_1",
file_type: "docx",
comment_id: "c1",
page_size: 200,
});
expect(requestMock).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
method: "GET",
url: "/open-apis/drive/v1/files/doc_1/comments/c1/replies?file_type=docx&page_size=100&user_id_type=open_id",
}),
expect.objectContaining({ success: true, reply_id: "r4" }),
);
});

View File

@@ -1,7 +1,7 @@
import type * as Lark from "@larksuiteoapi/node-sdk";
import type { OpenClawPluginApi } from "../runtime-api.js";
import { listEnabledFeishuAccounts } from "./accounts.js";
import { parseFeishuCommentTarget, type CommentFileType } from "./comment-target.js";
import { type CommentFileType } from "./comment-target.js";
import { FeishuDriveSchema, type FeishuDriveParams } from "./drive-schema.js";
import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
import {
@@ -26,7 +26,6 @@ type FeishuDriveInternalClient = Lark.Client & {
request(params: {
method: "GET" | "POST";
url: string;
params?: Record<string, string | undefined>;
data: unknown;
timeout?: number;
}): Promise<unknown>;
@@ -34,33 +33,10 @@ type FeishuDriveInternalClient = Lark.Client & {
type FeishuDriveApiResponse<T> = {
code: number;
log_id?: string;
msg?: string;
data?: T;
};
class FeishuReplyCommentError extends Error {
httpStatus?: number;
feishuCode?: number | string;
feishuMsg?: string;
feishuLogId?: string;
constructor(params: {
message: string;
httpStatus?: number;
feishuCode?: number | string;
feishuMsg?: string;
feishuLogId?: string;
}) {
super(params.message);
this.name = "FeishuReplyCommentError";
this.httpStatus = params.httpStatus;
this.feishuCode = params.feishuCode;
this.feishuMsg = params.feishuMsg;
this.feishuLogId = params.feishuLogId;
}
}
type FeishuDriveCommentReply = {
reply_id?: string;
user_id?: string;
@@ -98,13 +74,6 @@ type FeishuDriveListRepliesResponse = FeishuDriveApiResponse<{
page_token?: string;
}>;
type FeishuDriveToolContext = {
deliveryContext?: {
channel?: string;
to?: string;
};
};
const FEISHU_DRIVE_REQUEST_TIMEOUT_MS = 30_000;
function getDriveInternalClient(client: Lark.Client): FeishuDriveInternalClient {
@@ -190,14 +159,12 @@ async function requestDriveApi<T>(params: {
client: Lark.Client;
method: "GET" | "POST";
url: string;
query?: Record<string, string | undefined>;
data?: unknown;
}): Promise<T> {
const internalClient = getDriveInternalClient(params.client);
return (await internalClient.request({
method: params.method,
url: params.url,
params: params.query ?? {},
data: params.data ?? {},
timeout: FEISHU_DRIVE_REQUEST_TIMEOUT_MS,
})) as T;
@@ -238,149 +205,6 @@ function normalizeCommentCard(comment: FeishuDriveCommentCard) {
};
}
function normalizeCommentPageSize(pageSize: number | undefined): string | undefined {
if (typeof pageSize !== "number" || !Number.isFinite(pageSize)) {
return undefined;
}
return String(Math.min(Math.max(Math.floor(pageSize), 1), 100));
}
function resolveAmbientCommentTarget(context: FeishuDriveToolContext | undefined) {
const deliveryContext = context?.deliveryContext;
if (deliveryContext?.channel && deliveryContext.channel !== "feishu") {
return null;
}
return parseFeishuCommentTarget(deliveryContext?.to);
}
function applyAmbientCommentDefaults<
T extends {
file_token?: string;
file_type?: CommentFileType;
comment_id?: string;
},
>(params: T, context: FeishuDriveToolContext | undefined): T {
const ambient = resolveAmbientCommentTarget(context);
if (!ambient) {
return params;
}
return {
...params,
file_token: params.file_token?.trim() || ambient.fileToken,
file_type: params.file_type ?? ambient.fileType,
comment_id: params.comment_id?.trim() || ambient.commentId,
};
}
function applyAddCommentAmbientDefaults<
T extends {
file_token?: string;
file_type?: "doc" | "docx";
},
>(params: T, context: FeishuDriveToolContext | undefined): T {
const ambient = resolveAmbientCommentTarget(context);
if (!ambient || (ambient.fileType !== "doc" && ambient.fileType !== "docx")) {
return params;
}
return {
...params,
file_token: params.file_token?.trim() || ambient.fileToken,
file_type: params.file_type ?? ambient.fileType,
};
}
function applyAddCommentDefaults<
T extends {
file_token?: string;
file_type?: "doc" | "docx";
},
>(params: T): T & { file_type: "doc" | "docx" } {
const fileType = params.file_type ?? "docx";
if (!params.file_type) {
console.info(
`[feishu_drive] add_comment missing file_type; defaulting to docx ` +
`file_token=${params.file_token ?? "unknown"}`,
);
}
return {
...params,
file_type: fileType,
};
}
function applyCommentFileTypeDefault<
T extends {
file_token?: string;
file_type?: CommentFileType;
},
>(
params: T,
action: "list_comments" | "list_comment_replies" | "reply_comment",
): T & {
file_type: CommentFileType;
} {
const fileType = params.file_type ?? "docx";
if (!params.file_type) {
console.info(
`[feishu_drive] ${action} missing file_type; defaulting to docx ` +
`file_token=${params.file_token ?? "unknown"}`,
);
}
return {
...params,
file_type: fileType,
};
}
function formatDriveApiError(error: unknown): string {
if (!isRecord(error)) {
return String(error);
}
const response = isRecord(error.response) ? error.response : undefined;
const responseData = isRecord(response?.data) ? response?.data : undefined;
return JSON.stringify({
message: typeof error.message === "string" ? error.message : String(error),
code: readString(error.code),
method: readString(isRecord(error.config) ? error.config.method : undefined),
url: readString(isRecord(error.config) ? error.config.url : undefined),
params: isRecord(error.config) ? error.config.params : undefined,
http_status: typeof response?.status === "number" ? response.status : undefined,
feishu_code:
typeof responseData?.code === "number" ? responseData.code : readString(responseData?.code),
feishu_msg: readString(responseData?.msg),
feishu_log_id: readString(responseData?.log_id),
});
}
function extractDriveApiErrorMeta(error: unknown): {
message: string;
httpStatus?: number;
feishuCode?: number | string;
feishuMsg?: string;
feishuLogId?: string;
} {
if (!isRecord(error)) {
return { message: String(error) };
}
const response = isRecord(error.response) ? error.response : undefined;
const responseData = isRecord(response?.data) ? response?.data : undefined;
return {
message: typeof error.message === "string" ? error.message : String(error),
httpStatus: typeof response?.status === "number" ? response.status : undefined,
feishuCode:
typeof responseData?.code === "number" ? responseData.code : readString(responseData?.code),
feishuMsg: readString(responseData?.msg),
feishuLogId: readString(responseData?.log_id),
};
}
function isReplyNotAllowedError(error: unknown): boolean {
if (!(error instanceof FeishuReplyCommentError)) {
return false;
}
return error.feishuCode === 1069302;
}
async function getRootFolderToken(client: Lark.Client): Promise<string> {
// Use generic HTTP client to call the root folder meta API
// as it's not directly exposed in the SDK
@@ -547,7 +371,10 @@ async function listComments(
`/open-apis/drive/v1/files/${encodeURIComponent(params.file_token)}/comments` +
encodeQuery({
file_type: params.file_type,
page_size: normalizeCommentPageSize(params.page_size),
page_size:
typeof params.page_size === "number" && Number.isFinite(params.page_size)
? String(params.page_size)
: undefined,
page_token: params.page_token,
user_id_type: "open_id",
}),
@@ -580,7 +407,10 @@ async function listCommentReplies(
)}/replies` +
encodeQuery({
file_type: params.file_type,
page_size: normalizeCommentPageSize(params.page_size),
page_size:
typeof params.page_size === "number" && Number.isFinite(params.page_size)
? String(params.page_size)
: undefined,
page_token: params.page_token,
user_id_type: "open_id",
}),
@@ -601,7 +431,7 @@ async function addComment(
content: string;
block_id?: string;
},
): Promise<{ success: true } & Record<string, unknown>> {
) {
if (params.block_id?.trim() && params.file_type !== "docx") {
throw new Error("block_id is only supported for docx comments");
}
@@ -623,34 +453,6 @@ async function addComment(
};
}
// Fetch comment metadata via batch_query because the single-comment endpoint
// does not support partial comments.
async function queryCommentById(
client: Lark.Client,
params: {
file_token: string;
file_type: CommentFileType;
comment_id: string;
},
) {
const response = assertDriveApiSuccess(
await requestDriveApi<FeishuDriveListCommentsResponse>({
client,
method: "POST",
url:
`/open-apis/drive/v1/files/${encodeURIComponent(params.file_token)}/comments/batch_query` +
encodeQuery({
file_type: params.file_type,
user_id_type: "open_id",
}),
data: {
comment_ids: [params.comment_id],
},
}),
);
return response.data?.items?.find((comment) => comment.comment_id?.trim() === params.comment_id);
}
export async function replyComment(
client: Lark.Client,
params: {
@@ -660,28 +462,34 @@ export async function replyComment(
content: string;
},
): Promise<{ success: true; reply_id?: string } & Record<string, unknown>> {
const url = `/open-apis/drive/v1/files/${encodeURIComponent(params.file_token)}/comments/${encodeURIComponent(
params.comment_id,
)}/replies`;
const query = { file_type: params.file_type };
try {
const url =
`/open-apis/drive/v1/files/${encodeURIComponent(params.file_token)}/comments/${encodeURIComponent(
params.comment_id,
)}/replies` + encodeQuery({ file_type: params.file_type });
const attempts: unknown[] = [
{
content: {
elements: [
{
type: "text_run",
text_run: {
text: params.content,
},
},
],
},
},
{
reply_elements: buildReplyElements(params.content),
},
];
let lastMessage = "Feishu Drive reply comment failed";
for (const data of attempts) {
const response = (await requestDriveApi<FeishuDriveApiResponse<Record<string, unknown>>>({
client,
method: "POST",
url,
query,
data: {
content: {
elements: [
{
type: "text_run",
text_run: {
text: params.content,
},
},
],
},
},
data,
})) as FeishuDriveApiResponse<Record<string, unknown>>;
if (response.code === 0) {
return {
@@ -689,116 +497,9 @@ export async function replyComment(
...response.data,
};
}
console.warn(
`[feishu_drive] replyComment failed ` +
`comment=${params.comment_id} file_type=${params.file_type} ` +
`code=${response.code ?? "unknown"} ` +
`msg=${response.msg ?? "unknown"} log_id=${response.log_id ?? "unknown"}`,
);
throw new FeishuReplyCommentError({
message: response.msg ?? "Feishu Drive reply comment failed",
feishuCode: response.code,
feishuMsg: response.msg,
feishuLogId: response.log_id,
});
} catch (error) {
if (error instanceof FeishuReplyCommentError) {
throw error;
}
const meta = extractDriveApiErrorMeta(error);
console.warn(
`[feishu_drive] replyComment threw ` +
`comment=${params.comment_id} file_type=${params.file_type} ` +
`error=${formatDriveApiError(error)}`,
);
throw new FeishuReplyCommentError({
message: meta.message,
httpStatus: meta.httpStatus,
feishuCode: meta.feishuCode,
feishuMsg: meta.feishuMsg,
feishuLogId: meta.feishuLogId,
});
}
}
export async function deliverCommentThreadText(
client: Lark.Client,
params: {
file_token: string;
file_type: CommentFileType;
comment_id: string;
content: string;
is_whole_comment?: boolean;
},
): Promise<
| ({ success: true; reply_id?: string } & Record<string, unknown> & {
delivery_mode: "reply_comment";
})
| ({ success: true; comment_id?: string } & Record<string, unknown> & {
delivery_mode: "add_comment";
})
> {
let isWholeComment = params.is_whole_comment;
if (isWholeComment === undefined) {
try {
const comment = await queryCommentById(client, params);
isWholeComment = comment?.is_whole === true;
} catch (error) {
console.warn(
`[feishu_drive] comment metadata preflight failed ` +
`comment=${params.comment_id} file_type=${params.file_type} ` +
`error=${error instanceof Error ? error.message : String(error)}`,
);
isWholeComment = false;
}
}
if (isWholeComment) {
if (params.file_type !== "doc" && params.file_type !== "docx") {
throw new Error(
`Whole-document comment follow-ups are only supported for doc/docx (got ${params.file_type})`,
);
}
const wholeCommentFileType: "doc" | "docx" = params.file_type;
console.info(
`[feishu_drive] whole-comment compatibility path ` +
`comment=${params.comment_id} file_type=${params.file_type} mode=add_comment`,
);
return {
delivery_mode: "add_comment",
...(await addComment(client, {
file_token: params.file_token,
file_type: wholeCommentFileType,
content: params.content,
})),
};
}
try {
return {
delivery_mode: "reply_comment",
...(await replyComment(client, params)),
};
} catch (error) {
if (error instanceof FeishuReplyCommentError && isReplyNotAllowedError(error)) {
if (params.file_type !== "doc" && params.file_type !== "docx") {
throw error;
}
const fallbackFileType: "doc" | "docx" = params.file_type;
console.info(
`[feishu_drive] reply-not-allowed compatibility path ` +
`comment=${params.comment_id} file_type=${params.file_type} mode=add_comment ` +
`log_id=${error.feishuLogId ?? "unknown"}`,
);
return {
delivery_mode: "add_comment",
...(await addComment(client, {
file_token: params.file_token,
file_type: fallbackFileType,
content: params.content,
})),
};
}
throw error;
lastMessage = response.msg ?? lastMessage;
}
throw new Error(lastMessage);
}
// ============ Tool Registration ============
@@ -851,31 +552,14 @@ export function registerFeishuDriveTools(api: OpenClawPluginApi) {
return jsonToolResult(await moveFile(client, p.file_token, p.type, p.folder_token));
case "delete":
return jsonToolResult(await deleteFile(client, p.file_token, p.type));
case "list_comments": {
const resolved = applyCommentFileTypeDefault(
applyAmbientCommentDefaults(p, ctx),
"list_comments",
);
return jsonToolResult(await listComments(client, resolved));
}
case "list_comment_replies": {
const resolved = applyCommentFileTypeDefault(
applyAmbientCommentDefaults(p, ctx),
"list_comment_replies",
);
return jsonToolResult(await listCommentReplies(client, resolved));
}
case "add_comment": {
const resolved = applyAddCommentDefaults(applyAddCommentAmbientDefaults(p, ctx));
return jsonToolResult(await addComment(client, resolved));
}
case "reply_comment": {
const resolved = applyCommentFileTypeDefault(
applyAmbientCommentDefaults(p, ctx),
"reply_comment",
);
return jsonToolResult(await deliverCommentThreadText(client, resolved));
}
case "list_comments":
return jsonToolResult(await listComments(client, p));
case "list_comment_replies":
return jsonToolResult(await listCommentReplies(client, p));
case "add_comment":
return jsonToolResult(await addComment(client, p));
case "reply_comment":
return jsonToolResult(await replyComment(client, p));
default:
return unknownToolActionResult((p as { action?: unknown }).action);
}

View File

@@ -97,15 +97,11 @@ function makeDriveCommentEvent(
function makeOpenApiClient(params: {
documentTitle?: string;
documentUrl?: string;
isWholeComment?: boolean;
batchCommentId?: string;
quoteText?: string;
rootReplyText?: string;
targetReplyText?: string;
includeTargetReplyInBatch?: boolean;
repliesSequence?: Array<Array<{ reply_id: string; text: string }>>;
}) {
const remainingReplyBatches = [...(params.repliesSequence ?? [])];
return {
request: vi.fn(async (request: { method: "GET" | "POST"; url: string; data: unknown }) => {
if (request.url === "/open-apis/drive/v1/metas/batch_query") {
@@ -128,8 +124,7 @@ function makeOpenApiClient(params: {
data: {
items: [
{
comment_id: params.batchCommentId ?? "7623358762119646411",
is_whole: params.isWholeComment,
comment_id: "7623358762119646411",
quote: params.quoteText ?? "im.message.receive_v1 message trigger implementation",
reply_list: {
replies: [
@@ -174,54 +169,40 @@ function makeOpenApiClient(params: {
};
}
if (request.url.includes("/replies")) {
const replyBatch = remainingReplyBatches.shift();
const items = replyBatch?.map((reply) => ({
reply_id: reply.reply_id,
content: {
elements: [
{
type: "text_run",
text_run: {
content: reply.text,
},
},
],
},
})) ?? [
{
reply_id: "7623358762136374451",
content: {
elements: [
{
type: "text_run",
text_run: {
content:
params.rootReplyText ??
"Also send it to the agent after receiving the comment event",
},
},
],
},
},
{
reply_id: "7623359125036043462",
content: {
elements: [
{
type: "text_run",
text_run: {
content: params.targetReplyText ?? "Please follow up on this comment",
},
},
],
},
},
];
return {
code: 0,
data: {
has_more: false,
items,
items: [
{
reply_id: "7623358762136374451",
content: {
elements: [
{
type: "text_run",
text_run: {
content:
params.rootReplyText ??
"Also send it to the agent after receiving the comment event",
},
},
],
},
},
{
reply_id: "7623359125036043462",
content: {
elements: [
{
type: "text_run",
text_run: {
content: params.targetReplyText ?? "Please follow up on this comment",
},
},
],
},
},
],
},
};
}
@@ -276,53 +257,11 @@ describe("resolveDriveCommentEventTurn", () => {
expect(turn?.prompt).toContain(
"This is a Feishu document comment-thread event, not a Feishu IM conversation.",
);
expect(turn?.prompt).toContain("Prefer plain text suitable for a comment thread.");
expect(turn?.prompt).toContain("Do not include internal reasoning");
expect(turn?.prompt).toContain("Do not narrate your plan or execution process");
expect(turn?.prompt).toContain("reply only with the user-facing result itself");
expect(turn?.prompt).toContain("comment_id: 7623358762119646411");
expect(turn?.prompt).toContain("reply_id: 7623358762136374451");
expect(turn?.prompt).toContain("The system will automatically reply with your final answer");
});
it("preserves whole-document comment metadata for downstream delivery mode selection", async () => {
const client = makeOpenApiClient({
includeTargetReplyInBatch: true,
isWholeComment: true,
});
const turn = await resolveDriveCommentEventTurn({
cfg: buildMonitorConfig(),
accountId: "default",
event: makeDriveCommentEvent(),
botOpenId: "ou_bot",
createClient: () => client as never,
});
expect(turn?.isWholeComment).toBe(true);
expect(turn?.prompt).toContain("This is a whole-document comment.");
expect(turn?.prompt).toContain("Whole-document comments do not support direct replies.");
});
it("does not trust whole-comment metadata from a mismatched batch_query item", async () => {
const client = makeOpenApiClient({
includeTargetReplyInBatch: true,
isWholeComment: true,
batchCommentId: "different_comment_id",
});
const turn = await resolveDriveCommentEventTurn({
cfg: buildMonitorConfig(),
accountId: "default",
event: makeDriveCommentEvent(),
botOpenId: "ou_bot",
createClient: () => client as never,
});
expect(turn?.isWholeComment).toBeUndefined();
expect(turn?.prompt).not.toContain("This is a whole-document comment.");
});
it("preserves sender user_id for downstream allowlist checks", async () => {
const client = makeOpenApiClient({ includeTargetReplyInBatch: true });
@@ -374,71 +313,6 @@ describe("resolveDriveCommentEventTurn", () => {
);
expect(turn?.prompt).toContain(`file_token: ${TEST_DOC_TOKEN}`);
expect(turn?.prompt).toContain("Event type: add_reply");
expect(client.request).toHaveBeenCalledWith(
expect.objectContaining({
method: "GET",
url: expect.stringContaining(
`/comments/7623358762119646411/replies?file_type=docx&page_size=100&user_id_type=open_id`,
),
}),
);
});
it("retries comment reply lookup when the requested reply is not immediately visible", async () => {
const waitMs = vi.fn(async () => {});
const client = makeOpenApiClient({
includeTargetReplyInBatch: false,
repliesSequence: [
[
{
reply_id: "7623358762136374451",
text: "Also send it to the agent after receiving the comment event",
},
{ reply_id: "7623358762999999999", text: "Earlier assistant summary" },
],
[
{
reply_id: "7623358762136374451",
text: "Also send it to the agent after receiving the comment event",
},
{ reply_id: "7623358762999999999", text: "Earlier assistant summary" },
],
[
{
reply_id: "7623358762136374451",
text: "Also send it to the agent after receiving the comment event",
},
{ reply_id: "7623359125999999999", text: "Insert a sentence below this paragraph" },
],
],
});
const turn = await resolveDriveCommentEventTurn({
cfg: buildMonitorConfig(),
accountId: "default",
event: makeDriveCommentEvent({
notice_meta: {
...makeDriveCommentEvent().notice_meta,
notice_type: "add_reply",
},
reply_id: "7623359125999999999",
}),
botOpenId: "ou_bot",
createClient: () => client as never,
waitMs,
});
expect(turn?.targetReplyText).toBe("Insert a sentence below this paragraph");
expect(turn?.prompt).toContain("Insert a sentence below this paragraph");
expect(waitMs).toHaveBeenCalledTimes(2);
expect(waitMs).toHaveBeenNthCalledWith(1, 1000);
expect(waitMs).toHaveBeenNthCalledWith(2, 1000);
expect(
client.request.mock.calls.filter(
([request]: [{ method: string; url: string }]) =>
request.method === "GET" && request.url.includes("/replies"),
),
).toHaveLength(3);
});
it("ignores self-authored comment notices", async () => {

View File

@@ -6,10 +6,8 @@ import { normalizeCommentFileType, type CommentFileType } from "./comment-target
import type { ResolvedFeishuAccount } from "./types.js";
const FEISHU_COMMENT_VERIFY_TIMEOUT_MS = 3_000;
const FEISHU_COMMENT_REPLY_PAGE_SIZE = 100;
const FEISHU_COMMENT_REPLY_PAGE_SIZE = 200;
const FEISHU_COMMENT_REPLY_PAGE_LIMIT = 5;
const FEISHU_COMMENT_REPLY_MISS_RETRY_DELAY_MS = 1_000;
const FEISHU_COMMENT_REPLY_MISS_RETRY_LIMIT = 6;
type FeishuDriveCommentUserId = {
open_id?: string;
@@ -41,7 +39,6 @@ type ResolveDriveCommentEventParams = {
createClient?: (account: ResolvedFeishuAccount) => FeishuRequestClient;
verificationTimeoutMs?: number;
logger?: (message: string) => void;
waitMs?: (ms: number) => Promise<void>;
};
export type ResolvedDriveCommentEventTurn = {
@@ -52,7 +49,6 @@ export type ResolvedDriveCommentEventTurn = {
noticeType: "add_comment" | "add_reply";
fileToken: string;
fileType: CommentFileType;
isWholeComment?: boolean;
senderId: string;
senderUserId?: string;
timestamp?: string;
@@ -77,7 +73,6 @@ type FeishuRequestClient = ReturnType<typeof createFeishuClient> & {
type FeishuOpenApiResponse<T> = {
code?: number;
log_id?: string;
msg?: string;
data?: T;
};
@@ -99,7 +94,6 @@ type FeishuDriveCommentReply = {
type FeishuDriveCommentCard = {
comment_id?: string;
is_whole?: boolean;
quote?: string;
reply_list?: {
replies?: FeishuDriveCommentReply[];
@@ -128,25 +122,6 @@ function readBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
function safeJsonStringify(value: unknown): string {
try {
return JSON.stringify(value);
} catch (error) {
return JSON.stringify({
error: error instanceof Error ? error.message : String(error),
});
}
}
function summarizeCommentRepliesForLog(replies: FeishuDriveCommentReply[]): string {
return safeJsonStringify(
replies.map((reply) => ({
reply_id: reply.reply_id,
text_len: extractReplyText(reply)?.length ?? 0,
})),
);
}
function encodeQuery(params: Record<string, string | undefined>): string {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
@@ -159,10 +134,6 @@ function encodeQuery(params: Record<string, string | undefined>): string {
return queryString ? `?${queryString}` : "";
}
async function delayMs(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
function buildDriveCommentTargetUrl(params: {
fileToken: string;
fileType: CommentFileType;
@@ -204,26 +175,6 @@ async function requestFeishuOpenApi<T>(params: {
logger?: (message: string) => void;
errorLabel: string;
}): Promise<T | null> {
const formatErrorDetails = (error: unknown): string => {
if (!isRecord(error)) {
return String(error);
}
const response = isRecord(error.response) ? error.response : undefined;
const responseData = isRecord(response?.data) ? response?.data : undefined;
const details = {
message: typeof error.message === "string" ? error.message : String(error),
code: readString(error.code),
method: readString(isRecord(error.config) ? error.config.method : undefined),
url: readString(isRecord(error.config) ? error.config.url : undefined),
http_status: typeof response?.status === "number" ? response.status : undefined,
feishu_code:
typeof responseData?.code === "number" ? responseData.code : readString(responseData?.code),
feishu_msg: readString(responseData?.msg),
feishu_log_id: readString(responseData?.log_id),
};
return safeJsonStringify(details);
};
const result = await raceWithTimeoutAndAbort(
params.client.request({
method: params.method,
@@ -235,7 +186,7 @@ async function requestFeishuOpenApi<T>(params: {
)
.then((resolved) => (resolved.status === "resolved" ? resolved.value : null))
.catch((error) => {
params.logger?.(`${params.errorLabel}: ${formatErrorDetails(error)}`);
params.logger?.(`${params.errorLabel}: ${String(error)}`);
return null;
});
if (!result) {
@@ -303,9 +254,8 @@ async function fetchDriveCommentReplies(params: {
timeoutMs: number;
logger?: (message: string) => void;
accountId: string;
}): Promise<{ replies: FeishuDriveCommentReply[]; logIds: string[] }> {
}): Promise<FeishuDriveCommentReply[]> {
const replies: FeishuDriveCommentReply[] = [];
const logIds: string[] = [];
let pageToken: string | undefined;
for (let page = 0; page < FEISHU_COMMENT_REPLY_PAGE_LIMIT; page += 1) {
const response = await requestFeishuOpenApi<FeishuDriveCommentRepliesListResponse>({
@@ -321,15 +271,10 @@ async function fetchDriveCommentReplies(params: {
logger: params.logger,
errorLabel: `feishu[${params.accountId}]: failed to fetch comment replies for ${params.commentId}`,
});
if (response?.log_id?.trim()) {
logIds.push(response.log_id.trim());
}
if (response?.code !== 0) {
if (response) {
params.logger?.(
`feishu[${params.accountId}]: failed to fetch comment replies for ${params.commentId}: ` +
`${response.msg ?? "unknown error"} ` +
`log_id=${response.log_id?.trim() || "unknown"}`,
`feishu[${params.accountId}]: failed to fetch comment replies for ${params.commentId}: ${response.msg ?? "unknown error"}`,
);
}
break;
@@ -340,7 +285,7 @@ async function fetchDriveCommentReplies(params: {
}
pageToken = response.data.page_token.trim();
}
return { replies, logIds };
return replies;
}
async function fetchDriveCommentContext(params: {
@@ -352,11 +297,9 @@ async function fetchDriveCommentContext(params: {
timeoutMs: number;
logger?: (message: string) => void;
accountId: string;
waitMs: (ms: number) => Promise<void>;
}): Promise<{
documentTitle?: string;
documentUrl?: string;
isWholeComment?: boolean;
quoteText?: string;
rootCommentText?: string;
targetReplyText?: string;
@@ -392,96 +335,35 @@ async function fetchDriveCommentContext(params: {
const commentCard =
commentResponse?.code === 0
? (commentResponse.data?.items ?? []).find(
? ((commentResponse.data?.items ?? []).find(
(item) => item.comment_id?.trim() === params.commentId,
)
) ?? commentResponse.data?.items?.[0])
: undefined;
const embeddedReplies = commentCard?.reply_list?.replies ?? [];
params.logger?.(
`feishu[${params.accountId}]: embedded comment replies comment=${params.commentId} ` +
`count=${embeddedReplies.length} summary=${summarizeCommentRepliesForLog(embeddedReplies)}`,
);
const embeddedTargetReply = params.replyId
? embeddedReplies.find((reply) => reply.reply_id?.trim() === params.replyId?.trim())
: embeddedReplies.at(-1);
let replies = embeddedReplies;
let fetchedMatchedReply = params.replyId
? replies.find((reply) => reply.reply_id?.trim() === params.replyId?.trim())
: undefined;
if (!embeddedTargetReply || replies.length === 0) {
params.logger?.(
`feishu[${params.accountId}]: fetching extra comment replies comment=${params.commentId} ` +
`requested_reply=${params.replyId ?? "none"} ` +
`embedded_count=${embeddedReplies.length} ` +
`embedded_hit=${embeddedTargetReply ? "yes" : "no"}`,
);
const fetched = await fetchDriveCommentReplies(params);
if (fetched.replies.length > 0) {
params.logger?.(
`feishu[${params.accountId}]: fetched extra comment replies comment=${params.commentId} ` +
`count=${fetched.replies.length} ` +
`log_ids=${safeJsonStringify(fetched.logIds)} ` +
`summary=${summarizeCommentRepliesForLog(fetched.replies)}`,
);
replies = fetched.replies;
fetchedMatchedReply = params.replyId
? replies.find((reply) => reply.reply_id?.trim() === params.replyId?.trim())
: undefined;
}
if (params.replyId && !embeddedTargetReply && !fetchedMatchedReply) {
for (let attempt = 1; attempt <= FEISHU_COMMENT_REPLY_MISS_RETRY_LIMIT; attempt += 1) {
params.logger?.(
`feishu[${params.accountId}]: retrying comment reply lookup comment=${params.commentId} ` +
`requested_reply=${params.replyId} attempt=${attempt}/${FEISHU_COMMENT_REPLY_MISS_RETRY_LIMIT} ` +
`delay_ms=${FEISHU_COMMENT_REPLY_MISS_RETRY_DELAY_MS}`,
);
await params.waitMs(FEISHU_COMMENT_REPLY_MISS_RETRY_DELAY_MS);
const retried = await fetchDriveCommentReplies(params);
if (retried.replies.length > 0) {
params.logger?.(
`feishu[${params.accountId}]: fetched retried comment replies comment=${params.commentId} ` +
`attempt=${attempt} count=${retried.replies.length} ` +
`log_ids=${safeJsonStringify(retried.logIds)} ` +
`summary=${summarizeCommentRepliesForLog(retried.replies)}`,
);
replies = retried.replies;
}
fetchedMatchedReply = replies.find((reply) => reply.reply_id?.trim() === params.replyId);
if (fetchedMatchedReply) {
break;
}
}
const fetchedReplies = await fetchDriveCommentReplies(params);
if (fetchedReplies.length > 0) {
replies = fetchedReplies;
}
}
const rootReply = replies[0] ?? embeddedReplies[0];
const fetchedMatchedReply = params.replyId
? replies.find((reply) => reply.reply_id?.trim() === params.replyId?.trim())
: undefined;
const targetReply = params.replyId
? (embeddedTargetReply ?? fetchedMatchedReply ?? undefined)
: (replies.at(-1) ?? embeddedTargetReply ?? rootReply);
const matchSource = params.replyId
? embeddedTargetReply
? "embedded"
: fetchedMatchedReply
? "fetched"
: "miss"
: targetReply === rootReply
? "fallback_root"
: targetReply === embeddedTargetReply
? "embedded_latest"
: "fetched_latest";
params.logger?.(
`feishu[${params.accountId}]: comment reply resolution comment=${params.commentId} ` +
`requested_reply=${params.replyId ?? "none"} match_source=${matchSource} ` +
`root=${safeJsonStringify({ reply_id: rootReply?.reply_id, text_len: extractReplyText(rootReply)?.length ?? 0 })} ` +
`target=${safeJsonStringify({ reply_id: targetReply?.reply_id, text_len: extractReplyText(targetReply)?.length ?? 0 })}`,
);
const meta = metaResponse?.code === 0 ? metaResponse.data?.metas?.[0] : undefined;
return {
documentTitle: meta?.title?.trim() || undefined,
documentUrl: meta?.url?.trim() || undefined,
isWholeComment: commentCard?.is_whole,
quoteText: commentCard?.quote?.trim() || undefined,
rootCommentText: extractReplyText(rootReply),
targetReplyText: extractReplyText(targetReply),
@@ -494,7 +376,6 @@ function buildDriveCommentSurfacePrompt(params: {
fileToken: string;
commentId: string;
replyId?: string;
isWholeComment?: boolean;
isMentioned?: boolean;
documentTitle?: string;
documentUrl?: string;
@@ -532,16 +413,12 @@ function buildDriveCommentSurfacePrompt(params: {
`file_type: ${params.fileType}`,
`comment_id: ${params.commentId}`,
);
if (params.isWholeComment === true) {
lines.push("This is a whole-document comment.");
}
if (params.replyId?.trim()) {
lines.push(`reply_id: ${params.replyId.trim()}`);
}
lines.push(
"This is a Feishu document comment-thread event, not a Feishu IM conversation. Your final text reply will be posted automatically to the current comment thread and will not be sent as an instant message.",
"If you need to inspect or handle the comment thread, prefer the feishu_drive tools: use list_comments / list_comment_replies to inspect comments, and use reply_comment/add_comment to notify the user after modifying the document.",
"Whole-document comments do not support direct replies. When the current comment is whole-document, use feishu_drive.add_comment for any user-visible follow-up instead of reply_comment.",
'If the comment asks you to modify document content, such as adding, inserting, replacing, or deleting text, tables, or headings, you must first use feishu_doc to actually modify the document. Do not reply with only "done", "I\'ll handle it", or a restated plan without calling tools.',
'If the comment quotes document content, that quoted text is usually the edit anchor. For requests like "insert xxx below this content", first locate the position around the quoted content, then use feishu_doc to make the change.',
'If the comment asks you to summarize, explain, rewrite, translate, refine, continue, or review the document content "below", "above", "this paragraph", "this section", or the quoted content, you must also treat the quoted content as the primary target anchor instead of defaulting to the whole document.',
@@ -550,11 +427,6 @@ function buildDriveCommentSurfacePrompt(params: {
"When document edits are involved, first use feishu_doc.read or feishu_doc.list_blocks to confirm the context, then use feishu_doc writing or updating capabilities to complete the change. After the edit succeeds, notify the user through feishu_drive.reply_comment.",
"If the document edit fails or you cannot locate the anchor, do not pretend it succeeded. Reply clearly in the comment thread with the reason for failure or the missing information.",
"If this is a reading-comprehension task, such as summarization, explanation, or extraction, you may directly output the final answer text after confirming the context. The system will automatically reply with that answer in the current comment thread.",
"Prefer plain text suitable for a comment thread. Unless the user explicitly asks for Markdown, do not use Markdown headings, bullet lists, numbered lists, tables, blockquotes, or fenced code blocks in the final reply.",
"If source content was read in Markdown form, rewrite it into normal plain-text prose before replying in the comment thread instead of copying Markdown syntax through.",
'Do not include internal reasoning, analysis, chain-of-thought, scratch work, or any "Reasoning:" / "Thinking:" section in a user-visible reply. Output only the final answer meant for the user, or NO_REPLY when appropriate.',
'Do not narrate your plan or execution process in the user-visible reply. Avoid meta lead-ins such as "I will...", "Ill first...", "I need to...", "The user wants...", "I have updated...", or "I am going to...".',
"When the task is complete, reply only with the user-facing result itself, such as the final answer or a concise completion confirmation. Do not include preambles about what you plan to do next.",
"When you produce a user-visible reply, keep it in the same language as the user's original comment or reply unless they explicitly ask for another language.",
"If you have already completed the user-visible action through feishu_drive.reply_comment or feishu_drive.add_comment, output NO_REPLY at the end to avoid duplicate sending.",
"If the user directly asks a question in the comment and a plain text answer is sufficient, output the answer text directly. The system will automatically reply with your final answer in the current comment thread.",
@@ -571,7 +443,6 @@ async function resolveDriveCommentEventCore(params: ResolveDriveCommentEventPara
noticeType: "add_comment" | "add_reply";
fileToken: string;
fileType: CommentFileType;
isWholeComment?: boolean;
senderId: string;
senderUserId?: string;
timestamp?: string;
@@ -592,7 +463,6 @@ async function resolveDriveCommentEventCore(params: ResolveDriveCommentEventPara
createClient = (account) => createFeishuClient(account) as FeishuRequestClient,
verificationTimeoutMs = FEISHU_COMMENT_VERIFY_TIMEOUT_MS,
logger,
waitMs = delayMs,
} = params;
const eventId = event.event_id?.trim();
const commentId = event.comment_id?.trim();
@@ -637,7 +507,6 @@ async function resolveDriveCommentEventCore(params: ResolveDriveCommentEventPara
timeoutMs: verificationTimeoutMs,
logger,
accountId,
waitMs,
});
return {
eventId,
@@ -646,7 +515,6 @@ async function resolveDriveCommentEventCore(params: ResolveDriveCommentEventPara
noticeType,
fileToken,
fileType,
isWholeComment: context.isWholeComment,
senderId,
senderUserId,
timestamp: event.timestamp,
@@ -706,7 +574,6 @@ export async function resolveDriveCommentEventTurn(
fileToken: resolved.fileToken,
commentId: resolved.commentId,
replyId: resolved.replyId,
isWholeComment: resolved.isWholeComment,
isMentioned: resolved.isMentioned,
documentTitle: resolved.context.documentTitle,
documentUrl: resolved.context.documentUrl,
@@ -723,7 +590,6 @@ export async function resolveDriveCommentEventTurn(
noticeType: resolved.noticeType,
fileToken: resolved.fileToken,
fileType: resolved.fileType,
isWholeComment: resolved.isWholeComment,
senderId: resolved.senderId,
senderUserId: resolved.senderUserId,
timestamp: resolved.timestamp,

View File

@@ -21,18 +21,6 @@ describe("google generative ai helpers", () => {
expect(normalizeGoogleGenerativeAiBaseUrl("https://proxy.example.com/google/v1beta")).toBe(
"https://proxy.example.com/google/v1beta",
);
expect(normalizeGoogleGenerativeAiBaseUrl("https://aiplatform.googleapis.com")).toBe(
"https://aiplatform.googleapis.com",
);
expect(normalizeGoogleGenerativeAiBaseUrl("proxy/generativelanguage.googleapis.com")).toBe(
"proxy/generativelanguage.googleapis.com",
);
expect(normalizeGoogleGenerativeAiBaseUrl("generativelanguage.googleapis.com")).toBe(
"generativelanguage.googleapis.com",
);
expect(normalizeGoogleGenerativeAiBaseUrl("https://xgenerativelanguage.googleapis.com")).toBe(
"https://xgenerativelanguage.googleapis.com",
);
expect(normalizeGoogleGenerativeAiBaseUrl()).toBeUndefined();
});

View File

@@ -1,4 +1,3 @@
import { resolveProviderEndpoint } from "openclaw/plugin-sdk/provider-http";
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
import {
applyAgentDefaultModelPrimary,
@@ -15,16 +14,14 @@ type GoogleProviderConfigLike = GoogleApiCarrier & {
models?: ReadonlyArray<GoogleApiCarrier | null | undefined> | null;
};
const DEFAULT_GOOGLE_API_HOST = "generativelanguage.googleapis.com";
export const DEFAULT_GOOGLE_API_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
function trimTrailingSlashes(value: string): string {
return value.replace(/\/+$/, "");
}
function isCanonicalGoogleApiOriginShorthand(value: string): boolean {
return /^https:\/\/generativelanguage\.googleapis\.com\/?$/i.test(value);
}
export function normalizeGoogleApiBaseUrl(baseUrl?: string): string {
const raw = trimTrailingSlashes(baseUrl?.trim() || DEFAULT_GOOGLE_API_BASE_URL);
try {
@@ -32,14 +29,14 @@ export function normalizeGoogleApiBaseUrl(baseUrl?: string): string {
url.hash = "";
url.search = "";
if (
resolveProviderEndpoint(url.toString()).endpointClass === "google-generative-ai" &&
url.hostname.toLowerCase() === DEFAULT_GOOGLE_API_HOST &&
trimTrailingSlashes(url.pathname || "") === ""
) {
url.pathname = "/v1beta";
}
return trimTrailingSlashes(url.toString());
} catch {
if (isCanonicalGoogleApiOriginShorthand(raw)) {
if (/^https:\/\/generativelanguage\.googleapis\.com\/?$/i.test(raw)) {
return DEFAULT_GOOGLE_API_BASE_URL;
}
return raw;

View File

@@ -2,8 +2,8 @@ import type { ImageGenerationProvider } from "openclaw/plugin-sdk/image-generati
import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
assertOkOrThrowHttpError,
normalizeBaseUrl,
postJsonRequest,
resolveProviderHttpRequestConfig,
} from "openclaw/plugin-sdk/provider-http";
import {
DEFAULT_GOOGLE_API_BASE_URL,
@@ -134,16 +134,10 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProvider {
}
const model = normalizeGoogleImageModel(req.model);
const { baseUrl, allowPrivateNetwork, headers } = resolveProviderHttpRequestConfig({
baseUrl: resolveGoogleBaseUrl(req.cfg),
defaultBaseUrl: DEFAULT_GOOGLE_API_BASE_URL,
allowPrivateNetwork: Boolean(req.cfg?.models?.providers?.google?.baseUrl?.trim()),
defaultHeaders: parseGeminiAuth(auth.apiKey).headers,
provider: "google",
api: "google-generative-ai",
capability: "image",
transport: "http",
});
const baseUrl = normalizeBaseUrl(resolveGoogleBaseUrl(req.cfg), DEFAULT_GOOGLE_API_BASE_URL);
const allowPrivate = Boolean(req.cfg?.models?.providers?.google?.baseUrl?.trim());
const authHeaders = parseGeminiAuth(auth.apiKey);
const headers = new Headers(authHeaders.headers);
const imageConfig = mapSizeToImageConfig(req.size);
const inputParts = (req.inputImages ?? []).map((image) => ({
inlineData: {
@@ -159,6 +153,7 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProvider {
const { response: res, release } = await postJsonRequest({
url: `${baseUrl}/models/${model}:generateContent`,
provider: "google",
headers,
body: {
contents: [
@@ -176,7 +171,7 @@ export function buildGoogleImageGenerationProvider(): ImageGenerationProvider {
},
timeoutMs: 60_000,
fetchFn: fetch,
allowPrivateNetwork,
allowPrivateNetwork: allowPrivate,
});
try {

View File

@@ -9,8 +9,8 @@ import {
} from "openclaw/plugin-sdk/media-understanding";
import {
assertOkOrThrowHttpError,
normalizeBaseUrl,
postJsonRequest,
resolveProviderHttpRequestConfig,
} from "openclaw/plugin-sdk/provider-http";
import {
DEFAULT_GOOGLE_API_BASE_URL,
@@ -44,6 +44,11 @@ async function generateGeminiInlineDataText(params: {
missingTextError: string;
}): Promise<{ text: string; model: string }> {
const fetchFn = params.fetchFn ?? fetch;
const baseUrl = normalizeBaseUrl(
normalizeGoogleApiBaseUrl(params.baseUrl ?? params.defaultBaseUrl),
DEFAULT_GOOGLE_API_BASE_URL,
);
const allowPrivate = Boolean(params.baseUrl?.trim());
const model = (() => {
const trimmed = params.model?.trim();
if (!trimmed) {
@@ -51,19 +56,16 @@ async function generateGeminiInlineDataText(params: {
}
return normalizeGoogleModelId(trimmed);
})();
const { baseUrl, allowPrivateNetwork, headers } = resolveProviderHttpRequestConfig({
baseUrl: normalizeGoogleApiBaseUrl(params.baseUrl ?? params.defaultBaseUrl),
defaultBaseUrl: DEFAULT_GOOGLE_API_BASE_URL,
allowPrivateNetwork: Boolean(params.baseUrl?.trim()),
headers: params.headers,
defaultHeaders: parseGeminiAuth(params.apiKey).headers,
provider: "google",
api: "google-generative-ai",
capability: params.defaultMime.startsWith("audio/") ? "audio" : "video",
transport: "media-understanding",
});
const url = `${baseUrl}/models/${model}:generateContent`;
const authHeaders = parseGeminiAuth(params.apiKey);
const headers = new Headers(params.headers);
for (const [key, value] of Object.entries(authHeaders.headers)) {
if (!headers.has(key)) {
headers.set(key, value);
}
}
const prompt = (() => {
const trimmed = params.prompt?.trim();
return trimmed || params.defaultPrompt;
@@ -88,11 +90,12 @@ async function generateGeminiInlineDataText(params: {
const { response: res, release } = await postJsonRequest({
url,
provider: "google",
headers,
body,
timeoutMs: params.timeoutMs,
fetchFn,
allowPrivateNetwork,
allowPrivateNetwork: allowPrivate,
});
try {

View File

@@ -12,6 +12,7 @@ export const groqMediaUnderstandingProvider: MediaUnderstandingProvider = {
transcribeAudio: (req) =>
transcribeOpenAiCompatibleAudio({
...req,
provider: "groq",
baseUrl: req.baseUrl ?? DEFAULT_GROQ_AUDIO_BASE_URL,
defaultBaseUrl: DEFAULT_GROQ_AUDIO_BASE_URL,
defaultModel: DEFAULT_GROQ_AUDIO_MODEL,

View File

@@ -80,8 +80,6 @@ export default definePluginEntry({
},
},
capabilities: {
anthropicToolSchemaMode: "openai-functions",
anthropicToolChoiceMode: "openai-string-modes",
openAiPayloadNormalizationMode: "moonshot-thinking",
preserveAnthropicThinkingSignatures: false,
},

View File

@@ -11,7 +11,6 @@ vi.mock("./matrix/actions/verification.js", () => ({
import { matrixPlugin } from "./channel.js";
import { matrixSetupAdapter } from "./setup-core.js";
import { matrixSetupWizard } from "./setup-surface.js";
import { installMatrixTestRuntime } from "./test-runtime.js";
import type { CoreConfig } from "./types.js";
@@ -136,11 +135,6 @@ describe("matrix setup post-write bootstrap", () => {
installMatrixTestRuntime();
});
it("registers the Matrix guided setup wizard on the channel plugin", () => {
expect(matrixPlugin.setupWizard).toBe(matrixSetupWizard);
expect(matrixPlugin.setupWizard?.channel).toBe("matrix");
});
it("bootstraps verification for newly added encrypted accounts", async () => {
const { previousCfg, nextCfg, accountId, input } = applyDefaultAccountConfig();
mockBootstrapResult({ success: true, backupVersion: "7" });

View File

@@ -62,7 +62,6 @@ import {
import { getMatrixRuntime } from "./runtime.js";
import { resolveMatrixOutboundSessionRoute } from "./session-route.js";
import { matrixSetupAdapter } from "./setup-core.js";
import { matrixSetupWizard } from "./setup-surface.js";
import type { CoreConfig } from "./types.js";
// Mutex for serializing account startup (workaround for concurrent dynamic import race condition)
@@ -290,7 +289,6 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount, MatrixProbe> =
base: {
id: "matrix",
meta,
setupWizard: matrixSetupWizard,
capabilities: {
chatTypes: ["direct", "group", "thread"],
polls: true,

View File

@@ -4,14 +4,13 @@ import {
resolveConfiguredMatrixAccountIds,
resolveMatrixDefaultOrOnlyAccountId,
} from "../account-selection.js";
import { resolveMatrixAccountStringValues } from "../auth-precedence.js";
import { getMatrixScopedEnvVarNames } from "../env-vars.js";
import type { CoreConfig, MatrixConfig } from "../types.js";
import {
findMatrixAccountConfig,
resolveMatrixAccountConfig,
resolveMatrixBaseConfig,
} from "./account-config.js";
import { resolveMatrixConfigForAccount } from "./resolved-config.js";
import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials-read.js";
export type ResolvedMatrixAccount = {
@@ -24,96 +23,14 @@ export type ResolvedMatrixAccount = {
config: MatrixConfig;
};
type MatrixEnvConfig = {
homeserver: string;
userId: string;
accessToken?: string;
password?: string;
deviceId?: string;
deviceName?: string;
};
function clean(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function resolveGlobalMatrixEnvConfig(env: NodeJS.ProcessEnv): MatrixEnvConfig {
return {
homeserver: clean(env.MATRIX_HOMESERVER),
userId: clean(env.MATRIX_USER_ID),
accessToken: clean(env.MATRIX_ACCESS_TOKEN) || undefined,
password: clean(env.MATRIX_PASSWORD) || undefined,
deviceId: clean(env.MATRIX_DEVICE_ID) || undefined,
deviceName: clean(env.MATRIX_DEVICE_NAME) || undefined,
};
}
function resolveScopedMatrixEnvConfig(accountId: string, env: NodeJS.ProcessEnv): MatrixEnvConfig {
const keys = getMatrixScopedEnvVarNames(accountId);
return {
homeserver: clean(env[keys.homeserver]),
userId: clean(env[keys.userId]),
accessToken: clean(env[keys.accessToken]) || undefined,
password: clean(env[keys.password]) || undefined,
deviceId: clean(env[keys.deviceId]) || undefined,
deviceName: clean(env[keys.deviceName]) || undefined,
};
}
function resolveMatrixAccountAuthView(params: {
cfg: CoreConfig;
accountId: string;
env: NodeJS.ProcessEnv;
}): {
homeserver: string;
userId: string;
accessToken?: string;
password?: string;
} {
const normalizedAccountId = normalizeAccountId(params.accountId);
const matrix = resolveMatrixBaseConfig(params.cfg);
const account = findMatrixAccountConfig(params.cfg, normalizedAccountId) ?? {};
const resolvedStrings = resolveMatrixAccountStringValues({
accountId: normalizedAccountId,
account: {
homeserver: clean(account.homeserver),
userId: clean(account.userId),
accessToken: typeof account.accessToken === "string" ? clean(account.accessToken) : "",
password: typeof account.password === "string" ? clean(account.password) : "",
deviceId: clean(account.deviceId),
deviceName: clean(account.deviceName),
},
scopedEnv: resolveScopedMatrixEnvConfig(normalizedAccountId, params.env),
channel: {
homeserver: clean(matrix.homeserver),
userId: clean(matrix.userId),
accessToken: typeof matrix.accessToken === "string" ? clean(matrix.accessToken) : "",
password: typeof matrix.password === "string" ? clean(matrix.password) : "",
deviceId: clean(matrix.deviceId),
deviceName: clean(matrix.deviceName),
},
globalEnv: resolveGlobalMatrixEnvConfig(params.env),
});
return {
homeserver: resolvedStrings.homeserver,
userId: resolvedStrings.userId,
accessToken: resolvedStrings.accessToken || undefined,
password: resolvedStrings.password || undefined,
};
}
function resolveMatrixAccountUserId(params: {
cfg: CoreConfig;
accountId: string;
env?: NodeJS.ProcessEnv;
}): string | null {
const env = params.env ?? process.env;
const authView = resolveMatrixAccountAuthView({
cfg: params.cfg,
accountId: params.accountId,
env,
});
const configuredUserId = authView.userId.trim();
const resolved = resolveMatrixConfigForAccount(params.cfg, params.accountId, env);
const configuredUserId = resolved.userId.trim();
if (configuredUserId) {
return configuredUserId;
}
@@ -122,10 +39,10 @@ function resolveMatrixAccountUserId(params: {
if (!stored) {
return null;
}
if (authView.homeserver && stored.homeserver !== authView.homeserver) {
if (resolved.homeserver && stored.homeserver !== resolved.homeserver) {
return null;
}
if (authView.accessToken && stored.accessToken !== authView.accessToken) {
if (resolved.accessToken && stored.accessToken !== resolved.accessToken) {
return null;
}
return stored.userId.trim() || null;
@@ -188,24 +105,20 @@ export function resolveMatrixAccount(params: {
: (findMatrixAccountConfig(params.cfg, accountId) ?? {});
const enabled = base.enabled !== false && matrixBase.enabled !== false;
const authView = resolveMatrixAccountAuthView({
cfg: params.cfg,
accountId,
env,
});
const hasHomeserver = Boolean(authView.homeserver);
const hasUserId = Boolean(authView.userId);
const resolved = resolveMatrixConfigForAccount(params.cfg, accountId, env);
const hasHomeserver = Boolean(resolved.homeserver);
const hasUserId = Boolean(resolved.userId);
const hasAccessToken =
Boolean(authView.accessToken) || hasConfiguredSecretInput(explicitAuthConfig.accessToken);
const hasPassword = Boolean(authView.password);
Boolean(resolved.accessToken) || hasConfiguredSecretInput(explicitAuthConfig.accessToken);
const hasPassword = Boolean(resolved.password);
const hasPasswordAuth =
hasUserId && (hasPassword || hasConfiguredSecretInput(explicitAuthConfig.password));
const stored = loadMatrixCredentials(env, accountId);
const hasStored =
stored && authView.homeserver
stored && resolved.homeserver
? credentialsMatchConfig(stored, {
homeserver: authView.homeserver,
userId: authView.userId || "",
homeserver: resolved.homeserver,
userId: resolved.userId || "",
})
: false;
const configured = hasHomeserver && (hasAccessToken || hasPasswordAuth || Boolean(hasStored));
@@ -214,8 +127,8 @@ export function resolveMatrixAccount(params: {
enabled,
name: base.name?.trim() || undefined,
configured,
homeserver: authView.homeserver || undefined,
userId: authView.userId || undefined,
homeserver: resolved.homeserver || undefined,
userId: resolved.userId || undefined,
config: base,
};
}

View File

@@ -1,22 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { setMatrixRuntime } from "../../runtime.js";
import type { MatrixClient } from "../sdk.js";
import * as sendModule from "../send.js";
import { editMatrixMessage, readMatrixMessages } from "./messages.js";
function installMatrixActionTestRuntime(): void {
setMatrixRuntime({
config: {
loadConfig: () => ({}),
},
channel: {
text: {
resolveMarkdownTableMode: () => "code",
convertMarkdownTables: (text: string) => text,
},
},
} as unknown as import("../../runtime-api.js").PluginRuntime);
}
import { readMatrixMessages } from "./messages.js";
function createPollResponseEvent(): Record<string, unknown> {
return {
@@ -90,102 +74,6 @@ function createMessagesClient(params: {
}
describe("matrix message actions", () => {
it("forwards timeoutMs to the shared Matrix edit helper", async () => {
const editSpy = vi.spyOn(sendModule, "editMessageMatrix").mockResolvedValue("evt-edit");
try {
const result = await editMatrixMessage("!room:example.org", "$original", "hello", {
timeoutMs: 12_345,
});
expect(result).toEqual({ eventId: "evt-edit" });
expect(editSpy).toHaveBeenCalledWith("!room:example.org", "$original", "hello", {
cfg: undefined,
accountId: undefined,
client: undefined,
timeoutMs: 12_345,
});
} finally {
editSpy.mockRestore();
}
});
it("routes edits through the shared Matrix edit helper so mentions are preserved", async () => {
installMatrixActionTestRuntime();
const sendMessage = vi.fn().mockResolvedValue("evt-edit");
const client = {
getEvent: vi.fn().mockResolvedValue({
content: {
body: "hello @alice:example.org",
"m.mentions": { user_ids: ["@alice:example.org"] },
},
}),
getJoinedRoomMembers: vi.fn().mockResolvedValue([]),
getUserId: vi.fn().mockResolvedValue("@bot:example.org"),
sendMessage,
prepareForOneOff: vi.fn(async () => undefined),
start: vi.fn(async () => undefined),
stop: vi.fn(() => undefined),
stopAndPersist: vi.fn(async () => undefined),
} as unknown as MatrixClient;
const result = await editMatrixMessage(
"!room:example.org",
"$original",
"hello @alice:example.org and @bob:example.org",
{ client },
);
expect(result).toEqual({ eventId: "evt-edit" });
expect(sendMessage).toHaveBeenCalledWith(
"!room:example.org",
expect.objectContaining({
"m.mentions": { user_ids: ["@bob:example.org"] },
"m.new_content": expect.objectContaining({
"m.mentions": { user_ids: ["@alice:example.org", "@bob:example.org"] },
}),
}),
);
});
it("does not re-notify legacy mentions when action edits target pre-m.mentions messages", async () => {
installMatrixActionTestRuntime();
const sendMessage = vi.fn().mockResolvedValue("evt-edit");
const client = {
getEvent: vi.fn().mockResolvedValue({
content: {
body: "hello @alice:example.org",
},
}),
getJoinedRoomMembers: vi.fn().mockResolvedValue([]),
getUserId: vi.fn().mockResolvedValue("@bot:example.org"),
sendMessage,
prepareForOneOff: vi.fn(async () => undefined),
start: vi.fn(async () => undefined),
stop: vi.fn(() => undefined),
stopAndPersist: vi.fn(async () => undefined),
} as unknown as MatrixClient;
const result = await editMatrixMessage(
"!room:example.org",
"$original",
"hello again @alice:example.org",
{ client },
);
expect(result).toEqual({ eventId: "evt-edit" });
expect(sendMessage).toHaveBeenCalledWith(
"!room:example.org",
expect.objectContaining({
"m.mentions": {},
"m.new_content": expect.objectContaining({
body: "hello again @alice:example.org",
"m.mentions": { user_ids: ["@alice:example.org"] },
}),
}),
);
});
it("includes poll snapshots when reading message history", async () => {
const { client, doRequest, getEvent, getRelations } = createMessagesClient({
chunk: [

View File

@@ -1,14 +1,17 @@
import { fetchMatrixPollMessageSummary, resolveMatrixPollRootEventId } from "../poll-summary.js";
import { isPollEventType } from "../poll-types.js";
import { editMessageMatrix, sendMessageMatrix } from "../send.js";
import { withResolvedRoomAction } from "./client.js";
import { sendMessageMatrix } from "../send.js";
import { withResolvedActionClient, withResolvedRoomAction } from "./client.js";
import { resolveMatrixActionLimit } from "./limits.js";
import { summarizeMatrixRawEvent } from "./summary.js";
import {
EventType,
MsgType,
RelationType,
type MatrixActionClientOpts,
type MatrixMessageSummary,
type MatrixRawEvent,
type RoomMessageEventContent,
} from "./types.js";
export async function sendMatrixMessage(
@@ -44,13 +47,23 @@ export async function editMatrixMessage(
if (!trimmed) {
throw new Error("Matrix edit requires content");
}
const eventId = await editMessageMatrix(roomId, messageId, trimmed, {
cfg: opts.cfg,
accountId: opts.accountId ?? undefined,
client: opts.client,
timeoutMs: opts.timeoutMs,
return await withResolvedRoomAction(roomId, opts, async (client, resolvedRoom) => {
const newContent = {
msgtype: MsgType.Text,
body: trimmed,
} satisfies RoomMessageEventContent;
const payload: RoomMessageEventContent = {
msgtype: MsgType.Text,
body: `* ${trimmed}`,
"m.new_content": newContent,
"m.relates_to": {
rel_type: RelationType.Replace,
event_id: messageId,
},
};
const eventId = await client.sendMessage(resolvedRoom, payload);
return { eventId: eventId ?? null };
});
return { eventId: eventId || null };
}
export async function deleteMatrixMessage(

View File

@@ -1,7 +1,8 @@
import { getMatrixRuntime } from "../runtime.js";
import type { CoreConfig } from "../types.js";
import { getActiveMatrixClient } from "./active-client.js";
import { isBunRuntime } from "./client/runtime.js";
import { acquireSharedMatrixClient, isBunRuntime, resolveMatrixAuthContext } from "./client.js";
import { releaseSharedClientInstance } from "./client/shared.js";
import type { MatrixClient } from "./sdk.js";
type ResolvedRuntimeMatrixClient = {
@@ -18,26 +19,6 @@ type MatrixResolvedClientHook = (
context: { preparedByDefault: boolean },
) => Promise<void> | void;
type MatrixSharedClientRuntimeDeps = Pick<
typeof import("./client.js"),
"acquireSharedMatrixClient" | "resolveMatrixAuthContext"
> &
Pick<typeof import("./client/shared.js"), "releaseSharedClientInstance">;
let matrixSharedClientRuntimeDepsPromise: Promise<MatrixSharedClientRuntimeDeps> | undefined;
async function loadMatrixSharedClientRuntimeDeps(): Promise<MatrixSharedClientRuntimeDeps> {
matrixSharedClientRuntimeDepsPromise ??= Promise.all([
import("./client.js"),
import("./client/shared.js"),
]).then(([clientModule, sharedModule]) => ({
acquireSharedMatrixClient: clientModule.acquireSharedMatrixClient,
resolveMatrixAuthContext: clientModule.resolveMatrixAuthContext,
releaseSharedClientInstance: sharedModule.releaseSharedClientInstance,
}));
return await matrixSharedClientRuntimeDepsPromise;
}
async function ensureResolvedClientReadiness(params: {
client: MatrixClient;
readiness?: MatrixRuntimeClientReadiness;
@@ -72,8 +53,6 @@ async function resolveRuntimeMatrixClient(opts: {
}
const cfg = opts.cfg ?? (getMatrixRuntime().config.loadConfig() as CoreConfig);
const { acquireSharedMatrixClient, releaseSharedClientInstance, resolveMatrixAuthContext } =
await loadMatrixSharedClientRuntimeDeps();
const authContext = resolveMatrixAuthContext({
cfg,
accountId: opts.accountId,
@@ -83,6 +62,7 @@ async function resolveRuntimeMatrixClient(opts: {
await opts.onResolved?.(active, { preparedByDefault: false });
return { client: active, stopOnDone: false };
}
const client = await acquireSharedMatrixClient({
cfg,
timeoutMs: opts.timeoutMs,

View File

@@ -35,20 +35,12 @@ const {
resolveMatrixConfigForAccount,
resolveMatrixAuth,
resolveMatrixAuthContext,
setMatrixAuthClientDepsForTest,
resolveValidatedMatrixHomeserverUrl,
validateMatrixHomeserverUrl,
} = await import("./client/config.js");
let credentialsReadModule: typeof import("./credentials-read.js") | undefined;
const ensureMatrixSdkLoggingConfiguredMock = vi.fn();
const matrixDoRequestMock = vi.fn();
class MockMatrixClient {
async doRequest(...args: unknown[]) {
return await matrixDoRequestMock(...args);
}
}
let sdkModule: typeof import("./sdk.js") | undefined;
function requireCredentialsReadModule(): typeof import("./credentials-read.js") {
if (!credentialsReadModule) {
@@ -723,6 +715,7 @@ describe("resolveMatrixConfig", () => {
describe("resolveMatrixAuth", () => {
beforeAll(async () => {
credentialsReadModule = await import("./credentials-read.js");
sdkModule = await import("./sdk.js");
});
beforeEach(() => {
@@ -733,26 +726,21 @@ describe("resolveMatrixAuth", () => {
vi.mocked(readModule.credentialsMatchConfig).mockReturnValue(false);
saveMatrixCredentialsMock.mockReset();
touchMatrixCredentialsMock.mockReset();
ensureMatrixSdkLoggingConfiguredMock.mockReset();
matrixDoRequestMock.mockReset();
setMatrixAuthClientDepsForTest({
MatrixClient: MockMatrixClient as unknown as typeof import("./sdk.js").MatrixClient,
ensureMatrixSdkLoggingConfigured: ensureMatrixSdkLoggingConfiguredMock,
});
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
setMatrixAuthClientDepsForTest(undefined);
});
it("uses the hardened client request path for password login and persists deviceId", async () => {
matrixDoRequestMock.mockResolvedValue({
access_token: "tok-123",
user_id: "@bot:example.org",
device_id: "DEVICE123",
});
const doRequestSpy = vi
.spyOn(sdkModule!.MatrixClient.prototype, "doRequest")
.mockResolvedValue({
access_token: "tok-123",
user_id: "@bot:example.org",
device_id: "DEVICE123",
});
const cfg = {
channels: {
@@ -770,7 +758,7 @@ describe("resolveMatrixAuth", () => {
env: {} as NodeJS.ProcessEnv,
});
expect(matrixDoRequestMock).toHaveBeenCalledWith(
expect(doRequestSpy).toHaveBeenCalledWith(
"POST",
"/_matrix/client/v3/login",
undefined,
@@ -799,7 +787,8 @@ describe("resolveMatrixAuth", () => {
});
it("surfaces password login errors when account credentials are invalid", async () => {
matrixDoRequestMock.mockRejectedValueOnce(new Error("Invalid username or password"));
const doRequestSpy = vi.spyOn(sdkModule!.MatrixClient.prototype, "doRequest");
doRequestSpy.mockRejectedValueOnce(new Error("Invalid username or password"));
const cfg = {
channels: {
@@ -818,7 +807,7 @@ describe("resolveMatrixAuth", () => {
}),
).rejects.toThrow("Invalid username or password");
expect(matrixDoRequestMock).toHaveBeenCalledWith(
expect(doRequestSpy).toHaveBeenCalledWith(
"POST",
"/_matrix/client/v3/login",
undefined,
@@ -976,10 +965,12 @@ describe("resolveMatrixAuth", () => {
});
it("resolves token-only non-default account userId from whoami instead of inheriting the base user", async () => {
matrixDoRequestMock.mockResolvedValue({
user_id: "@ops:example.org",
device_id: "OPSDEVICE",
});
const doRequestSpy = vi
.spyOn(sdkModule!.MatrixClient.prototype, "doRequest")
.mockResolvedValue({
user_id: "@ops:example.org",
device_id: "OPSDEVICE",
});
const cfg = {
channels: {
@@ -1002,7 +993,7 @@ describe("resolveMatrixAuth", () => {
accountId: "ops",
});
expect(matrixDoRequestMock).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami");
expect(doRequestSpy).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami");
expect(auth.userId).toBe("@ops:example.org");
expect(auth.deviceId).toBe("OPSDEVICE");
});
@@ -1010,11 +1001,13 @@ describe("resolveMatrixAuth", () => {
it("uses named-account password auth instead of inheriting the base access token", async () => {
vi.mocked(credentialsReadModule!.loadMatrixCredentials).mockReturnValue(null);
vi.mocked(credentialsReadModule!.credentialsMatchConfig).mockReturnValue(false);
matrixDoRequestMock.mockResolvedValue({
access_token: "ops-token",
user_id: "@ops:example.org",
device_id: "OPSDEVICE",
});
const doRequestSpy = vi
.spyOn(sdkModule!.MatrixClient.prototype, "doRequest")
.mockResolvedValue({
access_token: "ops-token",
user_id: "@ops:example.org",
device_id: "OPSDEVICE",
});
const cfg = {
channels: {
@@ -1038,7 +1031,7 @@ describe("resolveMatrixAuth", () => {
accountId: "ops",
});
expect(matrixDoRequestMock).toHaveBeenCalledWith(
expect(doRequestSpy).toHaveBeenCalledWith(
"POST",
"/_matrix/client/v3/login",
undefined,
@@ -1058,10 +1051,12 @@ describe("resolveMatrixAuth", () => {
});
it("resolves missing whoami identity fields for token auth", async () => {
matrixDoRequestMock.mockResolvedValue({
user_id: "@bot:example.org",
device_id: "DEVICE123",
});
const doRequestSpy = vi
.spyOn(sdkModule!.MatrixClient.prototype, "doRequest")
.mockResolvedValue({
user_id: "@bot:example.org",
device_id: "DEVICE123",
});
const cfg = {
channels: {
@@ -1078,7 +1073,7 @@ describe("resolveMatrixAuth", () => {
env: {} as NodeJS.ProcessEnv,
});
expect(matrixDoRequestMock).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami");
expect(doRequestSpy).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami");
expect(auth).toMatchObject({
accountId: "default",
homeserver: "https://matrix.example.org",
@@ -1095,10 +1090,12 @@ describe("resolveMatrixAuth", () => {
await fs.writeFile(secretPath, "file-token\n", "utf8");
await fs.chmod(secretPath, 0o600);
matrixDoRequestMock.mockResolvedValue({
user_id: "@bot:example.org",
device_id: "DEVICE123",
});
const doRequestSpy = vi
.spyOn(sdkModule!.MatrixClient.prototype, "doRequest")
.mockResolvedValue({
user_id: "@bot:example.org",
device_id: "DEVICE123",
});
try {
const cfg = {
@@ -1124,10 +1121,7 @@ describe("resolveMatrixAuth", () => {
env: {} as NodeJS.ProcessEnv,
});
expect(matrixDoRequestMock).toHaveBeenCalledWith(
"GET",
"/_matrix/client/v3/account/whoami",
);
expect(doRequestSpy).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami");
expect(auth).toMatchObject({
accountId: "default",
homeserver: "https://matrix.example.org",
@@ -1141,10 +1135,12 @@ describe("resolveMatrixAuth", () => {
});
it("does not resolve inactive password SecretRefs when scoped token auth wins", async () => {
matrixDoRequestMock.mockResolvedValue({
user_id: "@ops:example.org",
device_id: "OPSDEVICE",
});
const doRequestSpy = vi
.spyOn(sdkModule!.MatrixClient.prototype, "doRequest")
.mockResolvedValue({
user_id: "@ops:example.org",
device_id: "OPSDEVICE",
});
const cfg = {
channels: {
@@ -1174,7 +1170,7 @@ describe("resolveMatrixAuth", () => {
accountId: "ops",
});
expect(matrixDoRequestMock).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami");
expect(doRequestSpy).toHaveBeenCalledWith("GET", "/_matrix/client/v3/account/whoami");
expect(auth).toMatchObject({
accountId: "ops",
homeserver: "https://matrix.example.org",

View File

@@ -1,12 +0,0 @@
export {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
normalizeOptionalAccountId,
} from "openclaw/plugin-sdk/account-id";
export { isPrivateOrLoopbackHost } from "../../runtime-api.js";
export {
assertHttpUrlTargetsPrivateNetwork,
ssrfPolicyFromAllowPrivateNetwork,
type LookupFn,
type SsrFPolicy,
} from "openclaw/plugin-sdk/ssrf-runtime";

View File

@@ -1 +0,0 @@
export { resolveConfiguredSecretInputString } from "openclaw/plugin-sdk/config-runtime";

View File

@@ -1,20 +1,14 @@
import {
coerceSecretRef,
resolveConfiguredSecretInputString,
} from "openclaw/plugin-sdk/config-runtime";
import type { PinnedDispatcherPolicy } from "openclaw/plugin-sdk/infra-runtime";
import { coerceSecretRef } from "openclaw/plugin-sdk/provider-auth";
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
import {
requiresExplicitMatrixDefaultAccount,
resolveMatrixDefaultOrOnlyAccountId,
} from "../../account-selection.js";
import { resolveMatrixAccountStringValues } from "../../auth-precedence.js";
import { getMatrixScopedEnvVarNames } from "../../env-vars.js";
import { getMatrixRuntime } from "../../runtime.js";
import type { CoreConfig } from "../../types.js";
import {
findMatrixAccountConfig,
resolveMatrixBaseConfig,
listNormalizedMatrixAccountIds,
} from "../account-config.js";
import { resolveMatrixConfigFieldPath } from "../config-paths.js";
import {
DEFAULT_ACCOUNT_ID,
assertHttpUrlTargetsPrivateNetwork,
@@ -22,8 +16,17 @@ import {
type LookupFn,
normalizeAccountId,
normalizeOptionalAccountId,
normalizeResolvedSecretInputString,
ssrfPolicyFromAllowPrivateNetwork,
} from "./config-runtime-api.js";
} from "../../runtime-api.js";
import { getMatrixRuntime } from "../../runtime.js";
import type { CoreConfig } from "../../types.js";
import {
findMatrixAccountConfig,
resolveMatrixBaseConfig,
listNormalizedMatrixAccountIds,
} from "../account-config.js";
import { resolveMatrixConfigFieldPath } from "../config-update.js";
import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
type MatrixAuthClientDeps = {
@@ -36,30 +39,10 @@ type MatrixCredentialsReadDeps = {
credentialsMatchConfig: typeof import("../credentials-read.js").credentialsMatchConfig;
};
type MatrixSecretInputDeps = {
resolveConfiguredSecretInputString: typeof import("./config-secret-input.runtime.js").resolveConfiguredSecretInputString;
};
let matrixAuthClientDepsPromise: Promise<MatrixAuthClientDeps> | undefined;
let matrixCredentialsReadDepsPromise: Promise<MatrixCredentialsReadDeps> | undefined;
let matrixSecretInputDepsPromise: Promise<MatrixSecretInputDeps> | undefined;
let matrixAuthClientDepsForTest: MatrixAuthClientDeps | undefined;
export function setMatrixAuthClientDepsForTest(
deps?:
| {
MatrixClient: typeof import("../sdk.js").MatrixClient;
ensureMatrixSdkLoggingConfigured: typeof import("./logging.js").ensureMatrixSdkLoggingConfigured;
}
| undefined,
): void {
matrixAuthClientDepsForTest = deps;
}
async function loadMatrixAuthClientDeps(): Promise<MatrixAuthClientDeps> {
if (matrixAuthClientDepsForTest) {
return matrixAuthClientDepsForTest;
}
matrixAuthClientDepsPromise ??= Promise.all([import("../sdk.js"), import("./logging.js")]).then(
([sdkModule, loggingModule]) => ({
MatrixClient: sdkModule.MatrixClient,
@@ -79,13 +62,6 @@ async function loadMatrixCredentialsReadDeps(): Promise<MatrixCredentialsReadDep
return await matrixCredentialsReadDepsPromise;
}
async function loadMatrixSecretInputDeps(): Promise<MatrixSecretInputDeps> {
matrixSecretInputDepsPromise ??= import("./config-secret-input.runtime.js").then((runtime) => ({
resolveConfiguredSecretInputString: runtime.resolveConfiguredSecretInputString,
}));
return await matrixSecretInputDepsPromise;
}
function readEnvSecretRefFallback(params: {
value: unknown;
env?: NodeJS.ProcessEnv;
@@ -282,7 +258,6 @@ async function resolveConfiguredMatrixAuthSecretInput(params: {
return undefined;
}
const { resolveConfiguredSecretInputString } = await loadMatrixSecretInputDeps();
const resolved = await resolveConfiguredSecretInputString({
config: params.cfg,
env: params.env,

View File

@@ -1,32 +1,15 @@
import fs from "node:fs";
import type { PinnedDispatcherPolicy } from "openclaw/plugin-sdk/infra-runtime";
import type { SsrFPolicy } from "../../runtime-api.js";
import type { MatrixClient } from "../sdk.js";
import { MatrixClient } from "../sdk.js";
import { resolveValidatedMatrixHomeserverUrl } from "./config.js";
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
import {
maybeMigrateLegacyStorage,
resolveMatrixStoragePaths,
writeStorageMeta,
} from "./storage.js";
type MatrixCreateClientRuntimeDeps = {
MatrixClient: typeof import("../sdk.js").MatrixClient;
ensureMatrixSdkLoggingConfigured: typeof import("./logging.js").ensureMatrixSdkLoggingConfigured;
};
let matrixCreateClientRuntimeDepsPromise: Promise<MatrixCreateClientRuntimeDeps> | undefined;
async function loadMatrixCreateClientRuntimeDeps(): Promise<MatrixCreateClientRuntimeDeps> {
matrixCreateClientRuntimeDepsPromise ??= Promise.all([
import("../sdk.js"),
import("./logging.js"),
]).then(([sdkModule, loggingModule]) => ({
MatrixClient: sdkModule.MatrixClient,
ensureMatrixSdkLoggingConfigured: loggingModule.ensureMatrixSdkLoggingConfigured,
}));
return await matrixCreateClientRuntimeDepsPromise;
}
export async function createMatrixClient(params: {
homeserver: string;
userId?: string;
@@ -42,8 +25,6 @@ export async function createMatrixClient(params: {
ssrfPolicy?: SsrFPolicy;
dispatcherPolicy?: PinnedDispatcherPolicy;
}): Promise<MatrixClient> {
const { MatrixClient, ensureMatrixSdkLoggingConfigured } =
await loadMatrixCreateClientRuntimeDeps();
ensureMatrixSdkLoggingConfigured();
const env = process.env;
const homeserver = await resolveValidatedMatrixHomeserverUrl(params.homeserver, {

View File

@@ -3,21 +3,9 @@ import type { CoreConfig } from "../../types.js";
import type { MatrixClient } from "../sdk.js";
import { LogService } from "../sdk/logger.js";
import { resolveMatrixAuth, resolveMatrixAuthContext } from "./config.js";
import { createMatrixClient } from "./create-client.js";
import type { MatrixAuth } from "./types.js";
type MatrixCreateClientDeps = {
createMatrixClient: typeof import("./create-client.js").createMatrixClient;
};
let matrixCreateClientDepsPromise: Promise<MatrixCreateClientDeps> | undefined;
async function loadMatrixCreateClientDeps(): Promise<MatrixCreateClientDeps> {
matrixCreateClientDepsPromise ??= import("./create-client.js").then((runtime) => ({
createMatrixClient: runtime.createMatrixClient,
}));
return await matrixCreateClientDepsPromise;
}
type SharedMatrixClientState = {
client: MatrixClient;
key: string;
@@ -50,7 +38,6 @@ async function createSharedMatrixClient(params: {
auth: MatrixAuth;
timeoutMs?: number;
}): Promise<SharedMatrixClientState> {
const { createMatrixClient } = await loadMatrixCreateClientDeps();
const client = await createMatrixClient({
homeserver: params.auth.homeserver,
userId: params.auth.userId,

View File

@@ -1,31 +0,0 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { CoreConfig } from "../types.js";
export function shouldStoreMatrixAccountAtTopLevel(cfg: CoreConfig, accountId: string): boolean {
const normalizedAccountId = normalizeAccountId(accountId);
if (normalizedAccountId !== DEFAULT_ACCOUNT_ID) {
return false;
}
const accounts = cfg.channels?.matrix?.accounts;
return !accounts || Object.keys(accounts).length === 0;
}
export function resolveMatrixConfigPath(cfg: CoreConfig, accountId: string): string {
const normalizedAccountId = normalizeAccountId(accountId);
if (shouldStoreMatrixAccountAtTopLevel(cfg, normalizedAccountId)) {
return "channels.matrix";
}
return `channels.matrix.accounts.${normalizedAccountId}`;
}
export function resolveMatrixConfigFieldPath(
cfg: CoreConfig,
accountId: string,
fieldPath: string,
): string {
const suffix = fieldPath.trim().replace(/^\.+/, "");
if (!suffix) {
return resolveMatrixConfigPath(cfg, accountId);
}
return `${resolveMatrixConfigPath(cfg, accountId)}.${suffix}`;
}

View File

@@ -1,19 +1,9 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { coerceSecretRef } from "openclaw/plugin-sdk/config-runtime";
import { normalizeSecretInputString } from "openclaw/plugin-sdk/setup";
import type { CoreConfig, MatrixConfig } from "../types.js";
import { findMatrixAccountConfig } from "./account-config.js";
import {
resolveMatrixConfigFieldPath,
resolveMatrixConfigPath,
shouldStoreMatrixAccountAtTopLevel,
} from "./config-paths.js";
export {
resolveMatrixConfigFieldPath,
resolveMatrixConfigPath,
shouldStoreMatrixAccountAtTopLevel,
} from "./config-paths.js";
export type MatrixAccountPatch = {
name?: string | null;
@@ -123,6 +113,35 @@ function applyNullableArrayField(
target[key] = [...value];
}
export function shouldStoreMatrixAccountAtTopLevel(cfg: CoreConfig, accountId: string): boolean {
const normalizedAccountId = normalizeAccountId(accountId);
if (normalizedAccountId !== DEFAULT_ACCOUNT_ID) {
return false;
}
const accounts = cfg.channels?.matrix?.accounts;
return !accounts || Object.keys(accounts).length === 0;
}
export function resolveMatrixConfigPath(cfg: CoreConfig, accountId: string): string {
const normalizedAccountId = normalizeAccountId(accountId);
if (shouldStoreMatrixAccountAtTopLevel(cfg, normalizedAccountId)) {
return "channels.matrix";
}
return `channels.matrix.accounts.${normalizedAccountId}`;
}
export function resolveMatrixConfigFieldPath(
cfg: CoreConfig,
accountId: string,
fieldPath: string,
): string {
const suffix = fieldPath.trim().replace(/^\.+/, "");
if (!suffix) {
return resolveMatrixConfigPath(cfg, accountId);
}
return `${resolveMatrixConfigPath(cfg, accountId)}.${suffix}`;
}
export function updateMatrixAccountConfig(
cfg: CoreConfig,
accountId: string,

View File

@@ -1,7 +1,7 @@
import { normalizeOptionalAccountId } from "openclaw/plugin-sdk/account-id";
import { resolveMatrixDefaultOrOnlyAccountId } from "../account-selection.js";
import type { CoreConfig } from "../types.js";
import { resolveMatrixConfigFieldPath } from "./config-paths.js";
import { resolveMatrixConfigFieldPath } from "./config-update.js";
export function resolveMatrixEncryptionConfigPath(
cfg: CoreConfig,

View File

@@ -1,11 +1,5 @@
import { describe, expect, it } from "vitest";
import { markdownToMatrixHtml, renderMarkdownToMatrixHtmlWithMentions } from "./format.js";
function createMentionClient(selfUserId = "@bot:example.org") {
return {
getUserId: async () => selfUserId,
} as unknown as import("./sdk.js").MatrixClient;
}
import { markdownToMatrixHtml } from "./format.js";
describe("markdownToMatrixHtml", () => {
it("renders basic inline formatting", () => {
@@ -49,236 +43,4 @@ describe("markdownToMatrixHtml", () => {
const html = markdownToMatrixHtml("line1\nline2");
expect(html).toContain("<br");
});
it("renders qualified Matrix user mentions as matrix.to links and m.mentions metadata", async () => {
const result = await renderMarkdownToMatrixHtmlWithMentions({
markdown: "hello @alice:example.org",
client: createMentionClient(),
});
expect(result.html).toContain('href="https://matrix.to/#/%40alice%3Aexample.org"');
expect(result.mentions).toEqual({
user_ids: ["@alice:example.org"],
});
});
it("url-encodes matrix.to hrefs for valid mxids with path characters", async () => {
const result = await renderMarkdownToMatrixHtmlWithMentions({
markdown: "hello @foo/bar:example.org",
client: createMentionClient(),
});
expect(result.html).toContain('href="https://matrix.to/#/%40foo%2Fbar%3Aexample.org"');
expect(result.mentions).toEqual({
user_ids: ["@foo/bar:example.org"],
});
});
it("treats mxids that begin with room as user mentions", async () => {
const result = await renderMarkdownToMatrixHtmlWithMentions({
markdown: "hello @room:example.org",
client: createMentionClient(),
});
expect(result.html).toContain('href="https://matrix.to/#/%40room%3Aexample.org"');
expect(result.mentions).toEqual({
user_ids: ["@room:example.org"],
});
});
it("treats hyphenated room-prefixed mxids as user mentions", async () => {
const result = await renderMarkdownToMatrixHtmlWithMentions({
markdown: "hello @room-admin:example.org",
client: createMentionClient(),
});
expect(result.html).toContain('href="https://matrix.to/#/%40room-admin%3Aexample.org"');
expect(result.mentions).toEqual({
user_ids: ["@room-admin:example.org"],
});
});
it("keeps explicit room mentions as room mentions", async () => {
const result = await renderMarkdownToMatrixHtmlWithMentions({
markdown: "hello @room",
client: createMentionClient(),
});
expect(result.html).toContain("@room");
expect(result.html).not.toContain("matrix.to");
expect(result.mentions).toEqual({
room: true,
});
});
it("treats sentence-ending room mentions as room mentions", async () => {
const result = await renderMarkdownToMatrixHtmlWithMentions({
markdown: "hello @room.",
client: createMentionClient(),
});
expect(result.html).toContain("hello @room.");
expect(result.html).not.toContain("matrix.to");
expect(result.mentions).toEqual({
room: true,
});
});
it("treats colon-suffixed room mentions as room mentions", async () => {
const result = await renderMarkdownToMatrixHtmlWithMentions({
markdown: "hello @room:",
client: createMentionClient(),
});
expect(result.html).toContain("hello @room:");
expect(result.html).not.toContain("matrix.to");
expect(result.mentions).toEqual({
room: true,
});
});
it("trims punctuation before storing mentioned user ids", async () => {
const result = await renderMarkdownToMatrixHtmlWithMentions({
markdown: "hello @alice:example.org.",
client: createMentionClient(),
});
expect(result.html).toContain('href="https://matrix.to/#/%40alice%3Aexample.org"');
expect(result.html).toContain("@alice:example.org</a>.");
expect(result.mentions).toEqual({
user_ids: ["@alice:example.org"],
});
});
it("does not emit mentions for mxid-like tokens with path suffixes", async () => {
const result = await renderMarkdownToMatrixHtmlWithMentions({
markdown: "hello @alice:example.org/path",
client: createMentionClient(),
});
expect(result.html).toContain("@alice:example.org/path");
expect(result.html).not.toContain("matrix.to");
expect(result.mentions).toEqual({});
});
it("accepts bracketed homeservers in matrix mentions", async () => {
const result = await renderMarkdownToMatrixHtmlWithMentions({
markdown: "hello @alice:[2001:db8::1]",
client: createMentionClient(),
});
expect(result.html).toContain('href="https://matrix.to/#/%40alice%3A%5B2001%3Adb8%3A%3A1%5D"');
expect(result.mentions).toEqual({
user_ids: ["@alice:[2001:db8::1]"],
});
});
it("accepts bracketed homeservers with ports in matrix mentions", async () => {
const result = await renderMarkdownToMatrixHtmlWithMentions({
markdown: "hello @alice:[2001:db8::1]:8448.",
client: createMentionClient(),
});
expect(result.html).toContain(
'href="https://matrix.to/#/%40alice%3A%5B2001%3Adb8%3A%3A1%5D%3A8448"',
);
expect(result.html).toContain("@alice:[2001:db8::1]:8448</a>.");
expect(result.mentions).toEqual({
user_ids: ["@alice:[2001:db8::1]:8448"],
});
});
it("leaves bare localpart text unmentioned", async () => {
const result = await renderMarkdownToMatrixHtmlWithMentions({
markdown: "hello @alice",
client: createMentionClient(),
});
expect(result.html).not.toContain("matrix.to");
expect(result.mentions).toEqual({});
});
it("does not convert escaped qualified mentions", async () => {
const result = await renderMarkdownToMatrixHtmlWithMentions({
markdown: "\\@alice:example.org",
client: createMentionClient(),
});
expect(result.html).toContain("@alice:example.org");
expect(result.html).not.toContain("matrix.to");
expect(result.mentions).toEqual({});
});
it("does not convert escaped room mentions", async () => {
const result = await renderMarkdownToMatrixHtmlWithMentions({
markdown: "\\@room",
client: createMentionClient(),
});
expect(result.html).toContain("@room");
expect(result.mentions).toEqual({});
});
it("keeps escaped mentions literal after escaped backticks", async () => {
const result = await renderMarkdownToMatrixHtmlWithMentions({
markdown: "\\`literal then \\@alice:example.org",
client: createMentionClient(),
});
expect(result.html).toContain("`literal then @alice:example.org");
expect(result.html).not.toContain("matrix.to");
expect(result.mentions).toEqual({});
});
it("restores escaped mentions in markdown link labels without linking them", async () => {
const result = await renderMarkdownToMatrixHtmlWithMentions({
markdown: "[\\@alice:example.org](https://example.com)",
client: createMentionClient(),
});
expect(result.html).toContain('<a href="https://example.com">@alice:example.org</a>');
expect(result.html).not.toContain("matrix.to");
expect(result.mentions).toEqual({});
});
it("keeps backslashes inside code spans", async () => {
const result = await renderMarkdownToMatrixHtmlWithMentions({
markdown: "`\\@alice:example.org`",
client: createMentionClient(),
});
expect(result.html).toContain("<code>\\@alice:example.org</code>");
expect(result.mentions).toEqual({});
});
it("does not convert mentions inside code spans", async () => {
const result = await renderMarkdownToMatrixHtmlWithMentions({
markdown: "`@alice:example.org`",
client: createMentionClient(),
});
expect(result.html).toContain("<code>@alice:example.org</code>");
expect(result.html).not.toContain("matrix.to");
expect(result.mentions).toEqual({});
});
it("keeps backslashes inside tilde fenced code blocks", async () => {
const result = await renderMarkdownToMatrixHtmlWithMentions({
markdown: "~~~\n\\@alice:example.org\n~~~",
client: createMentionClient(),
});
expect(result.html).toContain("<pre><code>\\@alice:example.org\n</code></pre>");
expect(result.mentions).toEqual({});
});
it("keeps backslashes inside indented code blocks", async () => {
const result = await renderMarkdownToMatrixHtmlWithMentions({
markdown: " \\@alice:example.org",
client: createMentionClient(),
});
expect(result.html).toContain("<pre><code>\\@alice:example.org\n</code></pre>");
expect(result.mentions).toEqual({});
});
});

View File

@@ -1,7 +1,5 @@
import MarkdownIt from "markdown-it";
import { isAutoLinkedFileRef } from "openclaw/plugin-sdk/text-runtime";
import type { MatrixClient } from "./sdk.js";
import { isMatrixQualifiedUserId } from "./target-ids.js";
const md = new MarkdownIt({
html: false,
@@ -13,28 +11,6 @@ const md = new MarkdownIt({
md.enable("strikethrough");
const { escapeHtml } = md.utils;
export type MatrixMentions = {
room?: boolean;
user_ids?: string[];
};
type MarkdownToken = ReturnType<typeof md.parse>[number];
type MarkdownInlineToken = NonNullable<MarkdownToken["children"]>[number];
type MatrixMentionCandidate = {
raw: string;
start: number;
end: number;
kind: "room" | "user";
userId?: string;
};
const ESCAPED_MENTION_SENTINEL = "\uE000";
const MENTION_PATTERN = /@[A-Za-z0-9._=+\-/:\[\]]+/g;
const MATRIX_MENTION_USER_ID_PATTERN =
/^@[A-Za-z0-9._=+\-/]+:(?:[A-Za-z0-9.-]+|\[[0-9A-Fa-f:.]+\])(?::\d+)?$/;
const TRIMMABLE_MENTION_SUFFIX = /[),.!?:;\]]/;
function shouldSuppressAutoLink(
tokens: Parameters<NonNullable<typeof md.renderer.rules.link_open>>[0],
idx: number,
@@ -62,313 +38,7 @@ md.renderer.rules.link_close = (tokens, idx, _options, _env, self) => {
return self.renderToken(tokens, idx, _options);
};
function maskEscapedMentions(markdown: string): string {
let masked = "";
let idx = 0;
let codeFenceLength = 0;
while (idx < markdown.length) {
if (markdown[idx] === "`" && !isMarkdownEscaped(markdown, idx)) {
let runLength = 1;
while (markdown[idx + runLength] === "`") {
runLength += 1;
}
if (codeFenceLength === 0) {
codeFenceLength = runLength;
} else if (runLength === codeFenceLength) {
codeFenceLength = 0;
}
masked += markdown.slice(idx, idx + runLength);
idx += runLength;
continue;
}
if (codeFenceLength === 0 && markdown[idx] === "\\" && markdown[idx + 1] === "@") {
masked += ESCAPED_MENTION_SENTINEL;
idx += 2;
continue;
}
masked += markdown[idx] ?? "";
idx += 1;
}
return masked;
}
function isMarkdownEscaped(markdown: string, idx: number): boolean {
let slashCount = 0;
let cursor = idx - 1;
while (cursor >= 0 && markdown[cursor] === "\\") {
slashCount += 1;
cursor -= 1;
}
return slashCount % 2 === 1;
}
function restoreEscapedMentions(text: string): string {
return text.replaceAll(ESCAPED_MENTION_SENTINEL, "@");
}
function restoreEscapedMentionsInCode(text: string): string {
return text.replaceAll(ESCAPED_MENTION_SENTINEL, "\\@");
}
function restoreEscapedMentionsInBlockTokens(tokens: MarkdownToken[]): void {
for (const token of tokens) {
if ((token.type === "fence" || token.type === "code_block") && token.content) {
token.content = restoreEscapedMentionsInCode(token.content);
}
}
}
function isMentionStartBoundary(charBefore: string | undefined): boolean {
return !charBefore || !/[A-Za-z0-9_]/.test(charBefore);
}
function trimMentionSuffix(raw: string, end: number): { raw: string; end: number } | null {
while (raw.length > 1 && TRIMMABLE_MENTION_SUFFIX.test(raw.at(-1) ?? "")) {
if (raw.at(-1) === "]" && /\[[0-9A-Fa-f:.]+\](?::\d+)?$/i.test(raw)) {
break;
}
raw = raw.slice(0, -1);
end -= 1;
}
if (!raw.startsWith("@") || raw === "@") {
return null;
}
return { raw, end };
}
function isMatrixMentionUserId(raw: string): boolean {
return isMatrixQualifiedUserId(raw) && MATRIX_MENTION_USER_ID_PATTERN.test(raw);
}
function buildMentionCandidate(raw: string, start: number): MatrixMentionCandidate | null {
const normalized = trimMentionSuffix(raw, start + raw.length);
if (!normalized) {
return null;
}
const kind = normalized.raw.toLowerCase() === "@room" ? "room" : "user";
const base: MatrixMentionCandidate = {
raw: normalized.raw,
start,
end: normalized.end,
kind,
};
if (kind === "room") {
return base;
}
const userCandidate = isMatrixMentionUserId(normalized.raw)
? { ...base, userId: normalized.raw }
: null;
if (!userCandidate) {
return null;
}
return userCandidate;
}
function collectMentionCandidates(text: string): MatrixMentionCandidate[] {
const mentions: MatrixMentionCandidate[] = [];
for (const match of text.matchAll(MENTION_PATTERN)) {
const raw = match[0];
const start = match.index ?? -1;
if (start < 0 || !raw) {
continue;
}
if (!isMentionStartBoundary(text[start - 1])) {
continue;
}
const candidate = buildMentionCandidate(raw, start);
if (!candidate) {
continue;
}
mentions.push(candidate);
}
return mentions;
}
function createToken(
sample: MarkdownInlineToken,
type: string,
tag: string,
nesting: number,
): MarkdownInlineToken {
const TokenCtor = sample.constructor as new (
type: string,
tag: string,
nesting: number,
) => MarkdownInlineToken;
return new TokenCtor(type, tag, nesting);
}
function createTextToken(sample: MarkdownInlineToken, content: string): MarkdownInlineToken {
const token = createToken(sample, "text", "", 0);
token.content = content;
return token;
}
function createMentionLinkTokens(params: {
sample: MarkdownInlineToken;
href: string;
label: string;
}): MarkdownInlineToken[] {
const open = createToken(params.sample, "link_open", "a", 1);
open.attrSet("href", params.href);
const text = createTextToken(params.sample, params.label);
const close = createToken(params.sample, "link_close", "a", -1);
return [open, text, close];
}
function resolveMentionUserId(match: MatrixMentionCandidate): string | null {
if (match.kind !== "user") {
return null;
}
return match.userId ?? null;
}
async function resolveMatrixSelfUserId(client: MatrixClient): Promise<string | null> {
const getUserId = (client as { getUserId?: () => Promise<string> | string }).getUserId;
if (typeof getUserId !== "function") {
return null;
}
return await Promise.resolve(getUserId.call(client)).catch(() => null);
}
function mutateInlineTokensWithMentions(params: {
children: MarkdownInlineToken[];
userIds: string[];
seenUserIds: Set<string>;
selfUserId: string | null;
}): { children: MarkdownInlineToken[]; roomMentioned: boolean } {
const nextChildren: MarkdownInlineToken[] = [];
let roomMentioned = false;
let insideLinkDepth = 0;
for (const child of params.children) {
if (child.type === "link_open") {
insideLinkDepth += 1;
nextChildren.push(child);
continue;
}
if (child.type === "link_close") {
insideLinkDepth = Math.max(0, insideLinkDepth - 1);
nextChildren.push(child);
continue;
}
if (child.type !== "text" || !child.content) {
nextChildren.push(child);
continue;
}
const visibleContent = restoreEscapedMentions(child.content);
if (insideLinkDepth > 0) {
nextChildren.push(createTextToken(child, visibleContent));
continue;
}
const matches = collectMentionCandidates(child.content);
if (matches.length === 0) {
nextChildren.push(createTextToken(child, visibleContent));
continue;
}
let cursor = 0;
for (const match of matches) {
if (match.start > cursor) {
nextChildren.push(
createTextToken(child, restoreEscapedMentions(child.content.slice(cursor, match.start))),
);
}
cursor = match.end;
if (match.kind === "room") {
roomMentioned = true;
nextChildren.push(createTextToken(child, match.raw));
continue;
}
const resolvedUserId = resolveMentionUserId(match);
if (!resolvedUserId || resolvedUserId === params.selfUserId) {
nextChildren.push(createTextToken(child, match.raw));
continue;
}
if (!params.seenUserIds.has(resolvedUserId)) {
params.seenUserIds.add(resolvedUserId);
params.userIds.push(resolvedUserId);
}
nextChildren.push(
...createMentionLinkTokens({
sample: child,
href: `https://matrix.to/#/${encodeURIComponent(resolvedUserId)}`,
label: match.raw,
}),
);
}
if (cursor < child.content.length) {
nextChildren.push(
createTextToken(child, restoreEscapedMentions(child.content.slice(cursor))),
);
}
}
return { children: nextChildren, roomMentioned };
}
export function markdownToMatrixHtml(markdown: string): string {
const rendered = md.render(markdown ?? "");
return rendered.trimEnd();
}
async function resolveMarkdownMentionState(params: {
markdown: string;
client: MatrixClient;
}): Promise<{ tokens: MarkdownToken[]; mentions: MatrixMentions }> {
const markdown = maskEscapedMentions(params.markdown ?? "");
const tokens = md.parse(markdown, {});
restoreEscapedMentionsInBlockTokens(tokens);
const selfUserId = await resolveMatrixSelfUserId(params.client);
const userIds: string[] = [];
const seenUserIds = new Set<string>();
let roomMentioned = false;
for (const token of tokens) {
if (!token.children?.length) {
continue;
}
const mutated = mutateInlineTokensWithMentions({
children: token.children,
userIds,
seenUserIds,
selfUserId,
});
token.children = mutated.children;
roomMentioned ||= mutated.roomMentioned;
}
const mentions: MatrixMentions = {};
if (userIds.length > 0) {
mentions.user_ids = userIds;
}
if (roomMentioned) {
mentions.room = true;
}
return {
tokens,
mentions,
};
}
export async function resolveMatrixMentionsInMarkdown(params: {
markdown: string;
client: MatrixClient;
}): Promise<MatrixMentions> {
const state = await resolveMarkdownMentionState(params);
return state.mentions;
}
export async function renderMarkdownToMatrixHtmlWithMentions(params: {
markdown: string;
client: MatrixClient;
}): Promise<{ html?: string; mentions: MatrixMentions }> {
const state = await resolveMarkdownMentionState(params);
const html = md.renderer.render(state.tokens, md.options, {}).trimEnd();
return {
html: html || undefined,
mentions: state.mentions,
};
}

View File

@@ -1,6 +1,5 @@
import { resolveMatrixTargets } from "../../resolve-targets.js";
import type { CoreConfig, MatrixRoomConfig } from "../../types.js";
import { isMatrixQualifiedUserId } from "../target-ids.js";
import { normalizeMatrixUserId } from "./allowlist.js";
import {
addAllowlistUserEntriesFromConfigEntry,
@@ -28,6 +27,10 @@ function normalizeMatrixRoomLookupEntry(raw: string): string {
.trim();
}
function isMatrixQualifiedUserId(value: string): boolean {
return value.startsWith("@") && value.includes(":");
}
function filterResolvedMatrixAllowlistEntries(entries: string[]): string[] {
return entries.filter((entry) => {
const trimmed = entry.trim();

View File

@@ -1553,21 +1553,11 @@ describe("matrix monitor handler draft streaming", () => {
) => Promise<void>;
type ReplyOpts = {
onPartialReply?: (payload: { text: string }) => void;
onBlockReplyQueued?: (
payload: {
text?: string;
isCompactionNotice?: boolean;
},
context?: { assistantMessageIndex?: number },
) => Promise<void> | void;
onAssistantMessageStart?: () => void;
disableBlockStreaming?: boolean;
};
function createStreamingHarness(opts?: {
replyToMode?: "off" | "first" | "all";
blockStreamingEnabled?: boolean;
}) {
function createStreamingHarness(opts?: { replyToMode?: "off" | "first" | "all" }) {
let capturedDeliver: DeliverFn | undefined;
let capturedReplyOpts: ReplyOpts | undefined;
// Gate that keeps the handler's model run alive until the test releases it.
@@ -1587,7 +1577,6 @@ describe("matrix monitor handler draft streaming", () => {
const { handler } = createMatrixHandlerTestHarness({
streaming: "partial",
blockStreamingEnabled: opts?.blockStreamingEnabled ?? false,
replyToMode: opts?.replyToMode ?? "off",
client: { redactEvent: redactEventMock },
createReplyDispatcherWithTyping: (params: Record<string, unknown> | undefined) => {
@@ -1646,133 +1635,6 @@ describe("matrix monitor handler draft streaming", () => {
return { dispatch, redactEventMock };
}
it("finalizes a single partial-preview block in place when block streaming is enabled", async () => {
const { dispatch, redactEventMock } = createStreamingHarness({ blockStreamingEnabled: true });
const { deliver, opts, finish } = await dispatch();
opts.onPartialReply?.({ text: "Single block" });
await vi.waitFor(() => {
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
});
deliverMatrixRepliesMock.mockClear();
await deliver({ text: "Single block" }, { kind: "final" });
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
expect(deliverMatrixRepliesMock).not.toHaveBeenCalled();
expect(redactEventMock).not.toHaveBeenCalled();
await finish();
});
it("preserves completed blocks by rotating to a new draft when block streaming is enabled", async () => {
const { dispatch, redactEventMock } = createStreamingHarness({ blockStreamingEnabled: true });
const { deliver, opts, finish } = await dispatch();
opts.onPartialReply?.({ text: "Block one" });
await vi.waitFor(() => {
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
});
deliverMatrixRepliesMock.mockClear();
await deliver({ text: "Block one" }, { kind: "block" });
expect(deliverMatrixRepliesMock).not.toHaveBeenCalled();
expect(redactEventMock).not.toHaveBeenCalled();
opts.onAssistantMessageStart?.();
sendSingleTextMessageMatrixMock.mockResolvedValueOnce({
messageId: "$draft2",
roomId: "!room",
});
opts.onPartialReply?.({ text: "Block two" });
await vi.waitFor(() => {
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(2);
});
await deliver({ text: "Block two" }, { kind: "final" });
expect(deliverMatrixRepliesMock).not.toHaveBeenCalled();
expect(redactEventMock).not.toHaveBeenCalled();
await finish();
});
it("queues late partials behind block-boundary rotation", async () => {
const { dispatch, redactEventMock } = createStreamingHarness({ blockStreamingEnabled: true });
const { deliver, opts, finish } = await dispatch();
opts.onPartialReply?.({ text: "Alpha" });
await vi.waitFor(() => {
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
});
await opts.onBlockReplyQueued?.({ text: "Alpha" });
sendSingleTextMessageMatrixMock.mockResolvedValueOnce({
messageId: "$draft2",
roomId: "!room",
});
opts.onPartialReply?.({ text: "AlphaBeta" });
// The next block must not update the previous block's draft while the
// prior block delivery is still draining.
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
expect(editMessageMatrixMock).not.toHaveBeenCalled();
await deliver({ text: "Alpha" }, { kind: "block" });
await vi.waitFor(() => {
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(2);
});
expect(sendSingleTextMessageMatrixMock.mock.calls[1]?.[1]).toBe("Beta");
expect(deliverMatrixRepliesMock).not.toHaveBeenCalled();
expect(redactEventMock).not.toHaveBeenCalled();
await finish();
});
it("keeps delayed same-message block boundaries at the emitted block length", async () => {
const { dispatch, redactEventMock } = createStreamingHarness({ blockStreamingEnabled: true });
const { deliver, opts, finish } = await dispatch();
opts.onPartialReply?.({ text: "Alpha" });
await vi.waitFor(() => {
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
});
opts.onPartialReply?.({ text: "AlphaBeta" });
await vi.waitFor(() => {
expect(editMessageMatrixMock).toHaveBeenCalledWith(
"!room:example.org",
"$draft1",
"AlphaBeta",
expect.anything(),
);
});
await opts.onBlockReplyQueued?.({ text: "Alpha" });
sendSingleTextMessageMatrixMock.mockClear();
editMessageMatrixMock.mockClear();
sendSingleTextMessageMatrixMock.mockResolvedValueOnce({
messageId: "$draft2",
roomId: "!room",
});
await deliver({ text: "Alpha" }, { kind: "block" });
await vi.waitFor(() => {
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
});
expect(sendSingleTextMessageMatrixMock.mock.calls[0]?.[1]).toBe("Beta");
expect(editMessageMatrixMock).toHaveBeenCalledWith(
"!room:example.org",
"$draft1",
"Alpha",
expect.anything(),
);
expect(deliverMatrixRepliesMock).not.toHaveBeenCalled();
expect(redactEventMock).not.toHaveBeenCalled();
await finish();
});
it("falls back to deliverMatrixReplies when final edit fails", async () => {
const { dispatch } = createStreamingHarness();
const { deliver, opts, finish } = await dispatch();
@@ -1820,7 +1682,7 @@ describe("matrix monitor handler draft streaming", () => {
}
});
it("resets draft block offsets on assistant message start", async () => {
it("resets materializedTextLength on assistant message start", async () => {
const { dispatch } = createStreamingHarness();
const { deliver, opts, finish } = await dispatch();
@@ -1852,152 +1714,6 @@ describe("matrix monitor handler draft streaming", () => {
await finish();
});
it("preserves queued block boundaries across assistant message start", async () => {
const { dispatch, redactEventMock } = createStreamingHarness({ blockStreamingEnabled: true });
const { deliver, opts, finish } = await dispatch();
opts.onPartialReply?.({ text: "Alpha" });
await vi.waitFor(() => {
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
});
await opts.onBlockReplyQueued?.({ text: "Alpha" });
opts.onAssistantMessageStart?.();
opts.onPartialReply?.({ text: "Beta" });
await vi.waitFor(() => {
expect(editMessageMatrixMock).toHaveBeenCalledWith(
"!room:example.org",
"$draft1",
"Beta",
expect.anything(),
);
});
sendSingleTextMessageMatrixMock.mockClear();
editMessageMatrixMock.mockClear();
sendSingleTextMessageMatrixMock.mockResolvedValueOnce({
messageId: "$draft2",
roomId: "!room",
});
await deliver({ text: "Alpha" }, { kind: "block" });
expect(editMessageMatrixMock).toHaveBeenCalledWith(
"!room:example.org",
"$draft1",
"Alpha",
expect.anything(),
);
await vi.waitFor(() => {
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
});
expect(sendSingleTextMessageMatrixMock.mock.calls[0]?.[1]).toBe("Beta");
await deliver({ text: "Beta" }, { kind: "final" });
expect(deliverMatrixRepliesMock).not.toHaveBeenCalled();
expect(redactEventMock).not.toHaveBeenCalled();
await finish();
});
it("queues late block boundaries against the source assistant message", async () => {
const { dispatch, redactEventMock } = createStreamingHarness({ blockStreamingEnabled: true });
const { deliver, opts, finish } = await dispatch();
opts.onAssistantMessageStart?.();
opts.onPartialReply?.({ text: "Alpha" });
await vi.waitFor(() => {
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
});
opts.onAssistantMessageStart?.();
await opts.onBlockReplyQueued?.({ text: "Alpha" }, { assistantMessageIndex: 1 });
opts.onPartialReply?.({ text: "Beta" });
await vi.waitFor(() => {
expect(editMessageMatrixMock).toHaveBeenCalledWith(
"!room:example.org",
"$draft1",
"Beta",
expect.anything(),
);
});
sendSingleTextMessageMatrixMock.mockClear();
editMessageMatrixMock.mockClear();
sendSingleTextMessageMatrixMock.mockResolvedValueOnce({
messageId: "$draft2",
roomId: "!room",
});
await deliver({ text: "Alpha" }, { kind: "block" });
expect(editMessageMatrixMock).toHaveBeenCalledWith(
"!room:example.org",
"$draft1",
"Alpha",
expect.anything(),
);
await vi.waitFor(() => {
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
});
expect(sendSingleTextMessageMatrixMock.mock.calls[0]?.[1]).toBe("Beta");
await deliver({ text: "Beta" }, { kind: "final" });
expect(deliverMatrixRepliesMock).not.toHaveBeenCalled();
expect(redactEventMock).not.toHaveBeenCalled();
await finish();
});
it("keeps queued block boundaries ordered while Matrix deliveries drain", async () => {
const { dispatch } = createStreamingHarness({ blockStreamingEnabled: true });
const { deliver, opts, finish } = await dispatch();
opts.onPartialReply?.({ text: "Alpha" });
await vi.waitFor(() => {
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
});
expect(sendSingleTextMessageMatrixMock.mock.calls[0]?.[1]).toBe("Alpha");
await opts.onBlockReplyQueued?.({ text: "Alpha" });
opts.onPartialReply?.({ text: "AlphaBeta" });
await opts.onBlockReplyQueued?.({ text: "Beta" });
opts.onPartialReply?.({ text: "AlphaBetaGamma" });
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
expect(editMessageMatrixMock).not.toHaveBeenCalled();
sendSingleTextMessageMatrixMock.mockClear();
editMessageMatrixMock.mockClear();
sendSingleTextMessageMatrixMock.mockResolvedValueOnce({
messageId: "$draft2",
roomId: "!room",
});
await deliver({ text: "Alpha" }, { kind: "block" });
await vi.waitFor(() => {
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
});
expect(sendSingleTextMessageMatrixMock.mock.calls[0]?.[1]).toBe("Beta");
expect(editMessageMatrixMock).not.toHaveBeenCalled();
sendSingleTextMessageMatrixMock.mockClear();
editMessageMatrixMock.mockClear();
sendSingleTextMessageMatrixMock.mockResolvedValueOnce({
messageId: "$draft3",
roomId: "!room",
});
await deliver({ text: "Beta" }, { kind: "block" });
await vi.waitFor(() => {
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
});
expect(sendSingleTextMessageMatrixMock.mock.calls[0]?.[1]).toBe("Gamma");
expect(editMessageMatrixMock).not.toHaveBeenCalled();
await finish();
});
it("stops draft stream on handler error (no leaked timer)", async () => {
vi.useFakeTimers();
try {
@@ -2133,35 +1849,6 @@ describe("matrix monitor handler draft streaming", () => {
expect(deliverMatrixRepliesMock).toHaveBeenCalledTimes(1);
await finish();
});
it("redacts stale draft and sends the final once when a later preview exceeds the event limit", async () => {
const { dispatch, redactEventMock } = createStreamingHarness();
const { deliver, opts, finish } = await dispatch();
opts.onPartialReply?.({ text: "1234" });
await vi.waitFor(() => {
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
});
prepareMatrixSingleTextMock.mockImplementation((text: string) => {
const trimmedText = text.trim();
return {
trimmedText,
convertedText: trimmedText,
singleEventLimit: 5,
fitsInSingleEvent: trimmedText.length <= 5,
};
});
opts.onPartialReply?.({ text: "123456" });
await deliver({ text: "123456" }, { kind: "final" });
expect(editMessageMatrixMock).not.toHaveBeenCalled();
expect(redactEventMock).toHaveBeenCalledWith("!room:example.org", "$draft1");
expect(deliverMatrixRepliesMock).toHaveBeenCalledTimes(1);
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
await finish();
});
});
describe("matrix monitor handler block streaming config", () => {
@@ -2186,7 +1873,7 @@ describe("matrix monitor handler block streaming config", () => {
expect(capturedDisableBlockStreaming).toBe(true);
});
it("keeps block streaming disabled when partial previews are on and block streaming is off", async () => {
it("disables shared block streaming when draft streaming is partial", async () => {
let capturedDisableBlockStreaming: boolean | undefined;
const { handler } = createMatrixHandlerTestHarness({
@@ -2207,7 +1894,7 @@ describe("matrix monitor handler block streaming config", () => {
expect(capturedDisableBlockStreaming).toBe(true);
});
it("allows shared block streaming when partial previews and block streaming are both enabled", async () => {
it("keeps draft streaming authoritative when partial and block streaming are both enabled", async () => {
let capturedDisableBlockStreaming: boolean | undefined;
const { handler } = createMatrixHandlerTestHarness({
@@ -2226,7 +1913,7 @@ describe("matrix monitor handler block streaming config", () => {
createMatrixTextMessageEvent({ eventId: "$msg1", body: "hello" }),
);
expect(capturedDisableBlockStreaming).toBe(false);
expect(capturedDisableBlockStreaming).toBe(true);
});
it("uses shared block streaming when explicitly enabled for Matrix", async () => {

View File

@@ -45,7 +45,6 @@ import {
getAgentScopedMediaLocalRoots,
logInboundDrop,
logTypingFailure,
type BlockReplyContext,
type PluginRuntime,
type ReplyPayload,
type RuntimeEnv,
@@ -1146,81 +1145,14 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
})
: undefined;
draftStreamRef = draftStream;
type PendingDraftBoundary = {
messageGeneration: number;
endOffset: number;
};
// Track the current draft block start plus any queued block-end offsets
// inside the model's cumulative partial text so multiple block
// boundaries can drain in order even when Matrix delivery lags behind.
let currentDraftMessageGeneration = 0;
let currentDraftBlockOffset = 0;
let latestDraftFullText = "";
const pendingDraftBoundaries: PendingDraftBoundary[] = [];
const latestQueuedDraftBoundaryOffsets = new Map<number, number>();
// Track how much of the full accumulated text has been materialized
// (delivered) so each new block only streams the new portion.
let materializedTextLength = 0;
let lastPartialFullTextLength = 0;
// Set after the first final payload consumes the draft event so
// subsequent finals go through normal delivery.
let draftConsumed = false;
const getDisplayableDraftText = () => {
const nextDraftBoundaryOffset = pendingDraftBoundaries.find(
(boundary) => boundary.messageGeneration === currentDraftMessageGeneration,
)?.endOffset;
if (nextDraftBoundaryOffset === undefined) {
return latestDraftFullText.slice(currentDraftBlockOffset);
}
return latestDraftFullText.slice(currentDraftBlockOffset, nextDraftBoundaryOffset);
};
const updateDraftFromLatestFullText = () => {
const blockText = getDisplayableDraftText();
if (blockText) {
draftStream?.update(blockText);
}
};
const queueDraftBlockBoundary = (payload: ReplyPayload, context?: BlockReplyContext) => {
const payloadTextLength = payload.text?.length ?? 0;
const messageGeneration = context?.assistantMessageIndex ?? currentDraftMessageGeneration;
const lastQueuedDraftBoundaryOffset =
latestQueuedDraftBoundaryOffsets.get(messageGeneration) ?? 0;
// Logical block boundaries must follow emitted block text, not whichever
// later partial preview has already arrived by the time the async
// boundary callback drains.
const nextDraftBoundaryOffset = lastQueuedDraftBoundaryOffset + payloadTextLength;
latestQueuedDraftBoundaryOffsets.set(messageGeneration, nextDraftBoundaryOffset);
pendingDraftBoundaries.push({
messageGeneration,
endOffset: nextDraftBoundaryOffset,
});
};
const advanceDraftBlockBoundary = (options?: { fallbackToLatestEnd?: boolean }) => {
const completedBoundary = pendingDraftBoundaries.shift();
if (completedBoundary) {
if (
!pendingDraftBoundaries.some(
(entry) => entry.messageGeneration === completedBoundary.messageGeneration,
)
) {
latestQueuedDraftBoundaryOffsets.delete(completedBoundary.messageGeneration);
}
if (completedBoundary.messageGeneration === currentDraftMessageGeneration) {
currentDraftBlockOffset = completedBoundary.endOffset;
}
return;
}
if (options?.fallbackToLatestEnd) {
currentDraftBlockOffset = latestDraftFullText.length;
}
};
const resetDraftBlockOffsets = () => {
currentDraftMessageGeneration += 1;
currentDraftBlockOffset = 0;
latestDraftFullText = "";
};
const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } =
core.channel.reply.createReplyDispatcherWithTyping({
...prefixOptions,
@@ -1369,11 +1301,10 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
// the stream must stay stopped so late async callbacks cannot
// create ghost messages.
if (info.kind === "block") {
materializedTextLength = lastPartialFullTextLength;
draftConsumed = false;
advanceDraftBlockBoundary({ fallbackToLatestEnd: true });
draftStream.reset();
currentDraftReplyToId = replyToMode === "all" ? draftReplyToId : undefined;
updateDraftFromLatestFullText();
// Re-assert typing so the user still sees the indicator while
// the next block generates.
@@ -1401,9 +1332,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
} else {
nonFinalReplyDeliveryFailed = true;
}
if (info.kind === "block") {
advanceDraftBlockBoundary({ fallbackToLatestEnd: true });
}
runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`);
},
onReplyStart: typingCallbacks.onReplyStart,
@@ -1424,31 +1352,28 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
replyOptions: {
...replyOptions,
skillFilter: roomConfig?.skills,
// Keep block streaming enabled when explicitly requested, even
// with draft previews on. The draft remains the live preview
// for the current assistant block, while block deliveries
// finalize completed blocks into their own preserved events.
disableBlockStreaming: !blockStreamingEnabled,
// Matrix expects explicit assistant progress updates as
// separate messages only when block streaming is explicitly
// enabled. Partial draft streaming still disables the shared
// block pipeline so draft edits do not double-send.
disableBlockStreaming: draftStream ? true : !blockStreamingEnabled,
onPartialReply: draftStream
? (payload) => {
latestDraftFullText = payload.text ?? "";
updateDraftFromLatestFullText();
}
: undefined,
onBlockReplyQueued: draftStream
? (payload, context) => {
if (payload.isCompactionNotice === true) {
return;
const fullText = payload.text ?? "";
lastPartialFullTextLength = fullText.length;
const blockText = fullText.slice(materializedTextLength);
if (blockText) {
draftStream.update(blockText);
}
queueDraftBlockBoundary(payload, context);
}
: undefined,
// Reset draft boundary bookkeeping on assistant message
// boundaries so post-tool blocks stream from a fresh
// cumulative payload (payload.text resets upstream).
// Reset text offset on assistant message boundaries so
// post-tool blocks stream correctly (payload.text resets
// per assistant message upstream).
onAssistantMessageStart: draftStream
? () => {
resetDraftBlockOffsets();
materializedTextLength = 0;
lastPartialFullTextLength = 0;
}
: undefined,
onModelSelected,

View File

@@ -19,7 +19,6 @@ export {
resolveChannelEntryMatch,
summarizeMapping,
toLocationContext,
type BlockReplyContext,
type MarkdownTableMode,
type NormalizedLocation,
type OpenClawConfig,

View File

@@ -1 +1 @@
export { loadOutboundMediaFromUrl } from "../runtime-api.js";
export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/matrix";

View File

@@ -1,18 +1,7 @@
import type { PinnedDispatcherPolicy } from "openclaw/plugin-sdk/infra-runtime";
import type { SsrFPolicy } from "../runtime-api.js";
import type { BaseProbeResult } from "../runtime-api.js";
import { isBunRuntime } from "./client/runtime.js";
type MatrixProbeRuntimeDeps = Pick<typeof import("./client.js"), "createMatrixClient">;
let matrixProbeRuntimeDepsPromise: Promise<MatrixProbeRuntimeDeps> | undefined;
async function loadMatrixProbeRuntimeDeps(): Promise<MatrixProbeRuntimeDeps> {
matrixProbeRuntimeDepsPromise ??= import("./client.js").then((clientModule) => ({
createMatrixClient: clientModule.createMatrixClient,
}));
return await matrixProbeRuntimeDepsPromise;
}
import { createMatrixClient, isBunRuntime } from "./client.js";
export type MatrixProbe = BaseProbeResult & {
status?: number | null;
@@ -59,7 +48,6 @@ export async function probeMatrix(params: {
};
}
try {
const { createMatrixClient } = await loadMatrixProbeRuntimeDeps();
const inputUserId = params.userId?.trim() || undefined;
const client = await createMatrixClient({
homeserver: params.homeserver,

View File

@@ -1,14 +1,17 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { coerceSecretRef } from "openclaw/plugin-sdk/config-runtime";
import type { PinnedDispatcherPolicy } from "openclaw/plugin-sdk/infra-runtime";
import { coerceSecretRef } from "openclaw/plugin-sdk/provider-auth";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
import { ssrfPolicyFromAllowPrivateNetwork } from "openclaw/plugin-sdk/ssrf-runtime";
import { resolveMatrixAccountStringValues } from "../auth-precedence.js";
import { getMatrixScopedEnvVarNames } from "../env-vars.js";
import type { CoreConfig } from "../types.js";
import { findMatrixAccountConfig, resolveMatrixBaseConfig } from "./account-config.js";
import { resolveMatrixConfigFieldPath } from "./config-update.js";
import {
findMatrixAccountConfig,
resolveMatrixBaseConfig,
} from "./account-config.js";
import type { MatrixResolvedConfig } from "./client/types.js";
import { resolveMatrixConfigFieldPath } from "./config-paths.js";
type MatrixEnvConfig = {
homeserver: string;
@@ -207,7 +210,10 @@ function resolveGlobalMatrixEnvConfig(env: NodeJS.ProcessEnv): MatrixEnvConfig {
};
}
function resolveScopedMatrixEnvConfig(accountId: string, env: NodeJS.ProcessEnv): MatrixEnvConfig {
function resolveScopedMatrixEnvConfig(
accountId: string,
env: NodeJS.ProcessEnv,
): MatrixEnvConfig {
const keys = getMatrixScopedEnvVarNames(accountId);
return {
homeserver: clean(env[keys.homeserver], keys.homeserver),

View File

@@ -1034,11 +1034,6 @@ describe("MatrixClient crypto bootstrapping", () => {
crossSigningPublished: true,
ownDeviceVerified: true,
});
await (
client as unknown as {
ensureCryptoSupportInitialized: () => Promise<void>;
}
).ensureCryptoSupportInitialized();
(
client as unknown as {
cryptoBootstrapper: { bootstrap: typeof bootstrapSpy };
@@ -1065,11 +1060,6 @@ describe("MatrixClient crypto bootstrapping", () => {
crossSigningPublished: false,
ownDeviceVerified: true,
});
await (
client as unknown as {
ensureCryptoSupportInitialized: () => Promise<void>;
}
).ensureCryptoSupportInitialized();
(
client as unknown as {
cryptoBootstrapper: { bootstrap: typeof bootstrapSpy };
@@ -1116,11 +1106,6 @@ describe("MatrixClient crypto bootstrapping", () => {
crossSigningPublished: false,
ownDeviceVerified: false,
});
await (
client as unknown as {
ensureCryptoSupportInitialized: () => Promise<void>;
}
).ensureCryptoSupportInitialized();
(
client as unknown as {
cryptoBootstrapper: { bootstrap: typeof bootstrapSpy };

View File

@@ -1,3 +1,5 @@
// Polyfill IndexedDB for WASM crypto in Node.js
import "fake-indexeddb/auto";
import { EventEmitter } from "node:events";
import {
ClientEvent,
@@ -15,11 +17,13 @@ import { resolveMatrixRoomKeyBackupReadinessError } from "./backup-health.js";
import { FileBackedMatrixSyncStore } from "./client/file-sync-store.js";
import { createMatrixJsSdkClientLogger } from "./client/logging.js";
import { isMatrixNotFoundError } from "./errors.js";
import { MatrixCryptoBootstrapper } from "./sdk/crypto-bootstrap.js";
import type { MatrixCryptoBootstrapResult } from "./sdk/crypto-bootstrap.js";
import type { MatrixCryptoFacade } from "./sdk/crypto-facade.js";
import type { MatrixDecryptBridge } from "./sdk/decrypt-bridge.js";
import { createMatrixCryptoFacade, type MatrixCryptoFacade } from "./sdk/crypto-facade.js";
import { MatrixDecryptBridge } from "./sdk/decrypt-bridge.js";
import { matrixEventToRaw, parseMxc } from "./sdk/event-helpers.js";
import { MatrixAuthedHttpClient } from "./sdk/http-client.js";
import { persistIdbToDisk, restoreIdbFromDisk } from "./sdk/idb-persistence.js";
import { ConsoleLogger, LogService, noop } from "./sdk/logger.js";
import { MatrixRecoveryKeyStore } from "./sdk/recovery-key-store.js";
import { createMatrixGuardedFetch, type HttpMethod, type QueryParams } from "./sdk/transport.js";
@@ -31,7 +35,11 @@ import type {
MatrixRawEvent,
MessageEventContent,
} from "./sdk/types.js";
import type { MatrixVerificationSummary } from "./sdk/verification-manager.js";
import {
MatrixVerificationManager,
type MatrixVerificationSummary,
} from "./sdk/verification-manager.js";
import { isMatrixDeviceOwnerVerified } from "./sdk/verification-status.js";
export { ConsoleLogger, LogService };
export type {
@@ -133,19 +141,6 @@ export type MatrixOwnDeviceDeleteResult = {
remainingDevices: MatrixOwnDeviceInfo[];
};
type MatrixCryptoRuntime = typeof import("./sdk/crypto-runtime.js");
let loadedMatrixCryptoRuntime: MatrixCryptoRuntime | null = null;
let matrixCryptoRuntimePromise: Promise<MatrixCryptoRuntime> | null = null;
async function loadMatrixCryptoRuntime(): Promise<MatrixCryptoRuntime> {
matrixCryptoRuntimePromise ??= import("./sdk/crypto-runtime.js").then((runtime) => {
loadedMatrixCryptoRuntime = runtime;
return runtime;
});
return await matrixCryptoRuntimePromise;
}
function normalizeOptionalString(value: string | null | undefined): string | null {
const normalized = value?.trim();
return normalized ? normalized : null;
@@ -182,16 +177,13 @@ export class MatrixClient {
private selfUserId: string | null;
private readonly dmRoomIds = new Set<string>();
private cryptoInitialized = false;
private decryptBridge?: MatrixDecryptBridge<MatrixRawEvent>;
private verificationManager?: import("./sdk/verification-manager.js").MatrixVerificationManager;
private readonly decryptBridge: MatrixDecryptBridge<MatrixRawEvent>;
private readonly verificationManager = new MatrixVerificationManager();
private readonly sendQueue = new KeyedAsyncQueue();
private readonly recoveryKeyStore: MatrixRecoveryKeyStore;
private cryptoBootstrapper?:
| import("./sdk/crypto-bootstrap.js").MatrixCryptoBootstrapper<MatrixRawEvent>
| undefined;
private readonly cryptoBootstrapper: MatrixCryptoBootstrapper<MatrixRawEvent>;
private readonly autoBootstrapCrypto: boolean;
private stopPersistPromise: Promise<void> | null = null;
private verificationSummaryListenerBound = false;
readonly dms = {
update: async (): Promise<boolean> => {
@@ -260,6 +252,41 @@ export class MatrixClient {
VerificationMethod.Reciprocate,
],
});
this.decryptBridge = new MatrixDecryptBridge<MatrixRawEvent>({
client: this.client,
toRaw: (event) => matrixEventToRaw(event),
emitDecryptedEvent: (roomId, event) => {
this.emitter.emit("room.decrypted_event", roomId, event);
},
emitMessage: (roomId, event) => {
this.emitter.emit("room.message", roomId, event);
},
emitFailedDecryption: (roomId, event, error) => {
this.emitter.emit("room.failed_decryption", roomId, event, error);
},
});
this.cryptoBootstrapper = new MatrixCryptoBootstrapper<MatrixRawEvent>({
getUserId: () => this.getUserId(),
getPassword: () => opts.password,
getDeviceId: () => this.client.getDeviceId(),
verificationManager: this.verificationManager,
recoveryKeyStore: this.recoveryKeyStore,
decryptBridge: this.decryptBridge,
});
this.verificationManager.onSummaryChanged((summary: MatrixVerificationSummary) => {
this.emitter.emit("verification.summary", summary);
});
if (this.encryptionEnabled) {
this.crypto = createMatrixCryptoFacade({
client: this.client,
verificationManager: this.verificationManager,
recoveryKeyStore: this.recoveryKeyStore,
getRoomStateEvent: (roomId, eventType, stateKey = "") =>
this.getRoomStateEvent(roomId, eventType, stateKey),
downloadContent: (mxcUrl) => this.downloadContent(mxcUrl),
});
}
}
on<TEvent extends keyof MatrixClientEventMap>(
@@ -284,60 +311,6 @@ export class MatrixClient {
private idbPersistTimer: ReturnType<typeof setInterval> | null = null;
private async ensureCryptoSupportInitialized(): Promise<void> {
if (
this.decryptBridge &&
(!this.encryptionEnabled ||
(this.verificationManager && this.cryptoBootstrapper && this.crypto))
) {
return;
}
const runtime = await loadMatrixCryptoRuntime();
this.decryptBridge ??= new runtime.MatrixDecryptBridge<MatrixRawEvent>({
client: this.client,
toRaw: (event) => matrixEventToRaw(event),
emitDecryptedEvent: (roomId, event) => {
this.emitter.emit("room.decrypted_event", roomId, event);
},
emitMessage: (roomId, event) => {
this.emitter.emit("room.message", roomId, event);
},
emitFailedDecryption: (roomId, event, error) => {
this.emitter.emit("room.failed_decryption", roomId, event, error);
},
});
if (!this.encryptionEnabled) {
return;
}
this.verificationManager ??= new runtime.MatrixVerificationManager();
this.cryptoBootstrapper ??= new runtime.MatrixCryptoBootstrapper<MatrixRawEvent>({
getUserId: () => this.getUserId(),
getPassword: () => this.password,
getDeviceId: () => this.client.getDeviceId(),
verificationManager: this.verificationManager,
recoveryKeyStore: this.recoveryKeyStore,
decryptBridge: this.decryptBridge,
});
if (!this.crypto) {
this.crypto = runtime.createMatrixCryptoFacade({
client: this.client,
verificationManager: this.verificationManager,
recoveryKeyStore: this.recoveryKeyStore,
getRoomStateEvent: (roomId, eventType, stateKey = "") =>
this.getRoomStateEvent(roomId, eventType, stateKey),
downloadContent: (mxcUrl) => this.downloadContent(mxcUrl),
});
}
if (!this.verificationSummaryListenerBound) {
this.verificationSummaryListenerBound = true;
this.verificationManager.onSummaryChanged((summary: MatrixVerificationSummary) => {
this.emitter.emit("verification.summary", summary);
});
}
}
async start(): Promise<void> {
await this.startSyncSession({ bootstrapCrypto: true });
}
@@ -347,7 +320,6 @@ export class MatrixClient {
return;
}
await this.ensureCryptoSupportInitialized();
this.registerBridge();
await this.initializeCryptoIfNeeded();
@@ -366,7 +338,6 @@ export class MatrixClient {
if (!this.encryptionEnabled) {
return;
}
await this.ensureCryptoSupportInitialized();
await this.initializeCryptoIfNeeded();
if (!this.crypto) {
return;
@@ -402,37 +373,21 @@ export class MatrixClient {
}
async drainPendingDecryptions(reason = "matrix client shutdown"): Promise<void> {
await this.decryptBridge?.drainPendingDecryptions(reason);
await this.decryptBridge.drainPendingDecryptions(reason);
}
stop(): void {
this.stopSyncWithoutPersist();
this.decryptBridge?.stop();
this.decryptBridge.stop();
// Final persist on shutdown
this.syncStore?.markCleanShutdown();
if (loadedMatrixCryptoRuntime) {
const { persistIdbToDisk } = loadedMatrixCryptoRuntime;
this.stopPersistPromise = Promise.all([
persistIdbToDisk({
snapshotPath: this.idbSnapshotPath,
databasePrefix: this.cryptoDatabasePrefix,
}).catch(noop),
this.syncStore?.flush().catch(noop),
]).then(() => undefined);
return;
}
this.stopPersistPromise = loadMatrixCryptoRuntime()
.then(async ({ persistIdbToDisk }) => {
await Promise.all([
persistIdbToDisk({
snapshotPath: this.idbSnapshotPath,
databasePrefix: this.cryptoDatabasePrefix,
}).catch(noop),
this.syncStore?.flush().catch(noop),
]);
})
.catch(noop)
.then(() => undefined);
this.stopPersistPromise = Promise.all([
persistIdbToDisk({
snapshotPath: this.idbSnapshotPath,
databasePrefix: this.cryptoDatabasePrefix,
}).catch(noop),
this.syncStore?.flush().catch(noop),
]).then(() => undefined);
}
async stopAndPersist(): Promise<void> {
@@ -444,16 +399,11 @@ export class MatrixClient {
if (!this.encryptionEnabled || !this.cryptoInitialized || this.cryptoBootstrapped) {
return;
}
await this.ensureCryptoSupportInitialized();
const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined;
if (!crypto) {
return;
}
const cryptoBootstrapper = this.cryptoBootstrapper;
if (!cryptoBootstrapper) {
return;
}
const initial = await cryptoBootstrapper.bootstrap(crypto, {
const initial = await this.cryptoBootstrapper.bootstrap(crypto, {
allowAutomaticCrossSigningReset: false,
});
if (!initial.crossSigningPublished || initial.ownDeviceVerified === false) {
@@ -465,7 +415,7 @@ export class MatrixClient {
);
} else if (this.password?.trim()) {
try {
const repaired = await cryptoBootstrapper.bootstrap(crypto, {
const repaired = await this.cryptoBootstrapper.bootstrap(crypto, {
forceResetCrossSigning: true,
strict: true,
});
@@ -496,7 +446,6 @@ export class MatrixClient {
if (!this.encryptionEnabled || this.cryptoInitialized) {
return;
}
const { persistIdbToDisk, restoreIdbFromDisk } = await loadMatrixCryptoRuntime();
// Restore persisted IndexedDB crypto store before initializing WASM crypto.
await restoreIdbFromDisk(this.idbSnapshotPath);
@@ -923,7 +872,6 @@ export class MatrixClient {
if (crypto && userId && deviceId && typeof crypto.getDeviceVerificationStatus === "function") {
deviceStatus = await crypto.getDeviceVerificationStatus(userId, deviceId).catch(() => null);
}
const { isMatrixDeviceOwnerVerified } = await loadMatrixCryptoRuntime();
return {
encryptionEnabled: true,
@@ -955,7 +903,6 @@ export class MatrixClient {
}
await this.ensureStartedForCryptoControlPlane();
await this.ensureCryptoSupportInitialized();
const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined;
if (!crypto) {
return await fail("Matrix crypto is not available (start client with encryption enabled)");
@@ -976,11 +923,7 @@ export class MatrixClient {
}
try {
const cryptoBootstrapper = this.cryptoBootstrapper;
if (!cryptoBootstrapper) {
return await fail("Matrix crypto bootstrapper is not available");
}
await cryptoBootstrapper.bootstrap(crypto, {
await this.cryptoBootstrapper.bootstrap(crypto, {
allowAutomaticCrossSigningReset: false,
});
await this.enableTrustedRoomKeyBackupIfPossible(crypto);
@@ -1243,7 +1186,6 @@ export class MatrixClient {
let bootstrapSummary: MatrixCryptoBootstrapResult | null = null;
try {
await this.ensureStartedForCryptoControlPlane();
await this.ensureCryptoSupportInitialized();
const crypto = this.client.getCrypto() as MatrixCryptoBootstrapApi | undefined;
if (!crypto) {
throw new Error("Matrix crypto is not available (start client with encryption enabled)");
@@ -1257,11 +1199,7 @@ export class MatrixClient {
});
}
const cryptoBootstrapper = this.cryptoBootstrapper;
if (!cryptoBootstrapper) {
throw new Error("Matrix crypto bootstrapper is not available");
}
bootstrapSummary = await cryptoBootstrapper.bootstrap(crypto, {
bootstrapSummary = await this.cryptoBootstrapper.bootstrap(crypto, {
forceResetCrossSigning: params?.forceResetCrossSigning === true,
allowSecretStorageRecreateWithoutRecoveryKey: true,
strict: true,
@@ -1476,11 +1414,10 @@ export class MatrixClient {
}
private registerBridge(): void {
if (this.bridgeRegistered || !this.decryptBridge) {
if (this.bridgeRegistered) {
return;
}
this.bridgeRegistered = true;
const decryptBridge = this.decryptBridge;
this.client.on(ClientEvent.Event, (event: MatrixEvent) => {
const roomId = event.getRoomId();
@@ -1494,7 +1431,7 @@ export class MatrixClient {
if (isEncryptedEvent) {
this.emitter.emit("room.encrypted_event", roomId, raw);
} else {
if (decryptBridge.shouldEmitUnencryptedMessage(roomId, raw.event_id)) {
if (this.decryptBridge.shouldEmitUnencryptedMessage(roomId, raw.event_id)) {
this.emitter.emit("room.message", roomId, raw);
}
}
@@ -1514,7 +1451,7 @@ export class MatrixClient {
}
if (isEncryptedEvent) {
decryptBridge.attachEncryptedEvent(event, roomId);
this.decryptBridge.attachEncryptedEvent(event, roomId);
}
});

View File

@@ -1,11 +0,0 @@
import "fake-indexeddb/auto";
export { MatrixCryptoBootstrapper } from "./crypto-bootstrap.js";
export type { MatrixCryptoBootstrapResult } from "./crypto-bootstrap.js";
export { createMatrixCryptoFacade } from "./crypto-facade.js";
export type { MatrixCryptoFacade } from "./crypto-facade.js";
export { MatrixDecryptBridge } from "./decrypt-bridge.js";
export { persistIdbToDisk, restoreIdbFromDisk } from "./idb-persistence.js";
export { MatrixVerificationManager } from "./verification-manager.js";
export type { MatrixVerificationSummary } from "./verification-manager.js";
export { isMatrixDeviceOwnerVerified } from "./verification-status.js";

View File

@@ -2,13 +2,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginRuntime } from "../../runtime-api.js";
import { setMatrixRuntime } from "../runtime.js";
import { voteMatrixPoll } from "./actions/polls.js";
import {
editMessageMatrix,
sendMessageMatrix,
sendPollMatrix,
sendSingleTextMessageMatrix,
sendTypingMatrix,
} from "./send.js";
import { sendMessageMatrix, sendSingleTextMessageMatrix, sendTypingMatrix } from "./send.js";
const loadOutboundMediaFromUrlMock = vi.hoisted(() => vi.fn());
const loadWebMediaMock = vi.fn().mockResolvedValue({
@@ -85,13 +79,11 @@ const makeClient = () => {
const sendMessage = vi.fn().mockResolvedValue("evt1");
const sendEvent = vi.fn().mockResolvedValue("evt-poll-vote");
const getEvent = vi.fn();
const getJoinedRoomMembers = vi.fn().mockResolvedValue([]);
const uploadContent = vi.fn().mockResolvedValue("mxc://example/file");
const client = {
sendMessage,
sendEvent,
getEvent,
getJoinedRoomMembers,
uploadContent,
getUserId: vi.fn().mockResolvedValue("@bot:example.org"),
prepareForOneOff: vi.fn(async () => undefined),
@@ -99,7 +91,7 @@ const makeClient = () => {
stop: vi.fn(() => undefined),
stopAndPersist: vi.fn(async () => undefined),
} as unknown as import("./sdk.js").MatrixClient;
return { client, sendMessage, sendEvent, getEvent, getJoinedRoomMembers, uploadContent };
return { client, sendMessage, sendEvent, getEvent, uploadContent };
};
function makeEncryptedMediaClient() {
@@ -392,132 +384,6 @@ describe("sendMessageMatrix media", () => {
});
});
describe("sendMessageMatrix mentions", () => {
beforeEach(() => {
vi.clearAllMocks();
resetMatrixSendRuntimeMocks();
});
it("adds an empty m.mentions object for plain messages without mentions", async () => {
const { client, sendMessage } = makeClient();
await sendMessageMatrix("room:!room:example", "hello", {
client,
});
expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
body: "hello",
"m.mentions": {},
});
});
it("emits m.mentions and matrix.to anchors for qualified user mentions", async () => {
const { client, sendMessage } = makeClient();
await sendMessageMatrix("room:!room:example", "hello @alice:example.org", {
client,
});
expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
body: "hello @alice:example.org",
"m.mentions": { user_ids: ["@alice:example.org"] },
});
expect(
(sendMessage.mock.calls[0]?.[1] as { formatted_body?: string }).formatted_body,
).toContain('href="https://matrix.to/#/%40alice%3Aexample.org"');
});
it("keeps bare localpart text as plain text", async () => {
const { client, sendMessage } = makeClient();
await sendMessageMatrix("room:!room:example", "hello @alice", {
client,
});
expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
"m.mentions": {},
});
expect(
(sendMessage.mock.calls[0]?.[1] as { formatted_body?: string }).formatted_body,
).not.toContain("matrix.to/#/@alice:example.org");
});
it("does not emit mentions for escaped qualified users", async () => {
const { client, sendMessage } = makeClient();
await sendMessageMatrix("room:!room:example", "\\@alice:example.org", {
client,
});
expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
"m.mentions": {},
});
expect(
(sendMessage.mock.calls[0]?.[1] as { formatted_body?: string }).formatted_body,
).not.toContain("matrix.to/#/@alice:example.org");
});
it("does not emit mentions for escaped room mentions", async () => {
const { client, sendMessage } = makeClient();
await sendMessageMatrix("room:!room:example", "\\@room please review", {
client,
});
expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
"m.mentions": {},
});
});
it("marks room mentions via m.mentions.room", async () => {
const { client, sendMessage } = makeClient();
await sendMessageMatrix("room:!room:example", "@room please review", {
client,
});
expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
"m.mentions": { room: true },
});
});
it("adds mention metadata to media captions", async () => {
const { client, sendMessage } = makeClient();
await sendMessageMatrix("room:!room:example", "caption @alice:example.org", {
client,
mediaUrl: "file:///tmp/photo.png",
});
expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
"m.mentions": { user_ids: ["@alice:example.org"] },
});
});
it("does not emit mentions from fallback filenames when there is no caption", async () => {
const { client, sendMessage } = makeClient();
loadWebMediaMock.mockResolvedValue({
buffer: Buffer.from("media"),
fileName: "@room.png",
contentType: "image/png",
kind: "image",
});
await sendMessageMatrix("room:!room:example", "", {
client,
mediaUrl: "file:///tmp/room.png",
});
expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
body: "@room.png",
"m.mentions": {},
});
expect(
(sendMessage.mock.calls[0]?.[1] as { formatted_body?: string }).formatted_body,
).toBeUndefined();
});
});
describe("sendMessageMatrix threads", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -580,114 +446,6 @@ describe("sendSingleTextMessageMatrix", () => {
});
});
describe("editMessageMatrix mentions", () => {
beforeEach(() => {
vi.clearAllMocks();
resetMatrixSendRuntimeMocks();
});
it("stores full mentions in m.new_content and only newly-added mentions in the edit event", async () => {
const { client, sendMessage, getEvent } = makeClient();
getEvent.mockResolvedValue({
content: {
body: "hello @alice:example.org",
"m.mentions": { user_ids: ["@alice:example.org"] },
},
});
await editMessageMatrix(
"room:!room:example",
"$original",
"hello @alice:example.org and @bob:example.org",
{
client,
},
);
expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
"m.mentions": { user_ids: ["@bob:example.org"] },
"m.new_content": {
"m.mentions": { user_ids: ["@alice:example.org", "@bob:example.org"] },
},
});
});
it("does not re-notify legacy mentions when the prior event body already mentioned the user", async () => {
const { client, sendMessage, getEvent } = makeClient();
getEvent.mockResolvedValue({
content: {
body: "hello @alice:example.org",
},
});
await editMessageMatrix("room:!room:example", "$original", "hello again @alice:example.org", {
client,
});
expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
"m.mentions": {},
"m.new_content": {
body: "hello again @alice:example.org",
"m.mentions": { user_ids: ["@alice:example.org"] },
},
});
});
it("keeps explicit empty prior m.mentions authoritative", async () => {
const { client, sendMessage, getEvent } = makeClient();
getEvent.mockResolvedValue({
content: {
body: "`@alice:example.org`",
"m.mentions": {},
},
});
await editMessageMatrix("room:!room:example", "$original", "@alice:example.org", {
client,
});
expect(sendMessage.mock.calls[0]?.[1]).toMatchObject({
"m.mentions": { user_ids: ["@alice:example.org"] },
"m.new_content": {
"m.mentions": { user_ids: ["@alice:example.org"] },
},
});
});
});
describe("sendPollMatrix mentions", () => {
beforeEach(() => {
vi.clearAllMocks();
resetMatrixSendRuntimeMocks();
});
it("adds m.mentions for poll fallback text", async () => {
const { client, sendEvent } = makeClient();
await sendPollMatrix(
"room:!room:example",
{
question: "@room lunch with @alice:example.org?",
options: ["yes", "no"],
},
{
client,
},
);
expect(sendEvent).toHaveBeenCalledWith(
"!room:example",
"m.poll.start",
expect.objectContaining({
"m.mentions": {
room: true,
user_ids: ["@alice:example.org"],
},
}),
);
});
});
describe("voteMatrixPoll", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -15,10 +15,6 @@ import {
buildReplyRelation,
buildTextContent,
buildThreadRelation,
diffMatrixMentions,
enrichMatrixFormattedContent,
extractMatrixMentions,
resolveMatrixMentionsForBody,
resolveMatrixMsgType,
resolveMatrixVoiceDecision,
} from "./send/formatting.js";
@@ -83,42 +79,6 @@ function normalizeMatrixClientResolveOpts(
};
}
function resolvePreviousEditContent(previousEvent: unknown): Record<string, unknown> | undefined {
if (!previousEvent || typeof previousEvent !== "object") {
return undefined;
}
const eventRecord = previousEvent as { content?: unknown };
if (!eventRecord.content || typeof eventRecord.content !== "object") {
return undefined;
}
const content = eventRecord.content as Record<string, unknown>;
const newContent = content["m.new_content"];
return newContent && typeof newContent === "object"
? (newContent as Record<string, unknown>)
: content;
}
function hasMatrixMentionsMetadata(content: Record<string, unknown> | undefined): boolean {
return Boolean(content && Object.hasOwn(content, "m.mentions"));
}
async function resolvePreviousEditMentions(params: {
client: MatrixClient;
content: Record<string, unknown> | undefined;
}) {
if (hasMatrixMentionsMetadata(params.content)) {
return extractMatrixMentions(params.content);
}
const body = typeof params.content?.body === "string" ? params.content.body : "";
if (!body) {
return {};
}
return await resolveMatrixMentionsForBody({
client: params.client,
body,
});
}
export function prepareMatrixSingleText(
text: string,
opts: {
@@ -237,8 +197,7 @@ export async function sendMessageMatrix(
})
: undefined;
const [firstChunk, ...rest] = chunks;
const captionMarkdown = useVoice ? "" : (firstChunk ?? "");
const body = useVoice ? "Voice message" : captionMarkdown || media.fileName || "(file)";
const body = useVoice ? "Voice message" : (firstChunk ?? media.fileName ?? "(file)");
const content = buildMediaContent({
msgtype,
body,
@@ -252,11 +211,6 @@ export async function sendMessageMatrix(
isVoice: useVoice,
imageInfo,
});
await enrichMatrixFormattedContent({
client,
content,
markdown: captionMarkdown,
});
const eventId = await sendContent(content);
lastMessageId = eventId ?? lastMessageId;
const textChunks = useVoice ? chunks : rest;
@@ -269,11 +223,6 @@ export async function sendMessageMatrix(
continue;
}
const followup = buildTextContent(text, followupRelation);
await enrichMatrixFormattedContent({
client,
content: followup,
markdown: text,
});
const followupEventId = await sendContent(followup);
lastMessageId = followupEventId ?? lastMessageId;
}
@@ -284,11 +233,6 @@ export async function sendMessageMatrix(
continue;
}
const content = buildTextContent(text, relation);
await enrichMatrixFormattedContent({
client,
content,
markdown: text,
});
const eventId = await sendContent(content);
lastMessageId = eventId ?? lastMessageId;
}
@@ -323,17 +267,10 @@ export async function sendPollMatrix(
async (client) => {
const roomId = await resolveMatrixRoomId(client, to);
const pollContent = buildPollStartContent(poll);
const fallbackText =
pollContent["m.text"] ?? pollContent["org.matrix.msc1767.text"] ?? poll.question ?? "";
const mentions = await resolveMatrixMentionsForBody({
client,
body: fallbackText,
});
const threadId = normalizeThreadId(opts.threadId);
const pollPayload: Record<string, unknown> = threadId
const pollPayload = threadId
? { ...pollContent, "m.relates_to": buildThreadRelation(threadId) }
: { ...pollContent };
pollPayload["m.mentions"] = mentions;
: pollContent;
const eventId = await client.sendEvent(roomId, M_POLL_START, pollPayload);
return {
@@ -414,11 +351,6 @@ export async function sendSingleTextMessageMatrix(
? buildThreadRelation(normalizedThreadId, opts.replyToId)
: buildReplyRelation(opts.replyToId);
const content = buildTextContent(convertedText, relation);
await enrichMatrixFormattedContent({
client,
content,
markdown: convertedText,
});
const eventId = await client.sendMessage(resolvedRoom, content);
return {
messageId: eventId ?? "unknown",
@@ -428,22 +360,6 @@ export async function sendSingleTextMessageMatrix(
);
}
async function getPreviousMatrixEvent(
client: MatrixClient,
roomId: string,
eventId: string,
): Promise<Record<string, unknown> | null> {
const getEvent = (
client as {
getEvent?: (roomId: string, eventId: string) => Promise<Record<string, unknown>>;
}
).getEvent;
if (typeof getEvent !== "function") {
return null;
}
return await Promise.resolve(getEvent.call(client, roomId, eventId)).catch(() => null);
}
export async function editMessageMatrix(
roomId: string,
originalEventId: string,
@@ -453,7 +369,6 @@ export async function editMessageMatrix(
cfg?: CoreConfig;
threadId?: string;
accountId?: string;
timeoutMs?: number;
} = {},
): Promise<string> {
return await withResolvedMatrixSendClient(
@@ -461,7 +376,6 @@ export async function editMessageMatrix(
client: opts.client,
cfg: opts.cfg,
accountId: opts.accountId,
timeoutMs: opts.timeoutMs,
},
async (client) => {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
@@ -473,21 +387,6 @@ export async function editMessageMatrix(
});
const convertedText = getCore().channel.text.convertMarkdownTables(newText, tableMode);
const newContent = buildTextContent(convertedText);
await enrichMatrixFormattedContent({
client,
content: newContent,
markdown: convertedText,
});
const previousEvent = await getPreviousMatrixEvent(client, resolvedRoom, originalEventId);
const previousContent = resolvePreviousEditContent(previousEvent);
const previousMentions = await resolvePreviousEditMentions({
client,
content: previousContent,
});
const replaceMentions = diffMatrixMentions(
extractMatrixMentions(newContent),
previousMentions,
);
const replaceRelation: Record<string, unknown> = {
rel_type: RelationType.Replace,
@@ -508,7 +407,6 @@ export async function editMessageMatrix(
...(typeof newContent.formatted_body === "string"
? { formatted_body: `* ${newContent.formatted_body}` }
: {}),
"m.mentions": replaceMentions,
"m.new_content": newContent,
"m.relates_to": replaceRelation,
};

View File

@@ -1,22 +1,11 @@
import { getMatrixRuntime } from "../../runtime.js";
import type { CoreConfig } from "../../types.js";
import { resolveMatrixAccountConfig } from "../account-config.js";
import { withResolvedRuntimeMatrixClient } from "../client-bootstrap.js";
import type { MatrixClient } from "../sdk.js";
const getCore = () => getMatrixRuntime();
type MatrixSendClientRuntime = Pick<
typeof import("../client-bootstrap.js"),
"withResolvedRuntimeMatrixClient"
>;
let matrixSendClientRuntimePromise: Promise<MatrixSendClientRuntime> | null = null;
async function loadMatrixSendClientRuntime(): Promise<MatrixSendClientRuntime> {
matrixSendClientRuntimePromise ??= import("../client-bootstrap.js");
return await matrixSendClientRuntimePromise;
}
export function resolveMediaMaxBytes(
accountId?: string | null,
cfg?: CoreConfig,
@@ -39,10 +28,6 @@ export async function withResolvedMatrixSendClient<T>(
},
run: (client: MatrixClient) => Promise<T>,
): Promise<T> {
if (opts.client) {
return await run(opts.client);
}
const { withResolvedRuntimeMatrixClient } = await loadMatrixSendClientRuntime();
return await withResolvedRuntimeMatrixClient(
{
...opts,
@@ -66,10 +51,6 @@ export async function withResolvedMatrixControlClient<T>(
},
run: (client: MatrixClient) => Promise<T>,
): Promise<T> {
if (opts.client) {
return await run(opts.client);
}
const { withResolvedRuntimeMatrixClient } = await loadMatrixSendClientRuntime();
return await withResolvedRuntimeMatrixClient(
{
...opts,

View File

@@ -1,10 +1,5 @@
import { getMatrixRuntime } from "../../runtime.js";
import {
resolveMatrixMentionsInMarkdown,
renderMarkdownToMatrixHtmlWithMentions,
type MatrixMentions,
} from "../format.js";
import type { MatrixClient } from "../sdk.js";
import { markdownToMatrixHtml } from "../format.js";
import {
MsgType,
RelationType,
@@ -19,7 +14,7 @@ import {
const getCore = () => getMatrixRuntime();
export function buildTextContent(body: string, relation?: MatrixRelation): MatrixTextContent {
return relation
const content: MatrixTextContent = relation
? {
msgtype: MsgType.Text,
body,
@@ -29,76 +24,17 @@ export function buildTextContent(body: string, relation?: MatrixRelation): Matri
msgtype: MsgType.Text,
body,
};
applyMatrixFormatting(content, body);
return content;
}
export async function enrichMatrixFormattedContent(params: {
client: MatrixClient;
content: MatrixFormattedContent;
markdown?: string | null;
}): Promise<void> {
const { html, mentions } = await renderMarkdownToMatrixHtmlWithMentions({
markdown: params.markdown ?? "",
client: params.client,
});
params.content["m.mentions"] = mentions;
if (!html) {
delete params.content.format;
delete params.content.formatted_body;
export function applyMatrixFormatting(content: MatrixFormattedContent, body: string): void {
const formatted = markdownToMatrixHtml(body ?? "");
if (!formatted) {
return;
}
params.content.format = "org.matrix.custom.html";
params.content.formatted_body = html;
}
export async function resolveMatrixMentionsForBody(params: {
client: MatrixClient;
body: string;
}): Promise<MatrixMentions> {
return await resolveMatrixMentionsInMarkdown({
markdown: params.body ?? "",
client: params.client,
});
}
function normalizeMentionUserIds(value: unknown): string[] {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
: [];
}
export function extractMatrixMentions(
content: Record<string, unknown> | undefined,
): MatrixMentions {
const rawMentions = content?.["m.mentions"];
if (!rawMentions || typeof rawMentions !== "object") {
return {};
}
const mentions = rawMentions as { room?: unknown; user_ids?: unknown };
const normalized: MatrixMentions = {};
const userIds = normalizeMentionUserIds(mentions.user_ids);
if (userIds.length > 0) {
normalized.user_ids = userIds;
}
if (mentions.room === true) {
normalized.room = true;
}
return normalized;
}
export function diffMatrixMentions(
current: MatrixMentions,
previous: MatrixMentions,
): MatrixMentions {
const previousUserIds = new Set(previous.user_ids ?? []);
const newUserIds = (current.user_ids ?? []).filter((userId) => !previousUserIds.has(userId));
const delta: MatrixMentions = {};
if (newUserIds.length > 0) {
delta.user_ids = newUserIds;
}
if (current.room && !previous.room) {
delta.room = true;
}
return delta;
content.format = "org.matrix.custom.html";
content.formatted_body = formatted;
}
export function buildReplyRelation(replyToId?: string): MatrixReplyRelation | undefined {

View File

@@ -8,6 +8,7 @@ import type {
TimedFileInfo,
VideoFileInfo,
} from "../sdk.js";
import { applyMatrixFormatting } from "./formatting.js";
import {
type MatrixMediaContent,
type MatrixMediaInfo,
@@ -102,6 +103,7 @@ export function buildMediaContent(params: {
if (params.relation) {
base["m.relates_to"] = params.relation;
}
applyMatrixFormatting(base, params.body);
return base;
}

View File

@@ -1,13 +1,8 @@
import type { OutputRuntimeEnv } from "openclaw/plugin-sdk/runtime";
import type { ChannelSetupWizardAdapter } from "openclaw/plugin-sdk/setup";
import { afterEach, vi } from "vitest";
import type { RuntimeEnv, WizardPrompter } from "../runtime-api.js";
import type { CoreConfig } from "./types.js";
type MatrixInteractiveOptions = Parameters<
NonNullable<ChannelSetupWizardAdapter["configureInteractive"]>
>[0]["options"];
const MATRIX_ENV_KEYS = [
"MATRIX_HOMESERVER",
"MATRIX_USER_ID",
@@ -93,7 +88,7 @@ export function createMatrixWizardPrompter(params: {
export async function runMatrixInteractiveConfigure(params: {
cfg: CoreConfig;
prompter: WizardPrompter;
options?: MatrixInteractiveOptions;
options?: unknown;
accountOverrides?: Record<string, string>;
shouldPromptAccountIds?: boolean;
forceAllowFrom?: boolean;

View File

@@ -1,8 +1,5 @@
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
import {
type ChannelSetupDmPolicy,
type ChannelSetupWizardAdapter,
} from "openclaw/plugin-sdk/setup";
import { type ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup";
import { requiresExplicitMatrixDefaultAccount } from "./account-selection.js";
import { listMatrixDirectoryGroupsLive } from "./directory-live.js";
import {
@@ -41,6 +38,54 @@ import type { CoreConfig } from "./types.js";
const channel = "matrix" as const;
type MatrixOnboardingStatus = {
channel: typeof channel;
configured: boolean;
statusLines: string[];
selectionHint?: string;
quickstartScore?: number;
};
type MatrixAccountOverrides = Partial<Record<typeof channel, string>>;
type MatrixOnboardingConfigureContext = {
cfg: CoreConfig;
runtime: RuntimeEnv;
prompter: WizardPrompter;
options?: unknown;
forceAllowFrom: boolean;
accountOverrides: MatrixAccountOverrides;
shouldPromptAccountIds: boolean;
};
type MatrixOnboardingInteractiveContext = MatrixOnboardingConfigureContext & {
configured: boolean;
label?: string;
};
type MatrixOnboardingAdapter = {
channel: typeof channel;
getStatus: (ctx: {
cfg: CoreConfig;
options?: unknown;
accountOverrides: MatrixAccountOverrides;
}) => Promise<MatrixOnboardingStatus>;
configure: (
ctx: MatrixOnboardingConfigureContext,
) => Promise<{ cfg: CoreConfig; accountId?: string }>;
configureInteractive?: (
ctx: MatrixOnboardingInteractiveContext,
) => Promise<{ cfg: CoreConfig; accountId?: string } | "skip">;
afterConfigWritten?: (ctx: {
previousCfg: CoreConfig;
cfg: CoreConfig;
accountId: string;
runtime: RuntimeEnv;
}) => Promise<void> | void;
dmPolicy?: ChannelSetupDmPolicy;
disable?: (cfg: CoreConfig) => CoreConfig;
};
function resolveMatrixOnboardingAccountId(cfg: CoreConfig, accountId?: string): string {
return normalizeAccountId(
accountId?.trim() || resolveDefaultMatrixAccountId(cfg) || DEFAULT_ACCOUNT_ID,
@@ -511,7 +556,7 @@ async function runMatrixConfigure(params: {
return { cfg: next, accountId };
}
export const matrixOnboardingAdapter: ChannelSetupWizardAdapter = {
export const matrixOnboardingAdapter: MatrixOnboardingAdapter = {
channel,
getStatus: async ({ cfg, accountOverrides }) => {
const resolvedCfg = cfg as CoreConfig;

View File

@@ -8,7 +8,6 @@ export {
formatZonedTimestamp,
getChatChannelMeta,
jsonResult,
loadOutboundMediaFromUrl,
normalizeAccountId,
normalizeOptionalAccountId,
readNumberParam,

View File

@@ -154,13 +154,10 @@ export type MatrixConfig = {
actions?: MatrixActionConfig;
/**
* Streaming mode for Matrix replies.
* - `"partial"`: edit a single draft message in place for the current
* assistant block as the model generates text.
* - `"partial"`: edit a single message in place as the model generates text.
* - `"off"`: deliver the full reply once the model finishes.
* - Use `blockStreaming: true` when you want completed assistant blocks to
* stay visible as separate progress messages. When combined with
* `"partial"`, Matrix keeps a live draft for the current block and
* preserves completed blocks as separate messages.
* - Use `blockStreaming: true` when you want separate progress messages
* while `streaming` remains `"off"`.
* - `true` maps to `"partial"`, `false` maps to `"off"`.
* Default: `"off"`.
*/

View File

@@ -373,12 +373,7 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = create
if (!token || !baseUrl) {
return { ok: false, error: "bot token or baseUrl missing" };
}
return await probeMattermost(
baseUrl,
token,
timeoutMs,
account.config.allowPrivateNetwork === true,
);
return await probeMattermost(baseUrl, token, timeoutMs);
},
resolveAccountSnapshot: ({ account, runtime }) => ({
accountId: account.accountId,

View File

@@ -1,24 +1,16 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { probeMattermost } from "./probe.js";
const { mockFetchGuard, mockRelease } = vi.hoisted(() => ({
mockFetchGuard: vi.fn(),
mockRelease: vi.fn(async () => {}),
}));
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
const original = (await importOriginal()) as Record<string, unknown>;
return { ...original, fetchWithSsrFGuard: mockFetchGuard };
});
const mockFetch = vi.fn<typeof fetch>();
describe("probeMattermost", () => {
beforeEach(() => {
mockFetchGuard.mockReset();
mockRelease.mockClear();
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("returns baseUrl missing for empty base URL", async () => {
@@ -26,28 +18,25 @@ describe("probeMattermost", () => {
ok: false,
error: "baseUrl missing",
});
expect(mockFetchGuard).not.toHaveBeenCalled();
expect(mockFetch).not.toHaveBeenCalled();
});
it("normalizes base URL and returns bot info", async () => {
mockFetchGuard.mockResolvedValueOnce({
response: new Response(JSON.stringify({ id: "bot-1", username: "clawbot" }), {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ id: "bot-1", username: "clawbot" }), {
status: 200,
headers: { "content-type": "application/json" },
}),
release: mockRelease,
});
);
const result = await probeMattermost("https://mm.example.com/api/v4/", "bot-token");
expect(mockFetchGuard).toHaveBeenCalledWith({
url: "https://mm.example.com/api/v4/users/me",
init: expect.objectContaining({
expect(mockFetch).toHaveBeenCalledWith(
"https://mm.example.com/api/v4/users/me",
expect.objectContaining({
headers: { Authorization: "Bearer bot-token" },
}),
auditContext: "mattermost-probe",
policy: undefined,
});
);
expect(result).toEqual(
expect.objectContaining({
ok: true,
@@ -56,36 +45,16 @@ describe("probeMattermost", () => {
}),
);
expect(result.elapsedMs).toBeGreaterThanOrEqual(0);
expect(mockRelease).toHaveBeenCalledTimes(1);
});
it("forwards allowPrivateNetwork to the SSRF guard policy", async () => {
mockFetchGuard.mockResolvedValueOnce({
response: new Response(JSON.stringify({ id: "bot-1" }), {
status: 200,
headers: { "content-type": "application/json" },
}),
release: mockRelease,
});
await probeMattermost("https://mm.example.com", "bot-token", 2500, true);
expect(mockFetchGuard).toHaveBeenCalledWith(
expect.objectContaining({
policy: { allowPrivateNetwork: true },
}),
);
});
it("returns API error details from JSON response", async () => {
mockFetchGuard.mockResolvedValueOnce({
response: new Response(JSON.stringify({ message: "invalid auth token" }), {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ message: "invalid auth token" }), {
status: 401,
statusText: "Unauthorized",
headers: { "content-type": "application/json" },
}),
release: mockRelease,
});
);
await expect(probeMattermost("https://mm.example.com", "bad-token")).resolves.toEqual(
expect.objectContaining({
@@ -94,18 +63,16 @@ describe("probeMattermost", () => {
error: "invalid auth token",
}),
);
expect(mockRelease).toHaveBeenCalledTimes(1);
});
it("falls back to statusText when error body is empty", async () => {
mockFetchGuard.mockResolvedValueOnce({
response: new Response("", {
mockFetch.mockResolvedValueOnce(
new Response("", {
status: 403,
statusText: "Forbidden",
headers: { "content-type": "text/plain" },
}),
release: mockRelease,
});
);
await expect(probeMattermost("https://mm.example.com", "token")).resolves.toEqual(
expect.objectContaining({
@@ -114,11 +81,10 @@ describe("probeMattermost", () => {
error: "Forbidden",
}),
);
expect(mockRelease).toHaveBeenCalledTimes(1);
});
it("returns fetch error when request throws", async () => {
mockFetchGuard.mockRejectedValueOnce(new Error("network down"));
mockFetch.mockRejectedValueOnce(new Error("network down"));
await expect(probeMattermost("https://mm.example.com", "token")).resolves.toEqual(
expect.objectContaining({

View File

@@ -1,4 +1,3 @@
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeMattermostBaseUrl, readMattermostError, type MattermostUser } from "./client.js";
import type { BaseProbeResult } from "./runtime-api.js";
@@ -12,7 +11,6 @@ export async function probeMattermost(
baseUrl: string,
botToken: string,
timeoutMs = 2500,
allowPrivateNetwork = false,
): Promise<MattermostProbe> {
const normalized = normalizeMattermostBaseUrl(baseUrl);
if (!normalized) {
@@ -26,36 +24,27 @@ export async function probeMattermost(
timer = setTimeout(() => controller.abort(), timeoutMs);
}
try {
const { response: res, release } = await fetchWithSsrFGuard({
url,
init: {
headers: { Authorization: `Bearer ${botToken}` },
signal: controller?.signal,
},
auditContext: "mattermost-probe",
policy: allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined,
const res = await fetch(url, {
headers: { Authorization: `Bearer ${botToken}` },
signal: controller?.signal,
});
try {
const elapsedMs = Date.now() - start;
if (!res.ok) {
const detail = await readMattermostError(res);
return {
ok: false,
status: res.status,
error: detail || res.statusText,
elapsedMs,
};
}
const bot = (await res.json()) as MattermostUser;
const elapsedMs = Date.now() - start;
if (!res.ok) {
const detail = await readMattermostError(res);
return {
ok: true,
ok: false,
status: res.status,
error: detail || res.statusText,
elapsedMs,
bot,
};
} finally {
await release();
}
const bot = (await res.json()) as MattermostUser;
return {
ok: true,
status: res.status,
elapsedMs,
bot,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {

View File

@@ -12,6 +12,7 @@ export const mistralMediaUnderstandingProvider: MediaUnderstandingProvider = {
transcribeAudio: async (req) =>
await transcribeOpenAiCompatibleAudio({
...req,
provider: "mistral",
baseUrl: req.baseUrl ?? DEFAULT_MISTRAL_AUDIO_BASE_URL,
defaultBaseUrl: DEFAULT_MISTRAL_AUDIO_BASE_URL,
defaultModel: DEFAULT_MISTRAL_AUDIO_MODEL,

View File

@@ -7,8 +7,8 @@ import {
} from "openclaw/plugin-sdk/media-understanding";
import {
assertOkOrThrowHttpError,
normalizeBaseUrl,
postJsonRequest,
resolveProviderHttpRequestConfig,
} from "openclaw/plugin-sdk/provider-http";
export const DEFAULT_MOONSHOT_VIDEO_BASE_URL = "https://api.moonshot.ai/v1";
@@ -62,24 +62,20 @@ export async function describeMoonshotVideo(
params: VideoDescriptionRequest,
): Promise<VideoDescriptionResult> {
const fetchFn = params.fetchFn ?? fetch;
const baseUrl = normalizeBaseUrl(params.baseUrl, DEFAULT_MOONSHOT_VIDEO_BASE_URL);
const model = resolveModel(params.model);
const mime = params.mime ?? "video/mp4";
const prompt = resolvePrompt(params.prompt);
const { baseUrl, allowPrivateNetwork, headers } = resolveProviderHttpRequestConfig({
baseUrl: params.baseUrl,
defaultBaseUrl: DEFAULT_MOONSHOT_VIDEO_BASE_URL,
headers: params.headers,
defaultHeaders: {
"content-type": "application/json",
authorization: `Bearer ${params.apiKey}`,
},
provider: "moonshot",
api: "openai-completions",
capability: "video",
transport: "media-understanding",
});
const url = `${baseUrl}/chat/completions`;
const headers = new Headers(params.headers);
if (!headers.has("content-type")) {
headers.set("content-type", "application/json");
}
if (!headers.has("authorization")) {
headers.set("authorization", `Bearer ${params.apiKey}`);
}
const body = {
model,
messages: [
@@ -100,11 +96,11 @@ export async function describeMoonshotVideo(
const { response: res, release } = await postJsonRequest({
url,
provider: "moonshot",
headers,
body,
timeoutMs: params.timeoutMs,
fetchFn,
allowPrivateNetwork,
});
try {

View File

@@ -1,74 +0,0 @@
import { describe, expect, it, vi } from "vitest";
const apiMocks = vi.hoisted(() => ({
clearTokenCache: vi.fn(),
getAccessToken: vi.fn().mockResolvedValue("token"),
sendC2CFileMessage: vi.fn(),
sendC2CImageMessage: vi.fn(),
sendC2CMessage: vi.fn(),
sendC2CVideoMessage: vi.fn(),
sendC2CVoiceMessage: vi.fn(),
sendChannelMessage: vi.fn(),
sendDmMessage: vi.fn(),
sendGroupFileMessage: vi.fn(),
sendGroupImageMessage: vi.fn(),
sendGroupMessage: vi.fn(),
sendGroupVideoMessage: vi.fn(),
sendGroupVoiceMessage: vi.fn(),
}));
vi.mock("./api.js", () => apiMocks);
import { handleStructuredPayload, type ReplyContext } from "./reply-dispatcher.js";
function buildCtx(): ReplyContext {
return {
target: {
type: "c2c",
senderId: "user-1",
messageId: "msg-1",
},
account: {
accountId: "default",
appId: "app-id",
clientSecret: "secret",
config: {},
} as ReplyContext["account"],
cfg: {},
log: {
info: vi.fn(),
error: vi.fn(),
},
};
}
describe("qqbot reply dispatcher", () => {
it("allows inline data image URLs for structured image payloads", async () => {
const ctx = buildCtx();
const recordActivity = vi.fn();
const dataUrl = "data:image/png;base64,Zm9v";
const handled = await handleStructuredPayload(
ctx,
`QQBOT_PAYLOAD:${JSON.stringify({
type: "media",
mediaType: "image",
source: "url",
path: dataUrl,
})}`,
recordActivity,
);
expect(handled).toBe(true);
expect(recordActivity).toHaveBeenCalledTimes(1);
expect(apiMocks.sendC2CImageMessage).toHaveBeenCalledWith(
"app-id",
"token",
"user-1",
dataUrl,
"msg-1",
undefined,
undefined,
);
});
});

View File

@@ -1,5 +1,3 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { textToSpeech as globalTextToSpeech } from "openclaw/plugin-sdk/speech-runtime";
@@ -27,7 +25,12 @@ import {
audioFileToSilkBase64,
formatDuration,
} from "./utils/audio-convert.js";
import { MAX_UPLOAD_SIZE, formatFileSize } from "./utils/file-utils.js";
import {
checkFileSize,
readFileAsync,
fileExistsAsync,
formatFileSize,
} from "./utils/file-utils.js";
import {
parseQQBotPayload,
encodePayloadForCron,
@@ -38,7 +41,7 @@ import {
import {
getQQBotDataDir,
normalizePath,
resolveQQBotPayloadLocalFilePath,
resolveQQBotLocalMediaPath,
sanitizeFileName,
} from "./utils/platform.js";
@@ -191,92 +194,23 @@ export async function handleStructuredPayload(
// Media payload handlers.
function validateStructuredPayloadLocalPath(
ctx: ReplyContext,
payloadPath: string,
mediaType: "image" | "video" | "file",
): string | null {
const allowedPath = resolveQQBotPayloadLocalFilePath(payloadPath);
if (allowedPath) {
return allowedPath;
}
ctx.log?.error(
`[qqbot:${ctx.account.accountId}] Blocked ${mediaType} payload local path outside QQ Bot media storage`,
);
return null;
}
function isRemoteHttpUrl(p: string): boolean {
return p.startsWith("http://") || p.startsWith("https://");
}
function isInlineImageDataUrl(p: string): boolean {
return /^data:image\/[^;]+;base64,/i.test(p);
}
function sanitizeForLog(value: string, maxLen = 200): string {
return value.replace(/[\r\n\t\0]/g, " ").slice(0, maxLen);
}
function describeMediaTargetForLog(pathValue: string, isHttpUrl: boolean): string {
if (!isHttpUrl) {
return "<local-file>";
}
try {
const url = new URL(pathValue);
url.username = "";
url.password = "";
const urlId = crypto.createHash("sha256").update(url.toString()).digest("hex").slice(0, 12);
return sanitizeForLog(`${url.protocol}//${url.host}#${urlId}`);
} catch {
return "<invalid-url>";
}
}
async function readStructuredPayloadLocalFile(filePath: string): Promise<Buffer> {
const openFlags =
fs.constants.O_RDONLY | ("O_NOFOLLOW" in fs.constants ? fs.constants.O_NOFOLLOW : 0);
const handle = await fs.promises.open(filePath, openFlags);
try {
const stat = await handle.stat();
if (!stat.isFile()) {
throw new Error("Path is not a regular file");
}
if (stat.size > MAX_UPLOAD_SIZE) {
throw new Error(
`File is too large (${formatFileSize(stat.size)}); QQ Bot API limit is ${formatFileSize(MAX_UPLOAD_SIZE)}`,
);
}
return handle.readFile();
} finally {
await handle.close();
}
}
async function handleImagePayload(ctx: ReplyContext, payload: MediaPayload): Promise<void> {
const { target, account, log } = ctx;
const normalizedPath = normalizePath(payload.path);
let imageUrl: string | null;
if (payload.source === "file") {
imageUrl = validateStructuredPayloadLocalPath(ctx, normalizedPath, "image");
} else if (isRemoteHttpUrl(normalizedPath) || isInlineImageDataUrl(normalizedPath)) {
imageUrl = normalizedPath;
} else {
log?.error(
`[qqbot:${account.accountId}] Image payload URL must use http(s) or data:image/: ${sanitizeForLog(payload.path)}`,
);
return;
}
if (!imageUrl) {
return;
}
let imageUrl = resolveQQBotLocalMediaPath(normalizePath(payload.path));
const originalImagePath = payload.source === "file" ? imageUrl : undefined;
if (payload.source === "file") {
try {
const fileBuffer = await readStructuredPayloadLocalFile(imageUrl);
if (!(await fileExistsAsync(imageUrl))) {
log?.error(`[qqbot:${account.accountId}] Image not found: ${imageUrl}`);
return;
}
const imgSzCheck = checkFileSize(imageUrl);
if (!imgSzCheck.ok) {
log?.error(`[qqbot:${account.accountId}] Image size check failed: ${imgSzCheck.error}`);
return;
}
const fileBuffer = await readFileAsync(imageUrl);
const base64Data = fileBuffer.toString("base64");
const ext = path.extname(imageUrl).toLowerCase();
const mimeTypes: Record<string, string> = {
@@ -471,93 +405,90 @@ async function handleAudioPayload(ctx: ReplyContext, payload: MediaPayload): Pro
async function handleVideoPayload(ctx: ReplyContext, payload: MediaPayload): Promise<void> {
const { target, account, log } = ctx;
try {
const originalPath = payload.path ?? "";
const normalizedPath = normalizePath(originalPath);
const isHttpUrl = isRemoteHttpUrl(normalizedPath);
const videoPath = isHttpUrl
? normalizedPath
: validateStructuredPayloadLocalPath(ctx, originalPath, "video");
if (!videoPath) {
return;
}
if (!videoPath.trim()) {
const videoPath = resolveQQBotLocalMediaPath(normalizePath(payload.path ?? ""));
if (!videoPath?.trim()) {
log?.error(`[qqbot:${account.accountId}] Video missing path`);
return;
}
} else {
const isHttpUrl = videoPath.startsWith("http://") || videoPath.startsWith("https://");
log?.info(`[qqbot:${account.accountId}] Video send: "${videoPath.slice(0, 60)}..."`);
log?.info(
`[qqbot:${account.accountId}] Video send: ${describeMediaTargetForLog(videoPath, isHttpUrl)}`,
);
await sendWithTokenRetry(
account.appId,
account.clientSecret,
async (token) => {
if (isHttpUrl) {
if (target.type === "c2c") {
await sendC2CVideoMessage(
account.appId,
token,
target.senderId,
videoPath,
undefined,
target.messageId,
);
} else if (target.type === "group" && target.groupOpenid) {
await sendGroupVideoMessage(
account.appId,
token,
target.groupOpenid,
videoPath,
undefined,
target.messageId,
);
} else if (target.type === "dm") {
log?.error(`[qqbot:${account.accountId}] Video not supported in DM`);
} else if (target.channelId) {
log?.error(`[qqbot:${account.accountId}] Video not supported in channel`);
}
} else {
if (!(await fileExistsAsync(videoPath))) {
throw new Error(`Video file does not exist: ${videoPath}`);
}
const vPaySzCheck = checkFileSize(videoPath);
if (!vPaySzCheck.ok) {
throw new Error(vPaySzCheck.error!);
}
const fileBuffer = await readFileAsync(videoPath);
const videoBase64 = fileBuffer.toString("base64");
log?.info(
`[qqbot:${account.accountId}] Read local video (${formatFileSize(fileBuffer.length)}): ${videoPath}`,
);
await sendWithTokenRetry(
account.appId,
account.clientSecret,
async (token) => {
if (isHttpUrl) {
if (target.type === "c2c") {
await sendC2CVideoMessage(
account.appId,
token,
target.senderId,
videoPath,
undefined,
target.messageId,
);
} else if (target.type === "group" && target.groupOpenid) {
await sendGroupVideoMessage(
account.appId,
token,
target.groupOpenid,
videoPath,
undefined,
target.messageId,
);
} else if (target.type === "dm") {
log?.error(`[qqbot:${account.accountId}] Video not supported in DM`);
} else if (target.channelId) {
log?.error(`[qqbot:${account.accountId}] Video not supported in channel`);
if (target.type === "c2c") {
await sendC2CVideoMessage(
account.appId,
token,
target.senderId,
undefined,
videoBase64,
target.messageId,
undefined,
videoPath,
);
} else if (target.type === "group" && target.groupOpenid) {
await sendGroupVideoMessage(
account.appId,
token,
target.groupOpenid,
undefined,
videoBase64,
target.messageId,
);
} else if (target.type === "dm") {
log?.error(`[qqbot:${account.accountId}] Video not supported in DM`);
} else if (target.channelId) {
log?.error(`[qqbot:${account.accountId}] Video not supported in channel`);
}
}
} else {
const fileBuffer = await readStructuredPayloadLocalFile(videoPath);
const videoBase64 = fileBuffer.toString("base64");
log?.info(
`[qqbot:${account.accountId}] Read local video (${formatFileSize(fileBuffer.length)}): ${describeMediaTargetForLog(videoPath, false)}`,
);
},
log,
account.accountId,
);
log?.info(`[qqbot:${account.accountId}] Video message sent`);
if (target.type === "c2c") {
await sendC2CVideoMessage(
account.appId,
token,
target.senderId,
undefined,
videoBase64,
target.messageId,
undefined,
videoPath,
);
} else if (target.type === "group" && target.groupOpenid) {
await sendGroupVideoMessage(
account.appId,
token,
target.groupOpenid,
undefined,
videoBase64,
target.messageId,
);
} else if (target.type === "dm") {
log?.error(`[qqbot:${account.accountId}] Video not supported in DM`);
} else if (target.channelId) {
log?.error(`[qqbot:${account.accountId}] Video not supported in channel`);
}
}
},
log,
account.accountId,
);
log?.info(`[qqbot:${account.accountId}] Video message sent`);
if (payload.caption) {
await sendTextToTarget(ctx, payload.caption);
if (payload.caption) {
await sendTextToTarget(ctx, payload.caption);
}
}
} catch (err) {
log?.error(`[qqbot:${account.accountId}] Video send failed: ${err}`);
@@ -567,90 +498,89 @@ async function handleVideoPayload(ctx: ReplyContext, payload: MediaPayload): Pro
async function handleFilePayload(ctx: ReplyContext, payload: MediaPayload): Promise<void> {
const { target, account, log } = ctx;
try {
const originalPath = payload.path ?? "";
const normalizedPath = normalizePath(originalPath);
const isHttpUrl = isRemoteHttpUrl(normalizedPath);
const filePath = isHttpUrl
? normalizedPath
: validateStructuredPayloadLocalPath(ctx, originalPath, "file");
if (!filePath) {
return;
}
if (!filePath.trim()) {
const filePath = resolveQQBotLocalMediaPath(normalizePath(payload.path ?? ""));
if (!filePath?.trim()) {
log?.error(`[qqbot:${account.accountId}] File missing path`);
return;
} else {
const isHttpUrl = filePath.startsWith("http://") || filePath.startsWith("https://");
const fileName = sanitizeFileName(path.basename(filePath));
log?.info(
`[qqbot:${account.accountId}] File send: "${filePath.slice(0, 60)}..." (${isHttpUrl ? "URL" : "local"})`,
);
await sendWithTokenRetry(
account.appId,
account.clientSecret,
async (token) => {
if (isHttpUrl) {
if (target.type === "c2c") {
await sendC2CFileMessage(
account.appId,
token,
target.senderId,
undefined,
filePath,
target.messageId,
fileName,
);
} else if (target.type === "group" && target.groupOpenid) {
await sendGroupFileMessage(
account.appId,
token,
target.groupOpenid,
undefined,
filePath,
target.messageId,
fileName,
);
} else if (target.type === "dm") {
log?.error(`[qqbot:${account.accountId}] File not supported in DM`);
} else if (target.channelId) {
log?.error(`[qqbot:${account.accountId}] File not supported in channel`);
}
} else {
if (!(await fileExistsAsync(filePath))) {
throw new Error(`File does not exist: ${filePath}`);
}
const fPaySzCheck = checkFileSize(filePath);
if (!fPaySzCheck.ok) {
throw new Error(fPaySzCheck.error!);
}
const fileBuffer = await readFileAsync(filePath);
const fileBase64 = fileBuffer.toString("base64");
if (target.type === "c2c") {
await sendC2CFileMessage(
account.appId,
token,
target.senderId,
fileBase64,
undefined,
target.messageId,
fileName,
filePath,
);
} else if (target.type === "group" && target.groupOpenid) {
await sendGroupFileMessage(
account.appId,
token,
target.groupOpenid,
fileBase64,
undefined,
target.messageId,
fileName,
);
} else if (target.type === "dm") {
log?.error(`[qqbot:${account.accountId}] File not supported in DM`);
} else if (target.channelId) {
log?.error(`[qqbot:${account.accountId}] File not supported in channel`);
}
}
},
log,
account.accountId,
);
log?.info(`[qqbot:${account.accountId}] File message sent`);
}
const fileName = sanitizeFileName(path.basename(filePath));
log?.info(
`[qqbot:${account.accountId}] File send: ${describeMediaTargetForLog(filePath, isHttpUrl)} (${isHttpUrl ? "URL" : "local"})`,
);
await sendWithTokenRetry(
account.appId,
account.clientSecret,
async (token) => {
if (isHttpUrl) {
if (target.type === "c2c") {
await sendC2CFileMessage(
account.appId,
token,
target.senderId,
undefined,
filePath,
target.messageId,
fileName,
);
} else if (target.type === "group" && target.groupOpenid) {
await sendGroupFileMessage(
account.appId,
token,
target.groupOpenid,
undefined,
filePath,
target.messageId,
fileName,
);
} else if (target.type === "dm") {
log?.error(`[qqbot:${account.accountId}] File not supported in DM`);
} else if (target.channelId) {
log?.error(`[qqbot:${account.accountId}] File not supported in channel`);
}
} else {
const fileBuffer = await readStructuredPayloadLocalFile(filePath);
const fileBase64 = fileBuffer.toString("base64");
if (target.type === "c2c") {
await sendC2CFileMessage(
account.appId,
token,
target.senderId,
fileBase64,
undefined,
target.messageId,
fileName,
filePath,
);
} else if (target.type === "group" && target.groupOpenid) {
await sendGroupFileMessage(
account.appId,
token,
target.groupOpenid,
fileBase64,
undefined,
target.messageId,
fileName,
);
} else if (target.type === "dm") {
log?.error(`[qqbot:${account.accountId}] File not supported in DM`);
} else if (target.channelId) {
log?.error(`[qqbot:${account.accountId}] File not supported in channel`);
}
}
},
log,
account.accountId,
);
log?.info(`[qqbot:${account.accountId}] File message sent`);
} catch (err) {
log?.error(`[qqbot:${account.accountId}] File send failed: ${err}`);
}

View File

@@ -2,23 +2,10 @@ import { execFile } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { decode, encode, isSilk } from "silk-wasm";
import { debugLog, debugError, debugWarn } from "./debug-log.js";
import { detectFfmpeg, isWindows } from "./platform.js";
type SilkWasm = typeof import("silk-wasm");
let _silkWasmPromise: Promise<SilkWasm | null> | null = null;
function loadSilkWasm(): Promise<SilkWasm | null> {
if (_silkWasmPromise) return _silkWasmPromise;
_silkWasmPromise = import("silk-wasm").catch((err) => {
debugWarn(
`[audio-convert] silk-wasm not available; SILK encode/decode disabled (${err instanceof Error ? err.message : String(err)})`,
);
return null;
});
return _silkWasmPromise;
}
/** Wrap PCM s16le bytes in a WAV container. */
function pcmToWav(
pcmData: Uint8Array,
@@ -85,14 +72,13 @@ export async function convertSilkToWav(
strippedBuf.byteLength,
);
const silk = await loadSilkWasm();
if (!silk || !silk.isSilk(rawData)) {
if (!isSilk(rawData)) {
return null;
}
// QQ voice commonly uses 24 kHz.
const sampleRate = 24000;
const result = await silk.decode(rawData, sampleRate);
const result = await decode(rawData, sampleRate);
const wavBuffer = pcmToWav(result.data, sampleRate);
@@ -407,12 +393,8 @@ export async function pcmToSilk(
pcmBuffer: Buffer,
sampleRate: number,
): Promise<{ silkBuffer: Buffer; duration: number }> {
const silk = await loadSilkWasm();
if (!silk) {
throw new Error("silk-wasm is not available; cannot encode PCM to SILK");
}
const pcmData = new Uint8Array(pcmBuffer.buffer, pcmBuffer.byteOffset, pcmBuffer.byteLength);
const result = await silk.encode(pcmData, sampleRate);
const result = await encode(pcmData, sampleRate);
return {
silkBuffer: Buffer.from(result.data.buffer, result.data.byteOffset, result.data.byteLength),
duration: result.duration,
@@ -468,8 +450,7 @@ export async function audioFileToSilkBase64(
if ([".slk", ".slac"].includes(ext)) {
const stripped = stripAmrHeader(buf);
const raw = new Uint8Array(stripped.buffer, stripped.byteOffset, stripped.byteLength);
const silk = await loadSilkWasm();
if (silk?.isSilk(raw)) {
if (isSilk(raw)) {
debugLog(`[audio-convert] SILK file, direct use: ${filePath} (${buf.length} bytes)`);
return buf.toString("base64");
}
@@ -483,8 +464,7 @@ export async function audioFileToSilkBase64(
strippedCheck.byteOffset,
strippedCheck.byteLength,
);
const silkForCheck = await loadSilkWasm();
if (silkForCheck?.isSilk(rawCheck) || silkForCheck?.isSilk(strippedRaw)) {
if (isSilk(rawCheck) || isSilk(strippedRaw)) {
debugLog(`[audio-convert] SILK detected by header: ${filePath} (${buf.length} bytes)`);
return buf.toString("base64");
}

View File

@@ -1,12 +1,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
getHomeDir,
resolveQQBotLocalMediaPath,
resolveQQBotPayloadLocalFilePath,
} from "./platform.js";
import { getHomeDir, resolveQQBotLocalMediaPath } from "./platform.js";
describe("qqbot local media path remapping", () => {
const createdPaths: string[] = [];
@@ -36,7 +31,6 @@ describe("qqbot local media path remapping", () => {
);
fs.mkdirSync(path.dirname(mediaFile), { recursive: true });
fs.writeFileSync(mediaFile, "image", "utf8");
createdPaths.push(path.dirname(mediaFile));
const missingWorkspacePath = path.join(
actualHome,
@@ -69,110 +63,7 @@ describe("qqbot local media path remapping", () => {
);
fs.mkdirSync(path.dirname(mediaFile), { recursive: true });
fs.writeFileSync(mediaFile, "image", "utf8");
createdPaths.push(path.dirname(mediaFile));
expect(resolveQQBotLocalMediaPath(mediaFile)).toBe(mediaFile);
});
it("blocks structured payload files outside QQ Bot storage", () => {
const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), "qqbot-platform-outside-"));
createdPaths.push(outsideRoot);
const outsideFile = path.join(outsideRoot, "secret.txt");
fs.writeFileSync(outsideFile, "secret", "utf8");
expect(resolveQQBotPayloadLocalFilePath(outsideFile)).toBeNull();
});
it("blocks structured payload paths that escape QQ Bot media via '..'", () => {
const escapedPath = path.join(
getHomeDir(),
".openclaw",
"media",
"qqbot",
"..",
"..",
"qqbot-escape.txt",
);
expect(resolveQQBotPayloadLocalFilePath(escapedPath)).toBeNull();
});
it("allows structured payload files inside the QQ Bot media directory", () => {
const actualHome = getHomeDir();
const openclawDir = path.join(actualHome, ".openclaw");
fs.mkdirSync(openclawDir, { recursive: true });
const testRoot = fs.mkdtempSync(path.join(openclawDir, "qqbot-platform-test-"));
createdPaths.push(testRoot);
const mediaFile = path.join(
actualHome,
".openclaw",
"media",
"qqbot",
"downloads",
path.basename(testRoot),
"allowed.png",
);
fs.mkdirSync(path.dirname(mediaFile), { recursive: true });
fs.writeFileSync(mediaFile, "image", "utf8");
createdPaths.push(path.dirname(mediaFile));
expect(resolveQQBotPayloadLocalFilePath(mediaFile)).toBe(mediaFile);
});
it("blocks structured payload files inside the QQ Bot data directory", () => {
const actualHome = getHomeDir();
const openclawDir = path.join(actualHome, ".openclaw");
fs.mkdirSync(openclawDir, { recursive: true });
const testRoot = fs.mkdtempSync(path.join(openclawDir, "qqbot-platform-test-"));
createdPaths.push(testRoot);
const dataFile = path.join(
actualHome,
".openclaw",
"qqbot",
"sessions",
path.basename(testRoot),
"session.json",
);
fs.mkdirSync(path.dirname(dataFile), { recursive: true });
fs.writeFileSync(dataFile, "{}", "utf8");
createdPaths.push(path.dirname(dataFile));
expect(resolveQQBotPayloadLocalFilePath(dataFile)).toBeNull();
});
it("allows legacy workspace paths when they remap into QQ Bot media storage", () => {
const actualHome = getHomeDir();
const openclawDir = path.join(actualHome, ".openclaw");
fs.mkdirSync(openclawDir, { recursive: true });
const testRoot = fs.mkdtempSync(path.join(openclawDir, "qqbot-platform-test-"));
createdPaths.push(testRoot);
const mediaFile = path.join(
actualHome,
".openclaw",
"media",
"qqbot",
"downloads",
path.basename(testRoot),
"legacy.png",
);
fs.mkdirSync(path.dirname(mediaFile), { recursive: true });
fs.writeFileSync(mediaFile, "image", "utf8");
createdPaths.push(path.dirname(mediaFile));
const missingWorkspacePath = path.join(
actualHome,
".openclaw",
"workspace",
"qqbot",
"downloads",
path.basename(testRoot),
"legacy.png",
);
expect(resolveQQBotPayloadLocalFilePath(missingWorkspacePath)).toBe(mediaFile);
});
});

View File

@@ -154,37 +154,6 @@ export function resolveQQBotLocalMediaPath(p: string): string {
return normalized;
}
/**
* Resolve a structured-payload local file path and enforce that it stays within
* QQ Bot-owned storage roots.
*/
export function resolveQQBotPayloadLocalFilePath(p: string): string | null {
const candidate = resolveQQBotLocalMediaPath(p);
if (!candidate.trim()) {
return null;
}
const resolvedCandidate = path.resolve(candidate);
if (!fs.existsSync(resolvedCandidate)) {
return null;
}
const canonicalCandidate = fs.realpathSync(resolvedCandidate);
const allowedRoots = [getQQBotMediaDir()];
for (const root of allowedRoots) {
const resolvedRoot = path.resolve(root);
const canonicalRoot = fs.existsSync(resolvedRoot)
? fs.realpathSync(resolvedRoot)
: resolvedRoot;
if (isPathWithinRoot(canonicalCandidate, canonicalRoot)) {
return canonicalCandidate;
}
}
return null;
}
// Filename normalization.
/**

View File

@@ -337,7 +337,6 @@ export async function resolveSlackAttachmentContent(params: {
export type SlackThreadStarter = {
text: string;
userId?: string;
botId?: string;
ts?: string;
files?: SlackFile[];
};
@@ -392,15 +391,7 @@ export async function resolveSlackThreadStarter(params: {
ts: params.threadTs,
limit: 1,
inclusive: true,
})) as {
messages?: Array<{
text?: string;
user?: string;
bot_id?: string;
ts?: string;
files?: SlackFile[];
}>;
};
})) as { messages?: Array<{ text?: string; user?: string; ts?: string; files?: SlackFile[] }> };
const message = response?.messages?.[0];
const text = (message?.text ?? "").trim();
if (!message || !text) {
@@ -409,7 +400,6 @@ export async function resolveSlackThreadStarter(params: {
const starter: SlackThreadStarter = {
text,
userId: message.user,
botId: message.bot_id,
ts: message.ts,
files: message.files,
};

View File

@@ -1,146 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { App } from "@slack/bolt";
import { resolveEnvelopeFormatOptions } from "openclaw/plugin-sdk/channel-inbound";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import type { SlackMessageEvent } from "../../types.js";
import { resolveSlackThreadContextData } from "./prepare-thread-context.js";
import { createInboundSlackTestContext, createSlackTestAccount } from "./prepare.test-helpers.js";
describe("resolveSlackThreadContextData", () => {
let fixtureRoot = "";
let caseId = 0;
function makeTmpStorePath() {
if (!fixtureRoot) {
throw new Error("fixtureRoot missing");
}
const dir = path.join(fixtureRoot, `case-${caseId++}`);
fs.mkdirSync(dir);
return { dir, storePath: path.join(dir, "sessions.json") };
}
beforeAll(() => {
fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-slack-thread-context-"));
});
afterAll(() => {
if (fixtureRoot) {
fs.rmSync(fixtureRoot, { recursive: true, force: true });
fixtureRoot = "";
}
});
function createThreadContext(params: { replies: unknown }) {
return createInboundSlackTestContext({
cfg: {
channels: { slack: { enabled: true, replyToMode: "all", groupPolicy: "open" } },
} as OpenClawConfig,
appClient: { conversations: { replies: params.replies } } as App["client"],
defaultRequireMention: false,
replyToMode: "all",
});
}
function createThreadMessage(overrides: Partial<SlackMessageEvent> = {}): SlackMessageEvent {
return {
channel: "C123",
channel_type: "channel",
user: "U1",
text: "current message",
ts: "101.000",
thread_ts: "100.000",
...overrides,
} as SlackMessageEvent;
}
it("omits non-allowlisted starter text and thread history messages", async () => {
const { storePath } = makeTmpStorePath();
const replies = vi.fn().mockResolvedValue({
messages: [
{ text: "starter secret", user: "U2", ts: "100.000" },
{ text: "assistant reply", bot_id: "B1", ts: "100.500" },
{ text: "blocked follow-up", user: "U2", ts: "100.700" },
{ text: "allowed follow-up", user: "U1", ts: "100.800" },
{ text: "current message", user: "U1", ts: "101.000" },
],
response_metadata: { next_cursor: "" },
});
const ctx = createThreadContext({ replies });
ctx.resolveUserName = async (id: string) => ({
name: id === "U1" ? "Alice" : "Mallory",
});
const result = await resolveSlackThreadContextData({
ctx,
account: createSlackTestAccount({ thread: { initialHistoryLimit: 20 } }),
message: createThreadMessage(),
isThreadReply: true,
threadTs: "100.000",
threadStarter: {
text: "starter secret",
userId: "U2",
ts: "100.000",
},
roomLabel: "#general",
storePath,
sessionKey: "thread-session",
allowFromLower: ["u1"],
allowNameMatching: false,
envelopeOptions: resolveEnvelopeFormatOptions({} as OpenClawConfig),
effectiveDirectMedia: null,
});
expect(result.threadStarterBody).toBeUndefined();
expect(result.threadLabel).toBe("Slack thread #general");
expect(result.threadHistoryBody).toContain("assistant reply");
expect(result.threadHistoryBody).toContain("allowed follow-up");
expect(result.threadHistoryBody).not.toContain("starter secret");
expect(result.threadHistoryBody).not.toContain("blocked follow-up");
expect(result.threadHistoryBody).not.toContain("current message");
expect(replies).toHaveBeenCalledTimes(1);
});
it("keeps starter text and history when allowNameMatching authorizes the sender", async () => {
const { storePath } = makeTmpStorePath();
const replies = vi.fn().mockResolvedValue({
messages: [
{ text: "starter from Alice", user: "U1", ts: "100.000" },
{ text: "blocked follow-up", user: "U2", ts: "100.700" },
{ text: "current message", user: "U1", ts: "101.000" },
],
response_metadata: { next_cursor: "" },
});
const ctx = createThreadContext({ replies });
ctx.resolveUserName = async (id: string) => ({
name: id === "U1" ? "Alice" : "Mallory",
});
const result = await resolveSlackThreadContextData({
ctx,
account: createSlackTestAccount({ thread: { initialHistoryLimit: 20 } }),
message: createThreadMessage(),
isThreadReply: true,
threadTs: "100.000",
threadStarter: {
text: "starter from Alice",
userId: "U1",
ts: "100.000",
},
roomLabel: "#general",
storePath,
sessionKey: "thread-session",
allowFromLower: ["alice"],
allowNameMatching: true,
envelopeOptions: resolveEnvelopeFormatOptions({} as OpenClawConfig),
effectiveDirectMedia: null,
});
expect(result.threadStarterBody).toBe("starter from Alice");
expect(result.threadLabel).toContain("starter from Alice");
expect(result.threadHistoryBody).toContain("starter from Alice");
expect(result.threadHistoryBody).not.toContain("blocked follow-up");
});
});

View File

@@ -3,7 +3,6 @@ import { readSessionUpdatedAt } from "openclaw/plugin-sdk/config-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import type { ResolvedSlackAccount } from "../../accounts.js";
import type { SlackMessageEvent } from "../../types.js";
import { resolveSlackAllowListMatch } from "../allow-list.js";
import type { SlackMonitorContext } from "../context.js";
import {
resolveSlackMedia,
@@ -20,27 +19,6 @@ export type SlackThreadContextData = {
threadStarterMedia: SlackMediaResult[] | null;
};
function isSlackThreadContextSenderAllowed(params: {
allowFromLower: string[];
allowNameMatching: boolean;
userId?: string;
userName?: string;
botId?: string;
}): boolean {
if (params.allowFromLower.length === 0 || params.botId) {
return true;
}
if (!params.userId) {
return false;
}
return resolveSlackAllowListMatch({
allowList: params.allowFromLower,
id: params.userId,
name: params.userName,
allowNameMatching: params.allowNameMatching,
}).allowed;
}
export async function resolveSlackThreadContextData(params: {
ctx: SlackMonitorContext;
account: ResolvedSlackAccount;
@@ -51,8 +29,6 @@ export async function resolveSlackThreadContextData(params: {
roomLabel: string;
storePath: string;
sessionKey: string;
allowFromLower: string[];
allowNameMatching: boolean;
envelopeOptions: ReturnType<
typeof import("openclaw/plugin-sdk/channel-inbound").resolveEnvelopeFormatOptions
>;
@@ -75,21 +51,7 @@ export async function resolveSlackThreadContextData(params: {
}
const starter = params.threadStarter;
const starterSenderName =
params.allowNameMatching && starter?.userId
? (await params.ctx.resolveUserName(starter.userId))?.name
: undefined;
const starterAllowed =
!starter ||
isSlackThreadContextSenderAllowed({
allowFromLower: params.allowFromLower,
allowNameMatching: params.allowNameMatching,
userId: starter.userId,
userName: starterSenderName,
botId: starter.botId,
});
if (starter?.text && starterAllowed) {
if (starter?.text) {
threadStarterBody = starter.text;
const snippet = starter.text.replace(/\s+/g, " ").slice(0, 80);
threadLabel = `Slack thread ${params.roomLabel}${snippet ? `: ${snippet}` : ""}`;
@@ -107,9 +69,6 @@ export async function resolveSlackThreadContextData(params: {
} else {
threadLabel = `Slack thread ${params.roomLabel}`;
}
if (starter?.text && !starterAllowed) {
logVerbose("slack: omitted non-allowlisted thread starter from context");
}
const threadInitialHistoryLimit = params.account.config?.thread?.initialHistoryLimit ?? 20;
threadSessionPreviousTimestamp = readSessionUpdatedAt({
@@ -142,25 +101,8 @@ export async function resolveSlackThreadContextData(params: {
}),
);
const allowedThreadHistory = threadHistory.filter((historyMsg) => {
const msgUser = historyMsg.userId ? userMap.get(historyMsg.userId) : null;
return isSlackThreadContextSenderAllowed({
allowFromLower: params.allowFromLower,
allowNameMatching: params.allowNameMatching,
userId: historyMsg.userId,
userName: msgUser?.name,
botId: historyMsg.botId,
});
});
const omittedHistoryCount = threadHistory.length - allowedThreadHistory.length;
if (omittedHistoryCount > 0) {
logVerbose(
`slack: omitted ${omittedHistoryCount} non-allowlisted thread message(s) from context`,
);
}
const historyParts: string[] = [];
for (const historyMsg of allowedThreadHistory) {
for (const historyMsg of threadHistory) {
const msgUser = historyMsg.userId ? userMap.get(historyMsg.userId) : null;
const msgSenderName =
msgUser?.name ?? (historyMsg.botId ? `Bot (${historyMsg.botId})` : "Unknown");
@@ -178,12 +120,10 @@ export async function resolveSlackThreadContextData(params: {
}),
);
}
if (historyParts.length > 0) {
threadHistoryBody = historyParts.join("\n\n");
logVerbose(
`slack: populated thread history with ${allowedThreadHistory.length} messages for new session`,
);
}
threadHistoryBody = historyParts.join("\n\n");
logVerbose(
`slack: populated thread history with ${threadHistory.length} messages for new session`,
);
}
}

Some files were not shown because too many files have changed in this diff Show More