mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-16 02:58:45 +08:00
Compare commits
4 Commits
feat/brows
...
fix/plugin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9a1731c2c | ||
|
|
7c6b4994fb | ||
|
|
dab087a24d | ||
|
|
8bbc467d5b |
@@ -6,10 +6,18 @@ class: standard
|
||||
capacity:
|
||||
market: spot
|
||||
strategy: most-available
|
||||
# Fail closed instead of silently falling back to on-demand while the
|
||||
# Azure-backed billing account is the default runner path.
|
||||
fallback: spot-only
|
||||
fallback: on-demand-after-120s
|
||||
hints: true
|
||||
availabilityZones:
|
||||
- eu-west-1a
|
||||
- eu-west-1b
|
||||
- eu-west-1c
|
||||
regions:
|
||||
- eu-west-1
|
||||
- eu-west-2
|
||||
- eu-central-1
|
||||
- us-east-1
|
||||
- us-west-2
|
||||
actions:
|
||||
workflow: .github/workflows/crabbox-hydrate.yml
|
||||
# Default AWS hydration uses local Actions replay. Use
|
||||
@@ -29,8 +37,6 @@ blacksmith:
|
||||
job: check
|
||||
ref: main
|
||||
aws:
|
||||
# AWS-specific overrides still pin direct `--provider aws` runs without
|
||||
# leaking AWS region names into the Azure default capacity fallback list.
|
||||
region: eu-west-1
|
||||
rootGB: 400
|
||||
sync:
|
||||
|
||||
26
.github/workflows/ci.yml
vendored
26
.github/workflows/ci.yml
vendored
@@ -601,7 +601,7 @@ jobs:
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: .artifacts/build-all-cache
|
||||
key: ${{ runner.os }}-build-all-v3-${{ hashFiles('package.json', 'pnpm-lock.yaml', 'npm-shrinkwrap.json', 'packages/plugin-sdk/package.json', 'packages/llm-core/package.json', 'packages/model-catalog-core/package.json', 'packages/memory-host-sdk/package.json', 'scripts/build-all.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entries.mjs', 'tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'src/plugin-sdk/**', 'packages/llm-core/src/**', 'packages/model-catalog-core/src/**', 'packages/memory-host-sdk/src/**', 'src/types/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'scripts/copy-export-html-templates.ts', 'scripts/lib/copy-assets.ts', 'src/auto-reply/reply/export-html/**') }}
|
||||
key: ${{ runner.os }}-build-all-v3-${{ hashFiles('package.json', 'pnpm-lock.yaml', 'npm-shrinkwrap.json', 'packages/plugin-sdk/package.json', 'packages/llm-core/package.json', 'packages/memory-host-sdk/package.json', 'scripts/build-all.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entries.mjs', 'tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'src/plugin-sdk/**', 'packages/llm-core/src/**', 'packages/memory-host-sdk/src/**', 'src/types/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'scripts/copy-export-html-templates.ts', 'scripts/lib/copy-assets.ts', 'src/auto-reply/reply/export-html/**') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-all-v3-
|
||||
|
||||
@@ -1403,7 +1403,7 @@ jobs:
|
||||
packages/plugin-sdk/dist
|
||||
extensions/*/dist/.boundary-tsc.tsbuildinfo
|
||||
extensions/*/dist/.boundary-tsc.stamp
|
||||
key: ${{ runner.os }}-extension-package-boundary-v1-${{ hashFiles('tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'packages/plugin-sdk/tsconfig.json', 'packages/llm-core/package.json', 'packages/model-catalog-core/package.json', 'scripts/check-extension-package-tsc-boundary.mjs', 'scripts/prepare-extension-package-boundary-artifacts.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-entries.mjs', 'src/plugin-sdk/**', 'src/auto-reply/**', 'packages/llm-core/src/**', 'packages/model-catalog-core/src/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'src/types/**', 'extensions/**', 'extensions/tsconfig.package-boundary*.json', 'package.json', 'pnpm-lock.yaml') }}
|
||||
key: ${{ runner.os }}-extension-package-boundary-v1-${{ hashFiles('tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'packages/plugin-sdk/tsconfig.json', 'packages/llm-core/package.json', 'scripts/check-extension-package-tsc-boundary.mjs', 'scripts/prepare-extension-package-boundary-artifacts.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-entries.mjs', 'src/plugin-sdk/**', 'src/auto-reply/**', 'packages/llm-core/src/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'src/types/**', 'extensions/**', 'extensions/tsconfig.package-boundary*.json', 'package.json', 'pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-extension-package-boundary-v1-
|
||||
|
||||
@@ -1420,22 +1420,14 @@ jobs:
|
||||
find src \
|
||||
-type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.mts' -o -name '*.cts' -o -name '*.js' -o -name '*.mjs' -o -name '*.json' \) \
|
||||
-exec touch -t 200001010000 {} +
|
||||
if [ -d packages/llm-core/src ]; then
|
||||
find packages/llm-core/src \
|
||||
-type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.mts' -o -name '*.cts' -o -name '*.js' -o -name '*.mjs' -o -name '*.json' \) \
|
||||
-exec touch -t 200001010000 {} +
|
||||
fi
|
||||
if [ -d packages/model-catalog-core/src ]; then
|
||||
find packages/model-catalog-core/src \
|
||||
-type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.mts' -o -name '*.cts' -o -name '*.js' -o -name '*.mjs' -o -name '*.json' \) \
|
||||
-exec touch -t 200001010000 {} +
|
||||
fi
|
||||
cache_inputs=(
|
||||
find packages/llm-core/src \
|
||||
-type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.mts' -o -name '*.cts' -o -name '*.js' -o -name '*.mjs' -o -name '*.json' \) \
|
||||
-exec touch -t 200001010000 {} +
|
||||
touch -t 200001010000 \
|
||||
tsconfig.json \
|
||||
tsconfig.plugin-sdk.dts.json \
|
||||
packages/plugin-sdk/tsconfig.json \
|
||||
packages/llm-core/package.json \
|
||||
packages/model-catalog-core/package.json \
|
||||
scripts/check-extension-package-tsc-boundary.mjs \
|
||||
scripts/prepare-extension-package-boundary-artifacts.mjs \
|
||||
scripts/write-plugin-sdk-entry-dts.ts \
|
||||
@@ -1443,12 +1435,6 @@ jobs:
|
||||
scripts/lib/plugin-sdk-entries.mjs \
|
||||
package.json \
|
||||
pnpm-lock.yaml
|
||||
)
|
||||
for cache_input in "${cache_inputs[@]}"; do
|
||||
if [ -e "$cache_input" ]; then
|
||||
touch -t 200001010000 "$cache_input"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Run additional check shard
|
||||
env:
|
||||
|
||||
@@ -78,7 +78,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Gateway/plugin metadata is process-stable: installs, manifests, catalogs, generated paths, bundled metadata. Changes require restart or explicit owner reload/install/doctor flow.
|
||||
- Runtime hot paths: no freshness polling (`stat`/`realpath`/JSON reread/hash). Reuse current snapshots, install records, discovery, lookup tables, root scopes, resolved paths.
|
||||
- Process-local metadata caches ok when lifecycle-owned and bounded/single-slot. Freshness exceptions need named owner + tests.
|
||||
- Inline comments: preserve reviewer context at the code site. Required for non-obvious cross-path/state invariants, lifecycle ordering, ownership boundaries, queue/dedupe symmetry, TTL/cache expiry, cleanup/release coupling, session/id adoption, fallback behavior, platform/dependency caps, deterministic ordering, compact encoded state, or intentional caller differences.
|
||||
- Inline comments: preserve reviewer context at the code site. Use for cross-path/state invariants, platform/dependency caps, deterministic ordering, compact encoded state, lifecycle ordering, ownership boundaries, session/id adoption, queue-depth symmetry, fallbacks, or intentional caller differences.
|
||||
- Comment shape: 1-3 short lines; state why the branch/helper exists, what contract it protects, and the bad outcome if removed. Cite nearby constants/helpers when useful. No syntax narration, PR/user-specific lore, or obvious mechanics.
|
||||
- Gateway protocol changes: additive first; incompatible needs versioning/docs/client follow-through.
|
||||
- Protocol version bumps: explicit owner confirmation only; never automatic/generated.
|
||||
|
||||
16
CHANGELOG.md
16
CHANGELOG.md
@@ -24,7 +24,6 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- CLI: keep `plugins list --json` on the snapshot-only path so plugin sweeps avoid loading the full runtime status graph.
|
||||
- Plugins: make PixVerse external-plugin ClawHub metadata explicit and keep it out of bundled dist builds.
|
||||
- Providers: bound generated media downloads from OpenAI, Runway, xAI, MiniMax, BytePlus, DashScope-compatible, FAL, OpenRouter, Google, Vydra, and Comfy providers.
|
||||
- Providers: cap GitHub Copilot OAuth request timeouts before creating abort signals.
|
||||
@@ -34,21 +33,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Security/config parsing: reject unsafe OAuth/token lifetimes, retry-after delays, inbound timestamps, response body sizes, command timeout config, sandbox observer token TTLs, and gateway WebSocket calls after close.
|
||||
- Providers/media: cap local service, model, usage, queue, generated media, TTS, music, workflow polling, and provider OAuth request timers across hosted and local providers.
|
||||
- Release/CI/E2E: bound release candidate reads, beta smoke REST calls, changelog restore, kitchen-sink and bundled plugin readiness probes, secret-provider probes, Vitest routing, and mainline test flakes. (#88127, #88137, #88155, #88160)
|
||||
- Release/CI/E2E: run the secret-provider integration proof through the repo pnpm runner so native macOS and Windows validation use the hydrated package-manager shim.
|
||||
- Release/CI/E2E: run the Telegram desktop proof gateway through the repo pnpm runner so native macOS proof uses the hydrated package-manager shim.
|
||||
- Docs/CI: run Mintlify anchor checks through the repo pnpm runner so docs link validation works when pnpm is only available through the hydrated package-manager shim.
|
||||
- Agents: keep configured fallback model metadata typed so provider params, context-token caps, and media input limits do not break changed-gate typechecks.
|
||||
- Agents: accept hidden `sessions_send` body aliases before validation while keeping the model-facing `message` schema canonical. (#88229) Thanks @zhangguiping-xydt.
|
||||
- CI/Crabbox: keep default runner capacity spot-only and provider-neutral so OpenClaw remote validation does not silently fall back to on-demand leases or stale AWS region hints.
|
||||
- CI/Crabbox: route Crabbox wrapper and Testbox workflow edits to their regression tests so changed-test gates do not silently run zero specs.
|
||||
- CI/workflows: route workflow sanity helper edits to their guard tests and cover composite-action input interpolation checks.
|
||||
- CI/tooling: route CI scope, dependency, changelog, and docs helper edits to their owner tests instead of silently skipping changed-test coverage.
|
||||
- CI/tooling: route package, release, and install helper edits to their owner tests so changed-test gates cover publish and installer script changes.
|
||||
- CI/tooling: route shared script library edits through their owner tests so lock, process, safety, and scan helpers do not skip changed-test coverage.
|
||||
- CI/tooling: skip expensive import-graph scans once a changed diff already requires broad fallback, keeping local changed-test planning fast while still collecting explicit owner tests.
|
||||
- CI/tooling: route script edits through conventional owner tests when matching `test/scripts` or `src/scripts` coverage already exists.
|
||||
- CI/tooling: honor option terminators in the memory FD repro script so follow-on arguments are not reparsed.
|
||||
- Release/CI/E2E: honor option terminators across release, Parallels smoke, plugin gauntlet, and extension-memory scripts.
|
||||
- Performance: reuse prepared provider handles, strict tool schemas, gateway runtime metadata, session maintenance config, plugin metadata, bundled skill allowlists, package-local plugin artifacts, and single-entry store writes.
|
||||
|
||||
## 2026.5.28
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
- Added hosted push relay defaults, realtime Talk playback, and safer WebSocket ping handling for mobile sessions.
|
||||
- Updated App Store screenshots to cover Gateway pairing, Command, Chat, Talk, Agent, and Settings flows.
|
||||
- Highlighted realtime Talk relay, Gateway connection status, node capabilities, push wake, and privacy controls.
|
||||
|
||||
## 2026.5.28 - 2026-05-28
|
||||
|
||||
|
||||
@@ -29,14 +29,6 @@ def clear_empty_env_var(key)
|
||||
ENV.delete(key) unless env_present?(ENV[key])
|
||||
end
|
||||
|
||||
def screenshot_upload_requested?
|
||||
ENV["DELIVER_SCREENSHOTS"] == "1"
|
||||
end
|
||||
|
||||
def screenshot_paths
|
||||
Dir[File.join(__dir__, "screenshots", "**", "*.png")]
|
||||
end
|
||||
|
||||
def maybe_decode_hex_keychain_secret(value)
|
||||
return value unless env_present?(value)
|
||||
|
||||
@@ -322,7 +314,6 @@ platform :ios do
|
||||
desc "Upload App Store metadata (and optionally screenshots)"
|
||||
lane :metadata do
|
||||
sync_ios_versioning!
|
||||
version_metadata = read_ios_version_metadata
|
||||
api_key = asc_api_key
|
||||
clear_empty_env_var("APP_STORE_CONNECT_API_KEY_PATH")
|
||||
app_identifier = ENV["ASC_APP_IDENTIFIER"]
|
||||
@@ -330,21 +321,11 @@ platform :ios do
|
||||
app_identifier = nil unless env_present?(app_identifier)
|
||||
app_id = nil unless env_present?(app_id)
|
||||
|
||||
if screenshot_upload_requested? && screenshot_paths.empty?
|
||||
UI.user_error!("DELIVER_SCREENSHOTS=1 but no PNG screenshots were found under apps/ios/fastlane/screenshots.")
|
||||
end
|
||||
|
||||
deliver_options = {
|
||||
api_key: api_key,
|
||||
force: true,
|
||||
app_version: version_metadata[:short_version],
|
||||
copyright: "2026 OpenClaw",
|
||||
primary_category: "PRODUCTIVITY",
|
||||
secondary_category: "UTILITIES",
|
||||
skip_screenshots: !screenshot_upload_requested?,
|
||||
skip_screenshots: ENV["DELIVER_SCREENSHOTS"] != "1",
|
||||
skip_metadata: ENV["DELIVER_METADATA"] != "1",
|
||||
skip_binary_upload: true,
|
||||
overwrite_screenshots: screenshot_upload_requested?,
|
||||
run_precheck_before_submit: false
|
||||
}
|
||||
deliver_options[:app_identifier] = app_identifier if app_identifier
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
OpenClaw is a personal AI assistant you run on your own devices.
|
||||
|
||||
Pair this iPhone app with your OpenClaw Gateway to use your phone as a secure node for chat, voice, approvals, sharing, and device-aware automation.
|
||||
Pair this iPhone app with your OpenClaw Gateway to connect your phone as a secure node for voice, camera, and device automation.
|
||||
|
||||
What you can do:
|
||||
- Pair with your private OpenClaw Gateway by QR code or setup code
|
||||
- Chat with your assistant from iPhone
|
||||
- Use realtime Talk mode and push-to-talk
|
||||
- Review Gateway action approvals from your phone
|
||||
- Use voice wake and push-to-talk
|
||||
- Capture photos and short clips on request
|
||||
- Record screen snippets for troubleshooting and workflows
|
||||
- Share text, links, and media directly from iOS into OpenClaw
|
||||
- Enable device capabilities such as camera, screen, location, photos, contacts, calendar, and reminders when you choose
|
||||
- Receive push wakes and node status updates for connected workflows
|
||||
- Run location-aware and device-aware automations
|
||||
|
||||
OpenClaw is local-first: you control your gateway, keys, configuration, and permissions. Device access is managed by iOS permissions and can be enabled only for the capabilities you want to use.
|
||||
OpenClaw is local-first: you control your gateway, keys, and configuration.
|
||||
|
||||
Getting started:
|
||||
1) Set up your OpenClaw Gateway
|
||||
2) Open the iOS app and pair with your gateway
|
||||
3) Start using chat, Talk mode, approvals, and automations from your phone
|
||||
3) Start using commands and automations from your phone
|
||||
|
||||
@@ -1 +1 @@
|
||||
openclaw,ai assistant,local ai,iphone ai,voice assistant,automation,gateway,chat,agent
|
||||
openclaw,ai assistant,local ai,voice assistant,automation,gateway,chat,agent,node
|
||||
|
||||
@@ -1 +1 @@
|
||||
Pair your iPhone with your OpenClaw Gateway for chat, realtime voice, approvals, device capabilities, and private automation.
|
||||
Run OpenClaw from your iPhone: pair with your own gateway, trigger automations, and use voice, camera, and share actions.
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
- Added hosted push relay defaults, realtime Talk playback, and safer WebSocket ping handling for mobile sessions.
|
||||
- Updated App Store screenshots to cover Gateway pairing, Command, Chat, Talk, Agent, and Settings flows.
|
||||
- Highlighted realtime Talk relay, Gateway connection status, node capabilities, push wake, and privacy controls.
|
||||
|
||||
@@ -326,8 +326,6 @@ Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`):
|
||||
openclaw plugins install -l ./my-plugin
|
||||
```
|
||||
|
||||
Standalone plugin files must be listed in `plugins.load.paths` rather than placed directly in `~/.openclaw/extensions` or `<workspace>/.openclaw/extensions`. Those auto-discovered roots load plugin package or bundle directories, while top-level script files are treated as local helpers and skipped.
|
||||
|
||||
<Note>
|
||||
`--force` is not supported with `--link` because linked installs reuse the source path instead of copying over a managed install target.
|
||||
|
||||
|
||||
@@ -54,16 +54,16 @@ Legend:
|
||||
|
||||
### Media delivery with block streaming
|
||||
|
||||
Streaming media must use structured payload fields such as `mediaUrl` or
|
||||
`mediaUrls`; streamed text is not parsed as an attachment command. When block
|
||||
streaming sends media early, OpenClaw remembers that delivery for the turn. If
|
||||
the final assistant payload repeats the same media URL, the final delivery
|
||||
strips the duplicate media instead of sending the attachment again.
|
||||
`MEDIA:` directives are normal delivery metadata. When block streaming sends a
|
||||
media block early, OpenClaw remembers that delivery for the turn. If the final
|
||||
assistant payload repeats the same media URL, the final delivery strips the
|
||||
duplicate media instead of sending the attachment again.
|
||||
|
||||
Exact duplicate final payloads are suppressed. If the final payload adds
|
||||
distinct text around media that was already streamed, OpenClaw still sends the
|
||||
new text while keeping the media single-delivery. This prevents duplicate voice
|
||||
notes or files on channels such as Telegram.
|
||||
notes or files on channels such as Telegram when an agent emits `MEDIA:` during
|
||||
streaming and the provider also includes it in the completed reply.
|
||||
|
||||
## Chunking algorithm (low/high bounds)
|
||||
|
||||
|
||||
@@ -214,8 +214,7 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and
|
||||
}
|
||||
```
|
||||
|
||||
- Loaded from package or bundle directories under `~/.openclaw/extensions` and `<workspace>/.openclaw/extensions`, plus files or directories listed in `plugins.load.paths`.
|
||||
- Put standalone plugin files in `plugins.load.paths`; auto-discovered extension roots ignore top-level `.js`, `.mjs`, and `.ts` files so helper scripts in those roots do not block startup.
|
||||
- Loaded from `~/.openclaw/extensions`, `<workspace>/.openclaw/extensions`, plus `plugins.load.paths`.
|
||||
- Discovery accepts native OpenClaw plugins plus compatible Codex bundles and Claude bundles, including manifestless Claude default-layout bundles.
|
||||
- **Config changes require a gateway restart.**
|
||||
- `allow`: optional allowlist (only listed plugins load). `deny` wins.
|
||||
|
||||
@@ -1737,7 +1737,7 @@ lives on the [Models FAQ](/help/faq-models).
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="My skill generated an image/PDF, but nothing was sent">
|
||||
Outbound attachments from the agent must use structured media fields such as `media`, `mediaUrl`, `path`, or `filePath`. See [OpenClaw assistant setup](/start/openclaw) and [Agent send](/tools/agent-send).
|
||||
Outbound attachments from the agent must include a `MEDIA:<path-or-url>` line (on its own line). See [OpenClaw assistant setup](/start/openclaw) and [Agent send](/tools/agent-send).
|
||||
|
||||
CLI sending:
|
||||
|
||||
@@ -1750,7 +1750,7 @@ lives on the [Models FAQ](/help/faq-models).
|
||||
- The target channel supports outbound media and isn't blocked by allowlists.
|
||||
- The file is within the provider's size limits (images are resized to max 2048px).
|
||||
- `tools.fs.workspaceOnly=true` keeps local-path sends limited to workspace, temp/media-store, and sandbox-validated files.
|
||||
- `tools.fs.workspaceOnly=false` lets structured local media sends use host-local files the agent can already read, but only for media plus safe document types (images, audio, video, PDF, and Office docs). Plain text and secret-like files are still blocked.
|
||||
- `tools.fs.workspaceOnly=false` lets `MEDIA:` send host-local files the agent can already read, but only for media plus safe document types (images, audio, video, PDF, and Office docs). Plain text and secret-like files are still blocked.
|
||||
|
||||
See [Images](/nodes/images).
|
||||
|
||||
|
||||
@@ -59,9 +59,9 @@ All camera access is gated behind **user-controlled settings**.
|
||||
|
||||
Like `canvas.*`, the iOS node only allows `camera.*` commands in the **foreground**. Background invocations return `NODE_BACKGROUND_UNAVAILABLE`.
|
||||
|
||||
### CLI helper
|
||||
### CLI helper (temp files + MEDIA)
|
||||
|
||||
The easiest way to get media files is via the CLI helper, which writes decoded media to a temp file and prints the saved path.
|
||||
The easiest way to get attachments is via the CLI helper, which writes decoded media to a temp file and prints `MEDIA:<path>`.
|
||||
|
||||
Examples:
|
||||
|
||||
@@ -126,12 +126,12 @@ Examples:
|
||||
|
||||
```bash
|
||||
openclaw nodes camera list --node <id> # list camera ids
|
||||
openclaw nodes camera snap --node <id> # prints saved path
|
||||
openclaw nodes camera snap --node <id> # prints MEDIA:<path>
|
||||
openclaw nodes camera snap --node <id> --max-width 1280
|
||||
openclaw nodes camera snap --node <id> --delay-ms 2000
|
||||
openclaw nodes camera snap --node <id> --device-id <id>
|
||||
openclaw nodes camera clip --node <id> --duration 10s # prints saved path
|
||||
openclaw nodes camera clip --node <id> --duration-ms 3000 # prints saved path (legacy flag)
|
||||
openclaw nodes camera clip --node <id> --duration 10s # prints MEDIA:<path>
|
||||
openclaw nodes camera clip --node <id> --duration-ms 3000 # prints MEDIA:<path> (legacy flag)
|
||||
openclaw nodes camera clip --node <id> --device-id <id>
|
||||
openclaw nodes camera clip --node <id> --no-audio
|
||||
```
|
||||
@@ -152,7 +152,7 @@ Notes:
|
||||
For _screen_ video (not camera), use the macOS companion:
|
||||
|
||||
```bash
|
||||
openclaw nodes screen record --node <id> --duration 10s --fps 15 # prints saved path
|
||||
openclaw nodes screen record --node <id> --duration 10s --fps 15 # prints MEDIA:<path>
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
@@ -218,7 +218,7 @@ and approve the new request so the gateway stores the updated command snapshot.
|
||||
|
||||
If the node is showing the Canvas (WebView), `canvas.snapshot` returns `{ format, base64 }`.
|
||||
|
||||
CLI helper (writes to a temp file and prints the saved path):
|
||||
CLI helper (writes to a temp file and prints `MEDIA:<path>`):
|
||||
|
||||
```bash
|
||||
openclaw nodes canvas snapshot --node <idOrNameOrIp> --format png
|
||||
|
||||
@@ -7,7 +7,7 @@ read_when:
|
||||
title: "iOS app"
|
||||
---
|
||||
|
||||
Availability: iPhone app builds are distributed through Apple channels when enabled for a release. Local development builds can also run from source.
|
||||
Availability: internal preview. The iOS app is not publicly distributed yet.
|
||||
|
||||
## What it does
|
||||
|
||||
|
||||
@@ -142,7 +142,6 @@ observation-only.
|
||||
**Subagents**
|
||||
|
||||
- `subagent_spawning` / `subagent_delivery_target` / `subagent_spawned` / `subagent_ended` - coordinate subagent routing and completion delivery
|
||||
- `subagent_spawned` includes `resolvedModel` and `resolvedProvider` when OpenClaw has resolved the child session's native model before launch.
|
||||
|
||||
**Lifecycle**
|
||||
|
||||
|
||||
@@ -108,18 +108,6 @@ Workboard also exposes optional agent tools for board-aware workflows:
|
||||
final summaries, proof, artifacts, created-card manifests, and blocker
|
||||
reasons. Created-card manifests must reference cards linked back to the
|
||||
completed card, which keeps phantom children out of summaries.
|
||||
- `workboard_board_create`, `workboard_board_archive`, and
|
||||
`workboard_board_delete` manage persisted board metadata such as display name,
|
||||
description, archive state, and default workspace.
|
||||
- `workboard_runs` returns the persisted run-attempt history stored on a card.
|
||||
- `workboard_specify` turns a rough triage or backlog card into a clarified
|
||||
`todo` card and records the specification summary on the card.
|
||||
- `workboard_decompose` fans a parent orchestration card into linked children,
|
||||
inherits board and tenant metadata, and can complete the parent with a
|
||||
created-card manifest.
|
||||
- `workboard_notify_subscribe`, `workboard_notify_list`, and
|
||||
`workboard_notify_unsubscribe` manage notification subscriptions in plugin
|
||||
state so operators and agents can discover durable notification intent.
|
||||
- `workboard_boards`, `workboard_stats`, `workboard_promote`,
|
||||
`workboard_reassign`, `workboard_reclaim`, `workboard_comment`,
|
||||
`workboard_proof`, `workboard_unblock`, and `workboard_dispatch` let an agent
|
||||
@@ -131,12 +119,6 @@ Claimed cards reject agent-tool mutations from other agents unless the caller
|
||||
has the claim token returned by `workboard_claim`. Dashboard operators still use
|
||||
the normal Gateway RPC surface and can recover or reassign cards.
|
||||
|
||||
Workboard stores all durable board data through the plugin SQLite key-value
|
||||
store. Cards live in `workboard.cards`, board metadata in `workboard.boards`,
|
||||
and notification subscriptions in `workboard.notify`. Run history, comments,
|
||||
proof, artifacts, diagnostics, dependencies, lifecycle events, and automation
|
||||
metadata stay on the card record so a card export remains self-contained.
|
||||
|
||||
Workboard diagnostics are computed from local card metadata. The built-in checks
|
||||
flag assigned cards that wait too long, running cards without recent heartbeat,
|
||||
blocked cards that need attention, repeated failures, done cards without proof,
|
||||
@@ -144,9 +126,9 @@ and running cards that only have a loose session link.
|
||||
|
||||
Dispatch is intentionally Gateway-local. It does not spawn arbitrary operating
|
||||
system processes; normal OpenClaw sessions still own execution. A dispatch nudge
|
||||
promotes dependency-ready cards, records dispatch metadata on ready cards,
|
||||
blocks expired claims or timed-out runs, and leaves durable notification
|
||||
subscriptions for the caller that delivers notifications.
|
||||
promotes dependency-ready cards, records dispatch metadata on ready cards, and
|
||||
blocks expired claims or timed-out runs so operators can recover them from the
|
||||
board.
|
||||
|
||||
## Session lifecycle sync
|
||||
|
||||
|
||||
@@ -1,51 +1,59 @@
|
||||
---
|
||||
summary: "Rich output protocol for structured media, embeds, audio hints, and replies"
|
||||
summary: "Rich output shortcode protocol for embeds, media, audio hints, and replies"
|
||||
read_when:
|
||||
- Changing assistant output rendering in the Control UI
|
||||
- Debugging `[embed ...]`, structured media, reply, or audio presentation directives
|
||||
- Debugging `[embed ...]`, `MEDIA:`, reply, or audio presentation directives
|
||||
title: "Rich output protocol"
|
||||
---
|
||||
|
||||
Assistant output can carry a small set of delivery/render directives:
|
||||
|
||||
- structured `mediaUrl` / `mediaUrls` fields for attachment delivery
|
||||
- `MEDIA:` for attachment delivery
|
||||
- `[[audio_as_voice]]` for audio presentation hints
|
||||
- `[[reply_to_current]]` / `[[reply_to:<id>]]` for reply metadata
|
||||
- `[embed ...]` for Control UI rich rendering
|
||||
|
||||
Remote media attachments must be public `https:` URLs. Plain `http:`,
|
||||
Remote `MEDIA:` attachments must be public `https:` URLs. Plain `http:`,
|
||||
loopback, link-local, private, and internal hostnames are ignored as attachment
|
||||
directives; server-side media fetchers still enforce their own network guards.
|
||||
|
||||
Local media attachments can use absolute paths, workspace-relative paths, or
|
||||
Local `MEDIA:` attachments can use absolute paths, workspace-relative paths, or
|
||||
home-relative `~/` paths. They still pass through the agent file-read policy and
|
||||
media type checks before delivery.
|
||||
|
||||
<Warning>
|
||||
Do not emit text commands for attachments from tools, plugins, streaming blocks,
|
||||
browser output, or message actions. Use structured media fields instead.
|
||||
`MEDIA:` is parsed only as plain text. Wrapping the directive in Markdown
|
||||
formatting (bold, inline code, fenced code) prevents the parser from
|
||||
recognizing it, and the attachment is silently dropped from delivery.
|
||||
|
||||
Valid message-tool payload:
|
||||
Valid:
|
||||
|
||||
```json
|
||||
{ "message": "Here is your image.", "mediaUrl": "/workspace/image.png" }
|
||||
```text
|
||||
MEDIA:/workspace/image.png
|
||||
```
|
||||
|
||||
Legacy final assistant reply text may still be normalized for compatibility, but
|
||||
it is not a general plugin/tool protocol.
|
||||
Invalid (parsed as prose, no attachment delivered):
|
||||
|
||||
```text
|
||||
**MEDIA:/workspace/image.png**
|
||||
`MEDIA:/workspace/image.png`
|
||||
Here is your image: MEDIA:/workspace/image.png
|
||||
```
|
||||
|
||||
Keep `MEDIA:` on its own line, in plain text, with no surrounding formatting.
|
||||
</Warning>
|
||||
|
||||
Plain Markdown image syntax stays text by default. Channels that intentionally
|
||||
map Markdown image replies to media attachments opt in at their outbound
|
||||
adapter; Telegram does this so `` can still become a media reply.
|
||||
|
||||
These directives are separate. Structured media fields and reply/voice tags are
|
||||
delivery metadata; `[embed ...]` is the web-only rich render path.
|
||||
These directives are separate. `MEDIA:` and reply/voice tags remain delivery metadata; `[embed ...]` is the web-only rich render path.
|
||||
Trusted tool-result media uses the same `MEDIA:` / `[[audio_as_voice]]` parser before delivery, so text tool outputs can still mark an audio attachment as a voice note.
|
||||
|
||||
When block streaming is enabled, media must be carried on structured payload
|
||||
fields. If the same media URL is sent in a streamed block and repeated in the
|
||||
final assistant payload, OpenClaw delivers the attachment once and strips the
|
||||
duplicate from the final payload.
|
||||
When block streaming is enabled, `MEDIA:` remains single-delivery metadata for a
|
||||
turn. If the same media URL is sent in a streamed block and repeated in the final
|
||||
assistant payload, OpenClaw delivers the attachment once and strips the duplicate
|
||||
from the final payload.
|
||||
|
||||
## `[embed ...]`
|
||||
|
||||
@@ -64,7 +72,7 @@ Rules:
|
||||
- Only URL-backed embeds are rendered. Use `ref="..."` or `url="..."`.
|
||||
- Block-form inline HTML embed shortcodes are not rendered.
|
||||
- The web UI strips the shortcode from visible text and renders the embed inline.
|
||||
- Structured media is not an embed alias and should not be used for rich embed rendering.
|
||||
- `MEDIA:` is not an embed alias and should not be used for rich embed rendering.
|
||||
|
||||
## Stored rendering shape
|
||||
|
||||
|
||||
@@ -196,21 +196,27 @@ Inbound attachments (images/audio/docs) can be surfaced to your command via temp
|
||||
- `{{MediaUrl}}` (pseudo-URL)
|
||||
- `{{Transcript}}` (if audio transcription is enabled)
|
||||
|
||||
Outbound attachments from the agent use structured media fields on the message tool or reply payload, such as `media`, `mediaUrl`, `mediaUrls`, `path`, or `filePath`. Example message-tool arguments:
|
||||
Outbound attachments from the agent: include `MEDIA:<path-or-url>` on its own line (no spaces). The directive must start the line as plain text, outside code fences and without Markdown wrappers such as bold or inline code. Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Here's the screenshot.",
|
||||
"mediaUrl": "https://example.com/screenshot.png"
|
||||
}
|
||||
```
|
||||
Here's the screenshot.
|
||||
MEDIA:https://example.com/screenshot.png
|
||||
```
|
||||
|
||||
OpenClaw sends structured media alongside the text. Legacy final assistant replies may still be normalized for compatibility, but tool output, browser output, streaming blocks, and message actions do not parse text as attachment commands.
|
||||
OpenClaw extracts these and sends them as media alongside the text.
|
||||
|
||||
These forms are not attachment directives and are sent as normal text:
|
||||
|
||||
```md
|
||||
**MEDIA:https://example.com/screenshot.png**
|
||||
`MEDIA:https://example.com/screenshot.png`
|
||||
Here is the screenshot: MEDIA:https://example.com/screenshot.png
|
||||
```
|
||||
|
||||
Local-path behavior follows the same file-read trust model as the agent:
|
||||
|
||||
- If `tools.fs.workspaceOnly` is `true`, outbound local media paths stay restricted to the OpenClaw temp root, the media cache, agent workspace paths, and sandbox-generated files.
|
||||
- If `tools.fs.workspaceOnly` is `false`, outbound local media can use host-local files the agent is already allowed to read.
|
||||
- If `tools.fs.workspaceOnly` is `true`, outbound `MEDIA:` local paths stay restricted to the OpenClaw temp root, the media cache, agent workspace paths, and sandbox-generated files.
|
||||
- If `tools.fs.workspaceOnly` is `false`, outbound `MEDIA:` can use host-local files the agent is already allowed to read.
|
||||
- Local paths can be absolute, workspace-relative, or home-relative with `~/`.
|
||||
- Host-local sends still only allow media and safe document types (images, audio, video, PDF, and Office documents). Plain text and secret-like files are not treated as sendable media.
|
||||
|
||||
|
||||
@@ -246,7 +246,7 @@ Snapshot flags at a glance:
|
||||
- `--format aria`: accessibility tree with `axN` refs. When Playwright is available, OpenClaw binds refs with backend DOM ids to the live page so follow-up actions can use them; otherwise treat the output as inspection-only.
|
||||
- `--efficient` (or `--mode efficient`): compact role snapshot preset. Set `browser.snapshotDefaults.mode: "efficient"` to make this the default (see [Gateway configuration](/gateway/configuration-reference#browser)).
|
||||
- `--interactive`, `--compact`, `--depth`, `--selector` force a role snapshot with `ref=e12` refs. `--frame "<iframe>"` scopes role snapshots to an iframe.
|
||||
- `--labels` adds a viewport-only screenshot with overlayed ref labels and prints the saved path.
|
||||
- `--labels` adds a viewport-only screenshot with overlayed ref labels (prints `MEDIA:<path>`).
|
||||
- `--urls` appends discovered link destinations to AI snapshots.
|
||||
|
||||
## Snapshots and refs
|
||||
|
||||
@@ -188,57 +188,6 @@ Browser settings live in `~/.openclaw/openclaw.json`.
|
||||
}
|
||||
```
|
||||
|
||||
### Screenshot vision (text-only model support)
|
||||
|
||||
When the main model is text-only (no vision/multimodal support), browser
|
||||
screenshots return image blocks that the model cannot read. Browser screenshots
|
||||
reuse the existing image-understanding configuration, so an image model
|
||||
configured for media understanding can describe screenshots as text without any
|
||||
browser-specific model settings.
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
media: {
|
||||
image: {
|
||||
models: [
|
||||
{ provider: "bytedance", model: "doubao-seed-2.0-pro" },
|
||||
// Add fallback candidates; first success wins
|
||||
{ provider: "openai", model: "gpt-4o" },
|
||||
],
|
||||
},
|
||||
// Shared media models also work when tagged for image support.
|
||||
// models: [{ provider: "openai", model: "gpt-4o", capabilities: ["image"] }],
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
// Existing image-model defaults are also honored.
|
||||
// imageModel: { primary: "openai/gpt-4o" },
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. Agent calls `browser screenshot` → image captured to disk as usual.
|
||||
2. The browser tool asks the existing image-understanding runtime whether it
|
||||
can describe the screenshot using configured media image models, shared media
|
||||
models, image-model defaults, or an auth-backed image provider.
|
||||
3. The vision model returns a text description, which is wrapped with
|
||||
`wrapExternalContent` (prompt injection guard) and returned to the agent
|
||||
as a text block instead of an image block.
|
||||
4. If image understanding is unavailable, skipped, or fails, the browser falls
|
||||
back to returning the original image block.
|
||||
|
||||
Use the existing `tools.media.image` / `tools.media.models` fields for model
|
||||
fallbacks, timeouts, byte limits, profiles, and provider request settings.
|
||||
|
||||
If the active main model already supports vision and no explicit image
|
||||
understanding model is configured, OpenClaw keeps the normal image result so the
|
||||
main model can read the screenshot directly.
|
||||
|
||||
<AccordionGroup>
|
||||
|
||||
<Accordion title="Ports and reachability">
|
||||
|
||||
@@ -144,10 +144,6 @@ session to confirm the effective tool list.
|
||||
- **Run timeout:** if `sessions_spawn.runTimeoutSeconds` is omitted, OpenClaw uses `agents.defaults.subagents.runTimeoutSeconds` when set; otherwise it falls back to `0` (no timeout).
|
||||
- **Task delivery:** native sub-agents receive the delegated task in their first visible `[Subagent Task]` message. The sub-agent system prompt carries runtime rules and routing context, not a hidden duplicate of the task.
|
||||
|
||||
Accepted native sub-agent spawns include the resolved child model metadata in
|
||||
the tool result: `resolvedModel` contains the applied model ref and
|
||||
`resolvedProvider` contains the provider prefix when the ref has one.
|
||||
|
||||
### Delegation prompt mode
|
||||
|
||||
`agents.defaults.subagents.delegationMode` controls prompt guidance only; it does not change tool policy or enforce delegation.
|
||||
|
||||
@@ -736,7 +736,7 @@ OpenAI/ElevenLabs output formats are fixed per channel (see above).
|
||||
|
||||
When `messages.tts.auto` is enabled, OpenClaw:
|
||||
|
||||
- Skips TTS if the reply already contains structured media.
|
||||
- Skips TTS if the reply already contains media or a `MEDIA:` directive.
|
||||
- Skips very short replies (under 10 chars).
|
||||
- Summarizes long replies when summaries are enabled, using
|
||||
`summaryModel` (or `agents.defaults.model.primary`).
|
||||
@@ -751,7 +751,7 @@ summary model), audio is skipped and the normal text reply is sent.
|
||||
```text
|
||||
Reply -> TTS enabled?
|
||||
no -> send text
|
||||
yes -> has media / short?
|
||||
yes -> has media / MEDIA: / short?
|
||||
yes -> send text
|
||||
no -> length > limit?
|
||||
no -> TTS -> attach audio
|
||||
|
||||
@@ -4130,50 +4130,6 @@ describe("active-memory plugin", () => {
|
||||
expect(cached?.summary).toBe("memory 1");
|
||||
});
|
||||
|
||||
it("drops cached active-memory results when the current clock is not a valid date timestamp", () => {
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
||||
const cacheKey = testing.buildCacheKey({
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:invalid-clock-cache",
|
||||
query: "cache invalid clock prompt",
|
||||
});
|
||||
testing.setCachedResult(
|
||||
cacheKey,
|
||||
{
|
||||
status: "ok",
|
||||
elapsedMs: 1,
|
||||
rawReply: "memory",
|
||||
summary: "memory",
|
||||
},
|
||||
15_000,
|
||||
);
|
||||
|
||||
nowSpy.mockReturnValue(Number.NaN);
|
||||
|
||||
expect(testing.getCachedResult(cacheKey)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not cache active-memory results when the expiry timestamp would exceed the valid date range", () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
|
||||
const cacheKey = testing.buildCacheKey({
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:overflow-cache",
|
||||
query: "cache overflow prompt",
|
||||
});
|
||||
testing.setCachedResult(
|
||||
cacheKey,
|
||||
{
|
||||
status: "ok",
|
||||
elapsedMs: 1,
|
||||
rawReply: "memory",
|
||||
summary: "memory",
|
||||
},
|
||||
15_000,
|
||||
);
|
||||
|
||||
expect(testing.getCachedResult(cacheKey)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips recall after consecutive timeouts when circuit breaker trips (#74054)", async () => {
|
||||
const CONFIGURED_TIMEOUT_MS = 25;
|
||||
testing.setMinimumTimeoutMsForTests(1);
|
||||
|
||||
@@ -13,11 +13,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/agent-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { closeActiveMemorySearchManager } from "openclaw/plugin-sdk/memory-host-search";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
parseStrictPositiveInteger,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
resolveLivePluginConfigObject,
|
||||
resolvePluginConfigObject,
|
||||
@@ -1364,12 +1360,7 @@ function getCachedResult(cacheKey: string): ActiveRecallResult | undefined {
|
||||
if (!cached) {
|
||||
return undefined;
|
||||
}
|
||||
const now = asDateTimestampMs(Date.now());
|
||||
if (
|
||||
now === undefined ||
|
||||
asDateTimestampMs(cached.expiresAt) === undefined ||
|
||||
cached.expiresAt <= now
|
||||
) {
|
||||
if (cached.expiresAt <= Date.now()) {
|
||||
activeRecallCache.delete(cacheKey);
|
||||
return undefined;
|
||||
}
|
||||
@@ -1377,27 +1368,19 @@ function getCachedResult(cacheKey: string): ActiveRecallResult | undefined {
|
||||
}
|
||||
|
||||
function setCachedResult(cacheKey: string, result: ActiveRecallResult, ttlMs: number): void {
|
||||
const rawNow = Date.now();
|
||||
const now = asDateTimestampMs(rawNow);
|
||||
const now = Date.now();
|
||||
if (
|
||||
activeRecallCache.size >= DEFAULT_MAX_CACHE_ENTRIES ||
|
||||
(now !== undefined && now - lastActiveRecallCacheSweepAt >= CACHE_SWEEP_INTERVAL_MS)
|
||||
now - lastActiveRecallCacheSweepAt >= CACHE_SWEEP_INTERVAL_MS
|
||||
) {
|
||||
sweepExpiredCacheEntries(now);
|
||||
if (now !== undefined) {
|
||||
lastActiveRecallCacheSweepAt = now;
|
||||
}
|
||||
}
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(ttlMs, { nowMs: rawNow });
|
||||
if (expiresAt === undefined) {
|
||||
activeRecallCache.delete(cacheKey);
|
||||
return;
|
||||
lastActiveRecallCacheSweepAt = now;
|
||||
}
|
||||
if (activeRecallCache.has(cacheKey)) {
|
||||
activeRecallCache.delete(cacheKey);
|
||||
}
|
||||
activeRecallCache.set(cacheKey, {
|
||||
expiresAt,
|
||||
expiresAt: now + ttlMs,
|
||||
result,
|
||||
});
|
||||
while (activeRecallCache.size > DEFAULT_MAX_CACHE_ENTRIES) {
|
||||
@@ -1409,13 +1392,9 @@ function setCachedResult(cacheKey: string, result: ActiveRecallResult, ttlMs: nu
|
||||
}
|
||||
}
|
||||
|
||||
function sweepExpiredCacheEntries(now = asDateTimestampMs(Date.now())): void {
|
||||
if (now === undefined) {
|
||||
activeRecallCache.clear();
|
||||
return;
|
||||
}
|
||||
function sweepExpiredCacheEntries(now = Date.now()): void {
|
||||
for (const [cacheKey, cached] of activeRecallCache.entries()) {
|
||||
if (asDateTimestampMs(cached.expiresAt) === undefined || cached.expiresAt <= now) {
|
||||
if (cached.expiresAt <= now) {
|
||||
activeRecallCache.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,32 +230,6 @@ describe("bedrock mantle discovery", () => {
|
||||
expect(getCachedIamToken("us-east-1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not cache generated IAM tokens when ttl expiry overflows", async () => {
|
||||
const tokenProvider = vi
|
||||
.fn<() => Promise<string>>()
|
||||
.mockResolvedValueOnce("bedrock-overflow-token-1") // pragma: allowlist secret
|
||||
.mockResolvedValueOnce("bedrock-overflow-token-2"); // pragma: allowlist secret
|
||||
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
|
||||
|
||||
await expect(
|
||||
generateBearerTokenFromIam({
|
||||
region: "us-east-1",
|
||||
now: () => 8_640_000_000_000_000,
|
||||
tokenProviderFactory,
|
||||
}),
|
||||
).resolves.toBe("bedrock-overflow-token-1");
|
||||
expect(getCachedIamToken("us-east-1")).toBeUndefined();
|
||||
|
||||
await expect(
|
||||
generateBearerTokenFromIam({
|
||||
region: "us-east-1",
|
||||
now: () => 8_640_000_000_000_000,
|
||||
tokenProviderFactory,
|
||||
}),
|
||||
).resolves.toBe("bedrock-overflow-token-2");
|
||||
expect(tokenProvider).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model discovery
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -563,24 +537,6 @@ describe("bedrock mantle discovery", () => {
|
||||
expect(tokenProvider).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("omits Mantle runtime IAM token expiry when the process clock is invalid", async () => {
|
||||
const tokenProvider = vi.fn(async () => "bedrock-api-key-invalid-clock"); // pragma: allowlist secret
|
||||
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
|
||||
|
||||
const resolved = await resolveMantleRuntimeBearerToken({
|
||||
apiKey: MANTLE_IAM_TOKEN_MARKER,
|
||||
env: {
|
||||
AWS_REGION: "us-east-1",
|
||||
} as NodeJS.ProcessEnv,
|
||||
now: () => Number.NaN,
|
||||
tokenProviderFactory,
|
||||
});
|
||||
expect(resolved).toEqual({
|
||||
apiKey: "bedrock-api-key-invalid-clock",
|
||||
});
|
||||
expect(tokenProvider).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns null for unsupported regions", async () => {
|
||||
const provider = await resolveImplicitMantleProvider({
|
||||
env: {
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/core";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import {
|
||||
isFutureDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import type {
|
||||
ModelDefinitionConfig,
|
||||
ModelProviderConfig,
|
||||
@@ -96,10 +92,9 @@ function getCachedIamTokenEntry(
|
||||
now: number = Date.now(),
|
||||
): { token: string; expiresAt: number } | undefined {
|
||||
const cached = iamTokenCache.get(region);
|
||||
if (cached && isFutureDateTimestampMs(cached.expiresAt, { nowMs: now })) {
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached;
|
||||
}
|
||||
iamTokenCache.delete(region);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -128,10 +123,7 @@ export async function generateBearerTokenFromIam(params: {
|
||||
region: params.region,
|
||||
expiresInSeconds: 7200, // 2 hours
|
||||
})();
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(IAM_TOKEN_TTL_MS, { nowMs: now });
|
||||
if (expiresAt !== undefined) {
|
||||
iamTokenCache.set(params.region, { token, expiresAt });
|
||||
}
|
||||
iamTokenCache.set(params.region, { token, expiresAt: now + IAM_TOKEN_TTL_MS });
|
||||
return token;
|
||||
} catch (error) {
|
||||
log.debug?.("Mantle IAM token generation unavailable", {
|
||||
@@ -179,11 +171,9 @@ export async function resolveMantleRuntimeBearerToken(params: {
|
||||
return undefined;
|
||||
}
|
||||
const refreshed = getCachedIamTokenEntry(region, now);
|
||||
const expiresAt =
|
||||
refreshed?.expiresAt ?? resolveExpiresAtMsFromDurationMs(IAM_TOKEN_TTL_MS, { nowMs: now });
|
||||
return {
|
||||
apiKey: refreshed?.token ?? token,
|
||||
...(expiresAt === undefined ? {} : { expiresAt }),
|
||||
expiresAt: refreshed?.expiresAt ?? now + IAM_TOKEN_TTL_MS,
|
||||
};
|
||||
}
|
||||
/** Reset the IAM token cache (for testing). */
|
||||
|
||||
@@ -256,28 +256,6 @@ describe("bedrock discovery", () => {
|
||||
expect(sendMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("skips cache when refreshInterval expiry overflows", async () => {
|
||||
sendMock
|
||||
.mockResolvedValueOnce({ modelSummaries: [baseActiveAnthropicSummary] })
|
||||
.mockResolvedValueOnce({ inferenceProfileSummaries: [] })
|
||||
.mockResolvedValueOnce({ modelSummaries: [baseActiveAnthropicSummary] })
|
||||
.mockResolvedValueOnce({ inferenceProfileSummaries: [] });
|
||||
|
||||
await discoverBedrockModels({
|
||||
region: "us-east-1",
|
||||
config: { refreshInterval: 1 },
|
||||
now: () => 8_640_000_000_000_000,
|
||||
clientFactory,
|
||||
});
|
||||
await discoverBedrockModels({
|
||||
region: "us-east-1",
|
||||
config: { refreshInterval: 1 },
|
||||
now: () => 8_640_000_000_000_000,
|
||||
clientFactory,
|
||||
});
|
||||
expect(sendMock).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("skips cache when refreshInterval is 0", async () => {
|
||||
sendMock
|
||||
.mockResolvedValueOnce({ modelSummaries: [baseActiveAnthropicSummary] })
|
||||
|
||||
@@ -5,10 +5,6 @@ import {
|
||||
} from "@aws-sdk/client-bedrock";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/core";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import {
|
||||
isFutureDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationSeconds,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import type {
|
||||
BedrockDiscoveryConfig,
|
||||
ModelDefinitionConfig,
|
||||
@@ -507,16 +503,11 @@ export async function discoverBedrockModels(params: {
|
||||
|
||||
if (refreshIntervalSeconds > 0) {
|
||||
const cached = discoveryCache.get(cacheKey);
|
||||
if (cached && isFutureDateTimestampMs(cached.expiresAt, { nowMs: now })) {
|
||||
if (cached.value) {
|
||||
return cached.value;
|
||||
}
|
||||
if (cached.inFlight) {
|
||||
return cached.inFlight;
|
||||
}
|
||||
if (cached?.value && cached.expiresAt > now) {
|
||||
return cached.value;
|
||||
}
|
||||
if (cached) {
|
||||
discoveryCache.delete(cacheKey);
|
||||
if (cached?.inFlight) {
|
||||
return cached.inFlight;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -590,27 +581,19 @@ export async function discoverBedrockModels(params: {
|
||||
})();
|
||||
|
||||
if (refreshIntervalSeconds > 0) {
|
||||
const expiresAt = resolveExpiresAtMsFromDurationSeconds(refreshIntervalSeconds, { nowMs: now });
|
||||
if (expiresAt !== undefined) {
|
||||
discoveryCache.set(cacheKey, {
|
||||
expiresAt,
|
||||
inFlight: discoveryPromise,
|
||||
});
|
||||
}
|
||||
discoveryCache.set(cacheKey, {
|
||||
expiresAt: now + refreshIntervalSeconds * 1000,
|
||||
inFlight: discoveryPromise,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const value = await discoveryPromise;
|
||||
if (refreshIntervalSeconds > 0) {
|
||||
const expiresAt = resolveExpiresAtMsFromDurationSeconds(refreshIntervalSeconds, {
|
||||
nowMs: now,
|
||||
discoveryCache.set(cacheKey, {
|
||||
expiresAt: now + refreshIntervalSeconds * 1000,
|
||||
value,
|
||||
});
|
||||
if (expiresAt !== undefined) {
|
||||
discoveryCache.set(cacheKey, {
|
||||
expiresAt,
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
return value;
|
||||
} catch (error) {
|
||||
|
||||
@@ -17,13 +17,6 @@ export {
|
||||
import { buildAnthropicVertexProvider } from "./provider-catalog.js";
|
||||
import { hasAnthropicVertexAvailableAuth } from "./region.js";
|
||||
|
||||
let streamRuntimeModulePromise: Promise<typeof import("./stream-runtime.js")> | null = null;
|
||||
|
||||
const loadStreamRuntimeModule = async () => {
|
||||
streamRuntimeModulePromise ??= import("./stream-runtime.js");
|
||||
return await streamRuntimeModulePromise;
|
||||
};
|
||||
|
||||
export function mergeImplicitAnthropicVertexProvider(params: {
|
||||
existing?: ReturnType<typeof buildAnthropicVertexProvider>;
|
||||
implicit: ReturnType<typeof buildAnthropicVertexProvider>;
|
||||
@@ -57,7 +50,7 @@ export function createAnthropicVertexStreamFn(
|
||||
baseURL?: string,
|
||||
deps?: AnthropicVertexStreamDeps,
|
||||
): StreamFn {
|
||||
const streamFnPromise = loadStreamRuntimeModule().then((runtime) =>
|
||||
const streamFnPromise = import("./stream-runtime.js").then((runtime) =>
|
||||
runtime.createAnthropicVertexStreamFn(projectId, region, baseURL, deps),
|
||||
);
|
||||
return async (model, context, options) => {
|
||||
@@ -71,7 +64,7 @@ export function createAnthropicVertexStreamFnForModel(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
deps?: AnthropicVertexStreamDeps,
|
||||
): StreamFn {
|
||||
const streamFnPromise = loadStreamRuntimeModule().then((runtime) =>
|
||||
const streamFnPromise = import("./stream-runtime.js").then((runtime) =>
|
||||
runtime.createAnthropicVertexStreamFnForModel(model, env, deps),
|
||||
);
|
||||
return async (...args) => {
|
||||
|
||||
@@ -72,8 +72,6 @@ function levelIds(profile: unknown): Array<unknown> {
|
||||
return (levels as Array<{ id?: unknown }>).map((level) => level.id);
|
||||
}
|
||||
|
||||
const ANTHROPIC_SETUP_TOKEN = `sk-ant-oat01-${"a".repeat(80)}`;
|
||||
|
||||
describe("anthropic provider replay hooks", () => {
|
||||
it("registers the claude-cli backend", () => {
|
||||
const captured = capturePluginRegistration({ register: anthropicPlugin.register });
|
||||
@@ -686,61 +684,6 @@ describe("anthropic provider replay hooks", () => {
|
||||
expect(normalized).toBeUndefined();
|
||||
});
|
||||
|
||||
it("stores setup-token expiry from a bounded duration", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_000);
|
||||
try {
|
||||
const provider = await registerSingleProviderPlugin(anthropicPlugin);
|
||||
const setupTokenAuth = provider.auth.find((entry) => entry.id === "setup-token");
|
||||
if (!setupTokenAuth) {
|
||||
throw new Error("expected Anthropic setup-token auth method");
|
||||
}
|
||||
|
||||
const result = await setupTokenAuth.run({
|
||||
opts: {
|
||||
token: ANTHROPIC_SETUP_TOKEN,
|
||||
tokenExpiresIn: "1h",
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(result?.profiles[0]?.credential).toMatchObject({
|
||||
type: "token",
|
||||
provider: "anthropic",
|
||||
token: ANTHROPIC_SETUP_TOKEN,
|
||||
expires: 3_601_000,
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("omits setup-token expiry when duration overflows the Date range", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(8_640_000_000_000_000);
|
||||
try {
|
||||
const provider = await registerSingleProviderPlugin(anthropicPlugin);
|
||||
const setupTokenAuth = provider.auth.find((entry) => entry.id === "setup-token");
|
||||
if (!setupTokenAuth) {
|
||||
throw new Error("expected Anthropic setup-token auth method");
|
||||
}
|
||||
|
||||
const result = await setupTokenAuth.run({
|
||||
opts: {
|
||||
token: ANTHROPIC_SETUP_TOKEN,
|
||||
tokenExpiresIn: "1h",
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(result?.profiles[0]?.credential).toEqual({
|
||||
type: "token",
|
||||
provider: "anthropic",
|
||||
token: ANTHROPIC_SETUP_TOKEN,
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves claude-cli synthetic oauth auth", async () => {
|
||||
readClaudeCliCredentialsForRuntimeMock.mockReset();
|
||||
readClaudeCliCredentialsForRuntimeMock.mockReturnValue({
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { formatCliCommand, parseDurationMs } from "openclaw/plugin-sdk/cli-runtime";
|
||||
import { resolveExpiresAtMsFromDurationMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import type {
|
||||
OpenClawPluginApi,
|
||||
ProviderAuthContext,
|
||||
@@ -129,9 +128,7 @@ function resolveAnthropicSetupTokenExpiry(rawExpiresIn?: unknown): number | unde
|
||||
if (typeof rawExpiresIn !== "string" || rawExpiresIn.trim().length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return resolveExpiresAtMsFromDurationMs(
|
||||
parseDurationMs(rawExpiresIn.trim(), { defaultUnit: "d" }),
|
||||
);
|
||||
return Date.now() + parseDurationMs(rawExpiresIn.trim(), { defaultUnit: "d" });
|
||||
}
|
||||
|
||||
async function runAnthropicSetupTokenAuth(ctx: ProviderAuthContext): Promise<ProviderAuthResult> {
|
||||
|
||||
@@ -151,72 +151,6 @@ describe("browser plugin", () => {
|
||||
sandboxBridgeUrl: "http://127.0.0.1:9999",
|
||||
allowHostControl: true,
|
||||
agentSessionKey: "agent:main:webchat:direct:123",
|
||||
mediaScope: {
|
||||
sessionKey: "agent:main:webchat:direct:123",
|
||||
chatType: "direct",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("passes runtime context needed for screenshot image understanding", async () => {
|
||||
const { api, registerTool } = createApi();
|
||||
registerBrowserPlugin(api);
|
||||
|
||||
const factory = mockCallArg(registerTool);
|
||||
if (typeof factory !== "function") {
|
||||
throw new Error("expected browser plugin to register a tool factory");
|
||||
}
|
||||
|
||||
const tool = factory({
|
||||
sessionKey: "agent:main:webchat:direct:123",
|
||||
agentDir: "/tmp/agent",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
activeModel: { provider: "openai", modelId: "gpt-5.5" },
|
||||
deliveryContext: { channel: "telegram" },
|
||||
});
|
||||
if (!tool || Array.isArray(tool)) {
|
||||
throw new Error("expected browser plugin to return a single tool");
|
||||
}
|
||||
|
||||
await tool.execute("call-1", { action: "status" });
|
||||
expect(runtimeApiMocks.createBrowserTool).toHaveBeenCalledWith({
|
||||
agentSessionKey: "agent:main:webchat:direct:123",
|
||||
agentDir: "/tmp/agent",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
activeModel: { provider: "openai", model: "gpt-5.5" },
|
||||
mediaScope: {
|
||||
sessionKey: "agent:main:webchat:direct:123",
|
||||
channel: "telegram",
|
||||
chatType: "direct",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("derives group chat type for browser media scope", async () => {
|
||||
const { api, registerTool } = createApi();
|
||||
registerBrowserPlugin(api);
|
||||
|
||||
const factory = mockCallArg(registerTool);
|
||||
if (typeof factory !== "function") {
|
||||
throw new Error("expected browser plugin to register a tool factory");
|
||||
}
|
||||
|
||||
const tool = factory({
|
||||
sessionKey: "agent:main:telegram:group:chat-123",
|
||||
messageChannel: "telegram",
|
||||
});
|
||||
if (!tool || Array.isArray(tool)) {
|
||||
throw new Error("expected browser plugin to return a single tool");
|
||||
}
|
||||
|
||||
await tool.execute("call-1", { action: "status" });
|
||||
expect(runtimeApiMocks.createBrowserTool).toHaveBeenCalledWith({
|
||||
agentSessionKey: "agent:main:telegram:group:chat-123",
|
||||
mediaScope: {
|
||||
sessionKey: "agent:main:telegram:group:chat-123",
|
||||
channel: "telegram",
|
||||
chatType: "group",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -15,35 +15,10 @@ import { BrowserToolSchema } from "./src/browser-tool.schema.js";
|
||||
|
||||
const EAGER_BROWSER_CONTROL_SERVICE_ENV = "OPENCLAW_EAGER_BROWSER_CONTROL_SERVER";
|
||||
|
||||
let browserRegistrationRuntimeModulePromise: Promise<
|
||||
typeof import("./register.runtime.js")
|
||||
> | null = null;
|
||||
|
||||
const loadBrowserRegistrationRuntimeModule = async () => {
|
||||
browserRegistrationRuntimeModulePromise ??= import("./register.runtime.js");
|
||||
return await browserRegistrationRuntimeModulePromise;
|
||||
};
|
||||
|
||||
function isTruthyEnvValue(value: string | undefined): boolean {
|
||||
return /^(?:1|true|yes|on)$/iu.test(value?.trim() ?? "");
|
||||
}
|
||||
|
||||
function deriveChatTypeFromSessionKey(
|
||||
sessionKey: string | undefined,
|
||||
): "direct" | "group" | "channel" | undefined {
|
||||
const tokens = new Set(sessionKey?.toLowerCase().split(":").filter(Boolean) ?? []);
|
||||
if (tokens.has("group")) {
|
||||
return "group";
|
||||
}
|
||||
if (tokens.has("channel")) {
|
||||
return "channel";
|
||||
}
|
||||
if (tokens.has("direct") || tokens.has("dm")) {
|
||||
return "direct";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const BROWSER_CLI_DESCRIPTOR = {
|
||||
name: "browser",
|
||||
description: "Manage OpenClaw's dedicated browser (Chrome/Chromium)",
|
||||
@@ -54,17 +29,6 @@ function createLazyBrowserTool(opts?: {
|
||||
sandboxBridgeUrl?: string;
|
||||
allowHostControl?: boolean;
|
||||
agentSessionKey?: string;
|
||||
agentDir?: string;
|
||||
workspaceDir?: string;
|
||||
activeModel?: {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
};
|
||||
mediaScope?: {
|
||||
sessionKey?: string;
|
||||
channel?: string;
|
||||
chatType?: string;
|
||||
};
|
||||
}): AnyAgentTool {
|
||||
const targetDefault = opts?.sandboxBridgeUrl ? "sandbox" : "host";
|
||||
const hostHint =
|
||||
@@ -87,59 +51,13 @@ function createLazyBrowserTool(opts?: {
|
||||
].join(" "),
|
||||
parameters: BrowserToolSchema,
|
||||
execute: async (toolCallId, args, signal, onUpdate) => {
|
||||
const { createBrowserTool } = await loadBrowserRegistrationRuntimeModule();
|
||||
const { createBrowserTool } = await import("./register.runtime.js");
|
||||
const tool = createBrowserTool(opts);
|
||||
return await tool.execute(toolCallId, args, signal, onUpdate);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createBrowserToolOptions(ctx: OpenClawPluginToolContext): {
|
||||
sandboxBridgeUrl?: string;
|
||||
allowHostControl?: boolean;
|
||||
agentSessionKey?: string;
|
||||
agentDir?: string;
|
||||
workspaceDir?: string;
|
||||
activeModel?: {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
};
|
||||
mediaScope?: {
|
||||
sessionKey?: string;
|
||||
channel?: string;
|
||||
chatType?: string;
|
||||
};
|
||||
} {
|
||||
const mediaChannel = ctx.deliveryContext?.channel ?? ctx.messageChannel;
|
||||
const mediaChatType = deriveChatTypeFromSessionKey(ctx.sessionKey);
|
||||
return {
|
||||
...(ctx.browser?.sandboxBridgeUrl ? { sandboxBridgeUrl: ctx.browser.sandboxBridgeUrl } : {}),
|
||||
...(ctx.browser?.allowHostControl !== undefined
|
||||
? { allowHostControl: ctx.browser.allowHostControl }
|
||||
: {}),
|
||||
...(ctx.sessionKey ? { agentSessionKey: ctx.sessionKey } : {}),
|
||||
...(ctx.agentDir ? { agentDir: ctx.agentDir } : {}),
|
||||
...(ctx.workspaceDir ? { workspaceDir: ctx.workspaceDir } : {}),
|
||||
...(ctx.activeModel?.provider || ctx.activeModel?.modelId
|
||||
? {
|
||||
activeModel: {
|
||||
provider: ctx.activeModel.provider,
|
||||
model: ctx.activeModel.modelId,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(ctx.sessionKey || mediaChannel
|
||||
? {
|
||||
mediaScope: {
|
||||
...(ctx.sessionKey ? { sessionKey: ctx.sessionKey } : {}),
|
||||
...(mediaChannel ? { channel: mediaChannel } : {}),
|
||||
...(mediaChatType ? { chatType: mediaChatType } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export const browserPluginReload = { restartPrefixes: ["browser"] };
|
||||
|
||||
export const browserPluginNodeHostCommands: OpenClawPluginNodeHostCommand[] = [
|
||||
@@ -147,7 +65,7 @@ export const browserPluginNodeHostCommands: OpenClawPluginNodeHostCommand[] = [
|
||||
command: "browser.proxy",
|
||||
cap: "browser",
|
||||
handle: async (paramsJSON) => {
|
||||
const { runBrowserProxyCommand } = await loadBrowserRegistrationRuntimeModule();
|
||||
const { runBrowserProxyCommand } = await import("./register.runtime.js");
|
||||
return await runBrowserProxyCommand(paramsJSON);
|
||||
},
|
||||
},
|
||||
@@ -155,7 +73,7 @@ export const browserPluginNodeHostCommands: OpenClawPluginNodeHostCommand[] = [
|
||||
|
||||
export const browserSecurityAuditCollectors: OpenClawPluginSecurityAuditCollector[] = [
|
||||
async (ctx) => {
|
||||
const { collectBrowserSecurityAuditFindings } = await loadBrowserRegistrationRuntimeModule();
|
||||
const { collectBrowserSecurityAuditFindings } = await import("./register.runtime.js");
|
||||
return collectBrowserSecurityAuditFindings(ctx);
|
||||
},
|
||||
];
|
||||
@@ -164,7 +82,7 @@ function createLazyBrowserPluginService(): OpenClawPluginService {
|
||||
let service: OpenClawPluginService | null = null;
|
||||
const loadService = async () => {
|
||||
if (!service) {
|
||||
const { createBrowserPluginService } = await loadBrowserRegistrationRuntimeModule();
|
||||
const { createBrowserPluginService } = await import("./register.runtime.js");
|
||||
service = createBrowserPluginService();
|
||||
}
|
||||
return service;
|
||||
@@ -191,7 +109,11 @@ function createLazyBrowserPluginService(): OpenClawPluginService {
|
||||
|
||||
export function registerBrowserPlugin(api: OpenClawPluginApi) {
|
||||
api.registerTool(((ctx: OpenClawPluginToolContext) =>
|
||||
createLazyBrowserTool(createBrowserToolOptions(ctx))) as OpenClawPluginToolFactory);
|
||||
createLazyBrowserTool({
|
||||
sandboxBridgeUrl: ctx.browser?.sandboxBridgeUrl,
|
||||
allowHostControl: ctx.browser?.allowHostControl,
|
||||
agentSessionKey: ctx.sessionKey,
|
||||
})) as OpenClawPluginToolFactory);
|
||||
api.registerCli(
|
||||
async ({ program }) => {
|
||||
const { registerBrowserCli } = await import("./src/cli/browser-cli.js");
|
||||
@@ -202,7 +124,7 @@ export function registerBrowserPlugin(api: OpenClawPluginApi) {
|
||||
api.registerGatewayMethod(
|
||||
BROWSER_REQUEST_GATEWAY_METHOD,
|
||||
async (opts) => {
|
||||
const { handleBrowserGatewayRequest } = await loadBrowserRegistrationRuntimeModule();
|
||||
const { handleBrowserGatewayRequest } = await import("./register.runtime.js");
|
||||
return await handleBrowserGatewayRequest(opts);
|
||||
},
|
||||
{
|
||||
|
||||
@@ -10,14 +10,12 @@ export function resolveRuntimeImageSanitization(): { maxDimensionPx: number } |
|
||||
}
|
||||
export {
|
||||
callGatewayTool,
|
||||
describeImageFile,
|
||||
imageResultFromFile,
|
||||
jsonResult,
|
||||
listNodes,
|
||||
readPositiveIntegerParam,
|
||||
readStringParam,
|
||||
resolveNodeIdFromList,
|
||||
saveMediaBuffer,
|
||||
selectDefaultNodeFromList,
|
||||
} from "./sdk-setup-tools.js";
|
||||
export type { AnyAgentTool, NodeListNode } from "./sdk-setup-tools.js";
|
||||
|
||||
@@ -146,9 +146,6 @@ vi.mock("./browser/session-tab-registry.js", () => sessionTabRegistryMocks);
|
||||
|
||||
const toolCommonMocks = vi.hoisted(() => ({
|
||||
imageResultFromFile: vi.fn(),
|
||||
describeImageFile: vi.fn(async () => ({ text: undefined, decision: { outcome: "skipped" } })),
|
||||
normalizeBrowserScreenshot: vi.fn(async (buffer: Buffer) => ({ buffer })),
|
||||
saveMediaBuffer: vi.fn(async () => ({ path: "/tmp/openclaw-media/resized.jpg" })),
|
||||
}));
|
||||
vi.mock("./sdk-setup-tools.js", async () => {
|
||||
const actual =
|
||||
@@ -157,8 +154,6 @@ vi.mock("./sdk-setup-tools.js", async () => {
|
||||
...actual,
|
||||
callGatewayTool: gatewayMocks.callGatewayTool,
|
||||
imageResultFromFile: toolCommonMocks.imageResultFromFile,
|
||||
describeImageFile: toolCommonMocks.describeImageFile,
|
||||
saveMediaBuffer: toolCommonMocks.saveMediaBuffer,
|
||||
listNodes: nodesUtilsMocks.listNodes,
|
||||
};
|
||||
});
|
||||
@@ -201,8 +196,6 @@ vi.mock("./browser-tool.runtime.js", () => {
|
||||
getBrowserProfileCapabilities: (profile: Record<string, unknown>) => ({
|
||||
usesChromeMcp: profile.driver === "existing-session",
|
||||
}),
|
||||
describeImageFile: toolCommonMocks.describeImageFile,
|
||||
saveMediaBuffer: toolCommonMocks.saveMediaBuffer,
|
||||
imageResultFromFile: toolCommonMocks.imageResultFromFile,
|
||||
jsonResult: (result: unknown) => ({
|
||||
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
|
||||
@@ -279,14 +272,6 @@ function resetBrowserToolMocks() {
|
||||
actionTimeoutMs: 60_000,
|
||||
});
|
||||
nodesUtilsMocks.listNodes.mockResolvedValue([]);
|
||||
toolCommonMocks.describeImageFile.mockResolvedValue({
|
||||
text: undefined,
|
||||
decision: { outcome: "skipped" },
|
||||
});
|
||||
toolCommonMocks.normalizeBrowserScreenshot.mockImplementation(async (buffer: Buffer) => ({
|
||||
buffer,
|
||||
}));
|
||||
toolCommonMocks.saveMediaBuffer.mockResolvedValue({ path: "/tmp/openclaw-media/resized.jpg" });
|
||||
browserToolTesting.setDepsForTest({
|
||||
browserAct: browserActionsMocks.browserAct as never,
|
||||
browserArmDialog: browserActionsMocks.browserArmDialog as never,
|
||||
@@ -302,13 +287,10 @@ function resetBrowserToolMocks() {
|
||||
browserStart: browserClientMocks.browserStart as never,
|
||||
browserStatus: browserClientMocks.browserStatus as never,
|
||||
browserStop: browserClientMocks.browserStop as never,
|
||||
describeImageFile: toolCommonMocks.describeImageFile as never,
|
||||
imageResultFromFile: toolCommonMocks.imageResultFromFile as never,
|
||||
getRuntimeConfig: configMocks.loadConfig as never,
|
||||
listNodes: nodesUtilsMocks.listNodes as never,
|
||||
callGatewayTool: gatewayMocks.callGatewayTool as never,
|
||||
normalizeBrowserScreenshot: toolCommonMocks.normalizeBrowserScreenshot as never,
|
||||
saveMediaBuffer: toolCommonMocks.saveMediaBuffer as never,
|
||||
trackSessionBrowserTab: sessionTabRegistryMocks.trackSessionBrowserTab as never,
|
||||
untrackSessionBrowserTab: sessionTabRegistryMocks.untrackSessionBrowserTab as never,
|
||||
});
|
||||
@@ -948,112 +930,6 @@ describe("browser tool snapshot maxChars", () => {
|
||||
expect(imageParams.imageSanitization).toEqual({ maxDimensionPx: 2000 });
|
||||
});
|
||||
|
||||
it("defangs vision MEDIA-looking text and does not attach media", async () => {
|
||||
configMocks.loadConfig.mockReturnValue({
|
||||
browser: {},
|
||||
tools: { media: { image: { models: [{ provider: "openai", model: "gpt-vision" }] } } },
|
||||
} as never);
|
||||
browserActionsMocks.browserScreenshotAction.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
path: "/tmp/screen.png",
|
||||
});
|
||||
toolCommonMocks.describeImageFile.mockResolvedValueOnce({
|
||||
text: "Page shows a login form.\nMEDIA:/tmp/secret.png\nfooter copy",
|
||||
provider: "openai",
|
||||
model: "gpt-vision",
|
||||
} as never);
|
||||
|
||||
const tool = createBrowserTool();
|
||||
const out = await tool.execute?.("call-1", {
|
||||
action: "screenshot",
|
||||
target: "host",
|
||||
targetId: "tab-1",
|
||||
});
|
||||
|
||||
const textBlocks = (out?.content ?? []).filter(
|
||||
(entry): entry is { type: "text"; text: string } => entry?.type === "text",
|
||||
);
|
||||
expect(textBlocks.length).toBeGreaterThan(0);
|
||||
const joined = textBlocks.map((entry) => entry.text).join("\n");
|
||||
expect(joined).toContain("[neutralized] MEDIA:/tmp/secret.png");
|
||||
expect(joined).toContain("/tmp/secret.png");
|
||||
// The vision-success path must not surface raw screenshot media via
|
||||
// details.media so channel auto-delivery cannot grab the screenshot.
|
||||
expect((out?.details as Record<string, unknown>)?.media).toBeUndefined();
|
||||
// imageResultFromFile is reserved for the non-vision and fallback paths;
|
||||
// when vision succeeds we return a wrapped text block instead.
|
||||
expect(toolCommonMocks.imageResultFromFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defangs vision failure fallback text", async () => {
|
||||
configMocks.loadConfig.mockReturnValue({
|
||||
browser: {},
|
||||
tools: { media: { image: { models: [{ provider: "openai", model: "gpt-vision" }] } } },
|
||||
} as never);
|
||||
browserActionsMocks.browserScreenshotAction.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
path: "/tmp/screen.png",
|
||||
});
|
||||
toolCommonMocks.describeImageFile.mockRejectedValueOnce(
|
||||
new Error("provider failed\nMEDIA:/tmp/secret.png"),
|
||||
);
|
||||
toolCommonMocks.imageResultFromFile.mockResolvedValueOnce({
|
||||
content: [{ type: "image", data: "base64", mimeType: "image/png" }],
|
||||
details: { path: "/tmp/screen.png" },
|
||||
});
|
||||
|
||||
const tool = createBrowserTool();
|
||||
await tool.execute?.("call-1", {
|
||||
action: "screenshot",
|
||||
target: "host",
|
||||
targetId: "tab-1",
|
||||
});
|
||||
|
||||
const imageParams = lastMockCallArg<{
|
||||
path: string;
|
||||
extraText?: string;
|
||||
}>(toolCommonMocks.imageResultFromFile, 0);
|
||||
expect(imageParams.path).toBe("/tmp/screen.png");
|
||||
expect(imageParams.extraText).toContain("[neutralized] MEDIA:/tmp/secret.png");
|
||||
expect(imageParams.extraText).toContain("/tmp/secret.png");
|
||||
});
|
||||
|
||||
it("preserves screenshot image sanitization on vision failure fallback", async () => {
|
||||
configMocks.loadConfig.mockReturnValue({
|
||||
browser: {},
|
||||
tools: { media: { image: { models: [{ provider: "openai", model: "gpt-vision" }] } } },
|
||||
agents: { defaults: { imageMaxDimensionPx: 1600 } },
|
||||
} as never);
|
||||
browserActionsMocks.browserScreenshotAction.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
path: "/tmp/screen.png",
|
||||
});
|
||||
toolCommonMocks.describeImageFile.mockRejectedValueOnce(
|
||||
new Error("vision provider unavailable"),
|
||||
);
|
||||
toolCommonMocks.imageResultFromFile.mockResolvedValueOnce({
|
||||
content: [{ type: "image", data: "base64", mimeType: "image/png" }],
|
||||
details: { path: "/tmp/screen.png" },
|
||||
});
|
||||
|
||||
const tool = createBrowserTool();
|
||||
await tool.execute?.("call-1", {
|
||||
action: "screenshot",
|
||||
target: "host",
|
||||
targetId: "tab-1",
|
||||
});
|
||||
|
||||
const imageParams = lastMockCallArg<{
|
||||
imageSanitization?: { maxDimensionPx?: number };
|
||||
extraText?: string;
|
||||
}>(toolCommonMocks.imageResultFromFile, 0);
|
||||
// Fallback path must carry the same image sanitization the non-vision
|
||||
// screenshot path applies; otherwise configured maxDimensionPx is silently
|
||||
// bypassed whenever vision fails.
|
||||
expect(imageParams.imageSanitization).toEqual({ maxDimensionPx: 1600 });
|
||||
expect(imageParams.extraText).toContain("browser screenshot vision failed");
|
||||
});
|
||||
|
||||
it("passes screenshot timeoutMs through the node browser proxy", async () => {
|
||||
mockSingleBrowserProxyNode();
|
||||
gatewayMocks.callGatewayTool.mockResolvedValueOnce({
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
browserStatus,
|
||||
browserStop,
|
||||
callGatewayTool,
|
||||
describeImageFile,
|
||||
getRuntimeConfig,
|
||||
getBrowserProfileCapabilities,
|
||||
imageResultFromFile,
|
||||
@@ -42,16 +41,12 @@ import {
|
||||
resolveExistingPathsWithinRoot,
|
||||
resolveNodeIdFromList,
|
||||
resolveProfile,
|
||||
saveMediaBuffer,
|
||||
selectDefaultNodeFromList,
|
||||
touchSessionBrowserTab,
|
||||
trackSessionBrowserTab,
|
||||
untrackSessionBrowserTab,
|
||||
} from "./browser-tool.runtime.js";
|
||||
import { DEFAULT_BROWSER_SCREENSHOT_TIMEOUT_MS } from "./browser/constants.js";
|
||||
import { normalizeBrowserScreenshot } from "./browser/screenshot.js";
|
||||
import { describeBrowserScreenshot, neutralizeMediaDirectives } from "./browser/vision.js";
|
||||
import { wrapExternalContent } from "./sdk-security-runtime.js";
|
||||
|
||||
const browserToolDeps = {
|
||||
browserAct,
|
||||
@@ -68,13 +63,10 @@ const browserToolDeps = {
|
||||
browserStart,
|
||||
browserStatus,
|
||||
browserStop,
|
||||
describeImageFile,
|
||||
getRuntimeConfig,
|
||||
imageResultFromFile,
|
||||
listNodes,
|
||||
callGatewayTool,
|
||||
normalizeBrowserScreenshot,
|
||||
saveMediaBuffer,
|
||||
touchSessionBrowserTab,
|
||||
trackSessionBrowserTab,
|
||||
untrackSessionBrowserTab,
|
||||
@@ -97,13 +89,10 @@ export const testing = {
|
||||
browserStart: typeof browserStart;
|
||||
browserStatus: typeof browserStatus;
|
||||
browserStop: typeof browserStop;
|
||||
describeImageFile: typeof describeImageFile;
|
||||
imageResultFromFile: typeof imageResultFromFile;
|
||||
getRuntimeConfig: typeof getRuntimeConfig;
|
||||
listNodes: typeof listNodes;
|
||||
callGatewayTool: typeof callGatewayTool;
|
||||
normalizeBrowserScreenshot: typeof normalizeBrowserScreenshot;
|
||||
saveMediaBuffer: typeof saveMediaBuffer;
|
||||
touchSessionBrowserTab: typeof touchSessionBrowserTab;
|
||||
trackSessionBrowserTab: typeof trackSessionBrowserTab;
|
||||
untrackSessionBrowserTab: typeof untrackSessionBrowserTab;
|
||||
@@ -125,14 +114,10 @@ export const testing = {
|
||||
browserToolDeps.browserStart = overrides?.browserStart ?? browserStart;
|
||||
browserToolDeps.browserStatus = overrides?.browserStatus ?? browserStatus;
|
||||
browserToolDeps.browserStop = overrides?.browserStop ?? browserStop;
|
||||
browserToolDeps.describeImageFile = overrides?.describeImageFile ?? describeImageFile;
|
||||
browserToolDeps.imageResultFromFile = overrides?.imageResultFromFile ?? imageResultFromFile;
|
||||
browserToolDeps.getRuntimeConfig = overrides?.getRuntimeConfig ?? getRuntimeConfig;
|
||||
browserToolDeps.listNodes = overrides?.listNodes ?? listNodes;
|
||||
browserToolDeps.callGatewayTool = overrides?.callGatewayTool ?? callGatewayTool;
|
||||
browserToolDeps.normalizeBrowserScreenshot =
|
||||
overrides?.normalizeBrowserScreenshot ?? normalizeBrowserScreenshot;
|
||||
browserToolDeps.saveMediaBuffer = overrides?.saveMediaBuffer ?? saveMediaBuffer;
|
||||
browserToolDeps.touchSessionBrowserTab =
|
||||
overrides?.touchSessionBrowserTab ?? touchSessionBrowserTab;
|
||||
browserToolDeps.trackSessionBrowserTab =
|
||||
@@ -446,17 +431,6 @@ export function createBrowserTool(opts?: {
|
||||
sandboxBridgeUrl?: string;
|
||||
allowHostControl?: boolean;
|
||||
agentSessionKey?: string;
|
||||
agentDir?: string;
|
||||
workspaceDir?: string;
|
||||
activeModel?: {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
};
|
||||
mediaScope?: {
|
||||
sessionKey?: string;
|
||||
channel?: string;
|
||||
chatType?: string;
|
||||
};
|
||||
}): AnyAgentTool {
|
||||
const targetDefault = opts?.sandboxBridgeUrl ? "sandbox" : "host";
|
||||
const hostHint =
|
||||
@@ -789,80 +763,11 @@ export function createBrowserTool(opts?: {
|
||||
profile,
|
||||
});
|
||||
touchTrackedTab(readStringValue(result.targetId) ?? targetId);
|
||||
const screenshotPath = result.path;
|
||||
const screenshotCfg = browserToolDeps.getRuntimeConfig();
|
||||
const imageSanitization = resolveRuntimeImageSanitization();
|
||||
try {
|
||||
const described = await describeBrowserScreenshot(
|
||||
{
|
||||
cfg: screenshotCfg,
|
||||
filePath: screenshotPath,
|
||||
agentDir: opts?.agentDir,
|
||||
workspaceDir: opts?.workspaceDir,
|
||||
activeModel: opts?.activeModel,
|
||||
mediaScope: opts?.mediaScope,
|
||||
imageSanitization,
|
||||
},
|
||||
{
|
||||
describeImageFile: browserToolDeps.describeImageFile,
|
||||
normalizeBrowserScreenshot: browserToolDeps.normalizeBrowserScreenshot,
|
||||
saveMediaBuffer: browserToolDeps.saveMediaBuffer,
|
||||
},
|
||||
);
|
||||
if (described) {
|
||||
const analyzedBy =
|
||||
described.provider && described.model
|
||||
? `${described.provider}/${described.model}`
|
||||
: "media image understanding";
|
||||
const headerLines = [`[analyzed by ${analyzedBy}]`];
|
||||
// Vision model descriptions contain web page content which is
|
||||
// untrusted external input — wrap it the same way snapshot and
|
||||
// tabs results are wrapped to mitigate prompt injection.
|
||||
const wrappedDescription = wrapExternalContent(
|
||||
neutralizeMediaDirectives(described.text.trim()),
|
||||
{
|
||||
source: "browser",
|
||||
includeWarning: true,
|
||||
},
|
||||
);
|
||||
const text = `${headerLines.join("\n")}\n${wrappedDescription}`;
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
details: {
|
||||
...(result as Record<string, unknown>),
|
||||
// Do NOT include details.media here — the vision path returns
|
||||
// a text description as the deliverable output. Exposing the raw
|
||||
// screenshot as media would cause channel delivery to auto-send
|
||||
// potentially sensitive page content. The local screenshot file
|
||||
// is still referenced in result.path for diagnostic purposes.
|
||||
vision: {
|
||||
provider: described.provider,
|
||||
model: described.model,
|
||||
decision: described.decision,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
// Fall back to returning the raw image block so the agent loop can
|
||||
// still recover. Provider/runtime error messages are untrusted
|
||||
// input too, so defang line-start final-reply media directives.
|
||||
const rawReason = err instanceof Error ? err.message : String(err);
|
||||
const reason = neutralizeMediaDirectives(rawReason);
|
||||
const extraText = `[browser screenshot vision failed: ${reason}]`;
|
||||
return await browserToolDeps.imageResultFromFile({
|
||||
label: "browser:screenshot",
|
||||
path: screenshotPath,
|
||||
extraText,
|
||||
details: result,
|
||||
imageSanitization,
|
||||
});
|
||||
}
|
||||
return await browserToolDeps.imageResultFromFile({
|
||||
label: "browser:screenshot",
|
||||
path: screenshotPath,
|
||||
path: result.path,
|
||||
details: result,
|
||||
imageSanitization,
|
||||
imageSanitization: resolveRuntimeImageSanitization(),
|
||||
});
|
||||
}
|
||||
case "navigate": {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { MAX_DATE_TIMESTAMP_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import type { Dialog, Page } from "playwright-core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
@@ -122,46 +121,6 @@ describe("observed browser dialogs", () => {
|
||||
observed.cleanup();
|
||||
});
|
||||
|
||||
it("does not arm next-dialog responses while the process clock is invalid", () => {
|
||||
const nowSpy = vi.spyOn(Date, "now");
|
||||
try {
|
||||
nowSpy.mockReturnValue(Number.NaN);
|
||||
const { page, emit } = createPageHarness();
|
||||
ensurePageState(page);
|
||||
const dialog = createDialog({ type: "alert", message: "Still pending" });
|
||||
|
||||
armObservedDialogResponseOnPage({ page, accept: false, timeoutMs: 1000 });
|
||||
emit("dialog", dialog);
|
||||
|
||||
expect(dialog.dismiss).not.toHaveBeenCalled();
|
||||
expect(getObservedBrowserStateForPage(page).dialogs.pending).toMatchObject([
|
||||
{ id: "d1", type: "alert", message: "Still pending" },
|
||||
]);
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not arm next-dialog responses when the expiry would overflow Date bounds", () => {
|
||||
const nowSpy = vi.spyOn(Date, "now");
|
||||
try {
|
||||
nowSpy.mockReturnValue(MAX_DATE_TIMESTAMP_MS);
|
||||
const { page, emit } = createPageHarness();
|
||||
ensurePageState(page);
|
||||
const dialog = createDialog({ type: "alert", message: "Still pending" });
|
||||
|
||||
armObservedDialogResponseOnPage({ page, accept: false, timeoutMs: 1000 });
|
||||
emit("dialog", dialog);
|
||||
|
||||
expect(dialog.dismiss).not.toHaveBeenCalled();
|
||||
expect(getObservedBrowserStateForPage(page).dialogs.pending).toMatchObject([
|
||||
{ id: "d1", type: "alert", message: "Still pending" },
|
||||
]);
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("aborts in-flight actions while keeping unarmed dialogs pending", async () => {
|
||||
const { page, emit } = createPageHarness();
|
||||
ensurePageState(page);
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import {
|
||||
isFutureDateTimestampMs,
|
||||
parseFiniteNumber,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { parseFiniteNumber } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type {
|
||||
Browser,
|
||||
@@ -358,7 +354,7 @@ function observeDialog(pageState: PageState, dialog: Dialog): void {
|
||||
pageState.pendingDialogs.push(pending);
|
||||
|
||||
const armed = pageState.armedDialogResponse;
|
||||
if (armed && isFutureDateTimestampMs(armed.expiresAt)) {
|
||||
if (armed && armed.expiresAt >= Date.now()) {
|
||||
clearArmedDialogResponse(pageState);
|
||||
void settleObservedDialog({
|
||||
state: pageState,
|
||||
@@ -795,13 +791,9 @@ export function armObservedDialogResponseOnPage(opts: {
|
||||
const state = ensurePageState(opts.page);
|
||||
clearArmedDialogResponse(state);
|
||||
const timeoutMs = resolveObservedDialogTimeoutMs(opts.timeoutMs);
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(timeoutMs);
|
||||
if (expiresAt === undefined) {
|
||||
return;
|
||||
}
|
||||
const response: ArmedDialogResponse = {
|
||||
accept: opts.accept,
|
||||
expiresAt,
|
||||
expiresAt: Date.now() + timeoutMs,
|
||||
...(opts.promptText !== undefined ? { promptText: opts.promptText } : {}),
|
||||
};
|
||||
response.timer = setTimeout(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { redactCdpUrl } from "../cdp.helpers.js";
|
||||
import { snapshotAria } from "../cdp.js";
|
||||
import { getChromeMcpPid, takeChromeMcpSnapshot } from "../chrome-mcp.js";
|
||||
import { getChromeMcpPid } from "../chrome-mcp.js";
|
||||
import { resolveBrowserExecutableForPlatform } from "../chrome.executables.js";
|
||||
import { resolveManagedBrowserHeadlessMode } from "../config.js";
|
||||
import { buildBrowserDoctorReport } from "../doctor.js";
|
||||
@@ -227,6 +227,7 @@ async function runBrowserLiveProbe(req: BrowserRequest, ctx: BrowserRouteContext
|
||||
try {
|
||||
const tab = await profileCtx.ensureTabAvailable();
|
||||
if (capabilities.usesChromeMcp) {
|
||||
const { takeChromeMcpSnapshot } = await import("../chrome-mcp.js");
|
||||
await takeChromeMcpSnapshot({
|
||||
profileName: profileCtx.profile.name,
|
||||
profile: profileCtx.profile,
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DEFAULT_BROWSER_SCREENSHOT_DESCRIPTION_PROMPT,
|
||||
describeBrowserScreenshot,
|
||||
neutralizeMediaDirectives,
|
||||
} from "./vision.js";
|
||||
|
||||
type DescribeFn = ReturnType<typeof vi.fn>;
|
||||
|
||||
function makeDeps(
|
||||
describe: DescribeFn,
|
||||
overrides?: {
|
||||
normalizeBrowserScreenshot?: ReturnType<typeof vi.fn>;
|
||||
saveMediaBuffer?: ReturnType<typeof vi.fn>;
|
||||
},
|
||||
) {
|
||||
return {
|
||||
describeImageFile: describe as never,
|
||||
normalizeBrowserScreenshot:
|
||||
(overrides?.normalizeBrowserScreenshot as never) ??
|
||||
(vi.fn(async (buffer: Buffer) => ({ buffer })) as never),
|
||||
saveMediaBuffer:
|
||||
(overrides?.saveMediaBuffer as never) ??
|
||||
(vi.fn(async () => ({ path: "/tmp/resized.jpg" })) as never),
|
||||
};
|
||||
}
|
||||
|
||||
async function withTempImage<T>(fn: (filePath: string) => Promise<T>): Promise<T> {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "browser-vision-"));
|
||||
const filePath = path.join(dir, "screenshot.png");
|
||||
await writeFile(filePath, Buffer.from("image"));
|
||||
try {
|
||||
return await fn(filePath);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe("describeBrowserScreenshot", () => {
|
||||
it("uses existing image understanding config with a browser screenshot prompt", async () => {
|
||||
const describe = vi.fn().mockResolvedValue({
|
||||
text: "A login screen.",
|
||||
provider: "openai",
|
||||
model: "gpt-vision",
|
||||
decision: { outcome: "success" },
|
||||
});
|
||||
|
||||
await withTempImage(async (filePath) => {
|
||||
const result = await describeBrowserScreenshot(
|
||||
{
|
||||
cfg: {
|
||||
tools: {
|
||||
media: { image: { models: [{ provider: "openai", model: "gpt-vision" }] } },
|
||||
},
|
||||
},
|
||||
filePath,
|
||||
agentDir: "/tmp/agent",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
activeModel: { provider: "anthropic", model: "claude-sonnet-4.6" },
|
||||
mediaScope: { sessionKey: "agent:main:telegram:dm:123", channel: "telegram" },
|
||||
},
|
||||
makeDeps(describe),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
text: "A login screen.",
|
||||
provider: "openai",
|
||||
model: "gpt-vision",
|
||||
decision: { outcome: "success" },
|
||||
});
|
||||
expect(describe).toHaveBeenCalledWith({
|
||||
filePath,
|
||||
cfg: {
|
||||
tools: {
|
||||
media: {
|
||||
image: {
|
||||
models: [{ provider: "openai", model: "gpt-vision" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
prompt: DEFAULT_BROWSER_SCREENSHOT_DESCRIPTION_PROMPT,
|
||||
agentDir: "/tmp/agent",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
activeModel: { provider: "anthropic", model: "claude-sonnet-4.6" },
|
||||
scopeContext: { sessionKey: "agent:main:telegram:dm:123", channel: "telegram" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("resizes screenshots before image understanding when image sanitization is configured", async () => {
|
||||
const describe = vi.fn().mockResolvedValue({ text: "Small screenshot." });
|
||||
const normalizeBrowserScreenshot = vi.fn(async () => ({
|
||||
buffer: Buffer.from("small"),
|
||||
contentType: "image/jpeg" as const,
|
||||
}));
|
||||
const saveMediaBuffer = vi.fn(async () => ({ path: "/tmp/resized.jpg" }));
|
||||
|
||||
await withTempImage(async (filePath) => {
|
||||
await describeBrowserScreenshot(
|
||||
{
|
||||
cfg: { browser: {} },
|
||||
filePath,
|
||||
imageSanitization: { maxDimensionPx: 800 },
|
||||
},
|
||||
makeDeps(describe, { normalizeBrowserScreenshot, saveMediaBuffer }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(normalizeBrowserScreenshot).toHaveBeenCalledWith(Buffer.from("image"), {
|
||||
maxSide: 800,
|
||||
});
|
||||
expect(saveMediaBuffer).toHaveBeenCalledWith(Buffer.from("small"), "image/jpeg", "browser");
|
||||
expect(describe.mock.calls[0][0].filePath).toBe("/tmp/resized.jpg");
|
||||
});
|
||||
|
||||
it("returns null when image understanding is skipped or not configured", async () => {
|
||||
const describe = vi.fn().mockResolvedValue({
|
||||
text: undefined,
|
||||
decision: { outcome: "skipped" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
describeBrowserScreenshot(
|
||||
{ cfg: { browser: {} }, filePath: "/tmp/screenshot.png" },
|
||||
makeDeps(describe),
|
||||
),
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("does not pass an incomplete active model to media understanding", async () => {
|
||||
const describe = vi.fn().mockResolvedValue({ text: "ok" });
|
||||
|
||||
await describeBrowserScreenshot(
|
||||
{
|
||||
cfg: {
|
||||
tools: {
|
||||
media: { image: { models: [{ provider: "openai", model: "gpt-vision" }] } },
|
||||
},
|
||||
},
|
||||
filePath: "/tmp/screenshot.png",
|
||||
activeModel: { model: "missing-provider" },
|
||||
},
|
||||
makeDeps(describe),
|
||||
);
|
||||
|
||||
expect(describe.mock.calls[0][0].activeModel).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("neutralizeMediaDirectives", () => {
|
||||
it("defangs line-start final-reply media directives", () => {
|
||||
expect(neutralizeMediaDirectives("ok\n MEDIA:/tmp/secret.png\nMEDIA:http://x/y.png")).toBe(
|
||||
"ok\n [neutralized] MEDIA:/tmp/secret.png\n[neutralized] MEDIA:http://x/y.png",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves prose mentions alone", () => {
|
||||
expect(neutralizeMediaDirectives("see MEDIA: as plain prose")).toBe(
|
||||
"see MEDIA: as plain prose",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
// Browser screenshot descriptions piggyback on the existing media image
|
||||
// understanding contract. No browser-specific model registry lives here.
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { describeImageFile as DescribeImageFileFn } from "openclaw/plugin-sdk/media-understanding-runtime";
|
||||
import type { saveMediaBuffer as SaveMediaBufferFn } from "../sdk-setup-tools.js";
|
||||
import type { normalizeBrowserScreenshot as NormalizeBrowserScreenshotFn } from "./screenshot.js";
|
||||
|
||||
export const DEFAULT_BROWSER_SCREENSHOT_DESCRIPTION_PROMPT =
|
||||
"Describe what is visible in this browser screenshot. Capture page layout, headings, primary content blocks, visible text, and notable interactive elements so a text-only assistant can reason about the page.";
|
||||
|
||||
export type BrowserScreenshotDescriptionContext = {
|
||||
cfg: OpenClawConfig;
|
||||
filePath: string;
|
||||
agentDir?: string;
|
||||
workspaceDir?: string;
|
||||
agentId?: string;
|
||||
activeModel?: {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
};
|
||||
mediaScope?: {
|
||||
sessionKey?: string;
|
||||
channel?: string;
|
||||
chatType?: string;
|
||||
};
|
||||
imageSanitization?: {
|
||||
maxDimensionPx?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type BrowserScreenshotDescriptionDeps = {
|
||||
describeImageFile: typeof DescribeImageFileFn;
|
||||
normalizeBrowserScreenshot: typeof NormalizeBrowserScreenshotFn;
|
||||
saveMediaBuffer: typeof SaveMediaBufferFn;
|
||||
};
|
||||
|
||||
export type BrowserScreenshotDescriptionResult = {
|
||||
text: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
decision?: unknown;
|
||||
};
|
||||
|
||||
function normalizeActiveModel(
|
||||
activeModel: BrowserScreenshotDescriptionContext["activeModel"],
|
||||
): { provider: string; model?: string } | undefined {
|
||||
const provider = activeModel?.provider?.trim();
|
||||
if (!provider) {
|
||||
return undefined;
|
||||
}
|
||||
const model = activeModel?.model?.trim();
|
||||
return model ? { provider, model } : { provider };
|
||||
}
|
||||
|
||||
async function resolveImageUnderstandingFilePath(
|
||||
ctx: BrowserScreenshotDescriptionContext,
|
||||
deps: BrowserScreenshotDescriptionDeps,
|
||||
): Promise<string> {
|
||||
const maxDimensionPx = ctx.imageSanitization?.maxDimensionPx;
|
||||
if (typeof maxDimensionPx !== "number" || !Number.isFinite(maxDimensionPx)) {
|
||||
return ctx.filePath;
|
||||
}
|
||||
|
||||
const source = await readFile(ctx.filePath);
|
||||
const normalized = await deps.normalizeBrowserScreenshot(source, {
|
||||
maxSide: Math.max(1, Math.floor(maxDimensionPx)),
|
||||
});
|
||||
if (normalized.buffer === source) {
|
||||
return ctx.filePath;
|
||||
}
|
||||
const saved = await deps.saveMediaBuffer(
|
||||
normalized.buffer,
|
||||
normalized.contentType ?? "image/jpeg",
|
||||
"browser",
|
||||
);
|
||||
return saved.path;
|
||||
}
|
||||
|
||||
export async function describeBrowserScreenshot(
|
||||
ctx: BrowserScreenshotDescriptionContext,
|
||||
deps: BrowserScreenshotDescriptionDeps,
|
||||
): Promise<BrowserScreenshotDescriptionResult | null> {
|
||||
const filePath = await resolveImageUnderstandingFilePath(ctx, deps);
|
||||
const described = await deps.describeImageFile({
|
||||
filePath,
|
||||
cfg: ctx.cfg,
|
||||
prompt: DEFAULT_BROWSER_SCREENSHOT_DESCRIPTION_PROMPT,
|
||||
agentDir: ctx.agentDir,
|
||||
workspaceDir: ctx.workspaceDir,
|
||||
activeModel: normalizeActiveModel(ctx.activeModel),
|
||||
scopeContext: ctx.mediaScope,
|
||||
});
|
||||
const text = described.text?.trim();
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
text,
|
||||
provider: described.provider,
|
||||
model: described.model,
|
||||
decision: described.decision,
|
||||
};
|
||||
}
|
||||
|
||||
export function neutralizeMediaDirectives(text: string): string {
|
||||
if (!text || !/media:/i.test(text)) {
|
||||
return text;
|
||||
}
|
||||
const lines = text.split("\n");
|
||||
let changed = false;
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
const leading = line.length - line.trimStart().length;
|
||||
const rest = line.slice(leading);
|
||||
if (/^MEDIA:/i.test(rest)) {
|
||||
lines[i] = `${line.slice(0, leading)}[neutralized] ${rest}`;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? lines.join("\n") : text;
|
||||
}
|
||||
@@ -41,7 +41,7 @@ export function registerBrowserInspectCommands(
|
||||
) {
|
||||
browser
|
||||
.command("screenshot")
|
||||
.description("Capture a screenshot (prints the saved path)")
|
||||
.description("Capture a screenshot (MEDIA:<path>)")
|
||||
.argument("[targetId]", "CDP target id (or unique prefix)")
|
||||
.option("--full-page", "Capture full scrollable page", false)
|
||||
.option("--ref <ref>", "ARIA ref from ai snapshot")
|
||||
@@ -73,7 +73,7 @@ export function registerBrowserInspectCommands(
|
||||
defaultRuntime.writeJson(result);
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(shortenHomePath(result.path));
|
||||
defaultRuntime.log(`MEDIA:${shortenHomePath(result.path)}`);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
@@ -161,7 +161,7 @@ export function registerBrowserInspectCommands(
|
||||
} else {
|
||||
defaultRuntime.log(shortenHomePath(opts.out));
|
||||
if (result.format === "ai" && result.imagePath) {
|
||||
defaultRuntime.log(shortenHomePath(result.imagePath));
|
||||
defaultRuntime.log(`MEDIA:${shortenHomePath(result.imagePath)}`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -175,7 +175,7 @@ export function registerBrowserInspectCommands(
|
||||
if (result.format === "ai") {
|
||||
defaultRuntime.log(result.snapshot);
|
||||
if (result.imagePath) {
|
||||
defaultRuntime.log(shortenHomePath(result.imagePath));
|
||||
defaultRuntime.log(`MEDIA:${shortenHomePath(result.imagePath)}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ const browserCommandGroupDefinitions: readonly BrowserCommandGroupDefinition[] =
|
||||
},
|
||||
{
|
||||
placeholders: [
|
||||
command("screenshot", "Capture a screenshot (prints the saved path)"),
|
||||
command("screenshot", "Capture a screenshot (MEDIA:<path>)"),
|
||||
command("snapshot", "Capture a snapshot (default: ai; aria is the accessibility tree)"),
|
||||
],
|
||||
register: async (args) => {
|
||||
|
||||
@@ -29,5 +29,4 @@ export {
|
||||
} from "openclaw/plugin-sdk/media-runtime";
|
||||
export { detectMime } from "openclaw/plugin-sdk/media-mime";
|
||||
export { ensureMediaDir, saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime";
|
||||
export { describeImageFile } from "openclaw/plugin-sdk/media-understanding-runtime";
|
||||
export { formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
|
||||
|
||||
@@ -87,9 +87,9 @@ describe("canvas CLI", () => {
|
||||
expect(writtenFile.filePath).toMatch(/openclaw-canvas-snapshot-.*\.png$/);
|
||||
expect(writtenFile.base64).toBe("aGk=");
|
||||
expect(runtime.log).toHaveBeenCalledTimes(1);
|
||||
const savedPath = runtime.log.mock.calls[0]?.[0];
|
||||
expect(savedPath?.startsWith("MEDIA:")).toBe(false);
|
||||
expect(savedPath?.endsWith(".png")).toBe(true);
|
||||
const mediaMessage = runtime.log.mock.calls[0]?.[0];
|
||||
expect(mediaMessage?.startsWith("MEDIA:")).toBe(true);
|
||||
expect(mediaMessage?.endsWith(".png")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects node-controlled snapshot formats before writing", async () => {
|
||||
|
||||
@@ -260,7 +260,7 @@ export function registerNodesCanvasCommands(nodes: Command, deps: CanvasCliDepen
|
||||
deps.nodesCallOpts(
|
||||
canvas
|
||||
.command("snapshot")
|
||||
.description("Capture a canvas snapshot (prints the saved path)")
|
||||
.description("Capture a canvas snapshot (prints MEDIA:<path>)")
|
||||
.requiredOption("--node <idOrNameOrIp>", "Node id, name, or IP")
|
||||
.option("--format <png|jpg|jpeg>", "Image format", "jpg")
|
||||
.option("--max-width <px>", "Max width in px (optional)")
|
||||
@@ -287,7 +287,7 @@ export function registerNodesCanvasCommands(nodes: Command, deps: CanvasCliDepen
|
||||
deps.defaultRuntime.writeJson({ file: { path: filePath, format: payload.format } });
|
||||
return;
|
||||
}
|
||||
deps.defaultRuntime.log(deps.shortenHomePath(filePath));
|
||||
deps.defaultRuntime.log(`MEDIA:${deps.shortenHomePath(filePath)}`);
|
||||
});
|
||||
}),
|
||||
{ timeoutMs: 60_000 },
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { MAX_DATE_TIMESTAMP_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CodexAppInventoryCache,
|
||||
@@ -72,31 +71,6 @@ describe("Codex app inventory cache", () => {
|
||||
expect(refreshed.apps.map((item) => item.id)).toEqual(["app-2"]);
|
||||
});
|
||||
|
||||
it("marks inventory stale when the expiry would exceed the Date range", async () => {
|
||||
const cache = new CodexAppInventoryCache({ ttlMs: 100 });
|
||||
const request = vi.fn(async () => {
|
||||
return {
|
||||
data: [app("app-overflow")],
|
||||
nextCursor: null,
|
||||
} satisfies v2.AppsListResponse;
|
||||
});
|
||||
const key = "runtime";
|
||||
const snapshot = await cache.refreshNow({
|
||||
key,
|
||||
request,
|
||||
nowMs: MAX_DATE_TIMESTAMP_MS,
|
||||
});
|
||||
|
||||
expect(snapshot.expiresAtMs).toBe(0);
|
||||
const read = cache.read({
|
||||
key,
|
||||
request,
|
||||
nowMs: Date.parse("2026-05-29T12:00:00.000Z"),
|
||||
});
|
||||
expect(read.state).toBe("stale");
|
||||
expect(read.snapshot?.apps.map((item) => item.id)).toEqual(["app-overflow"]);
|
||||
});
|
||||
|
||||
it("records refresh errors without discarding the last successful snapshot", async () => {
|
||||
const cache = new CodexAppInventoryCache({ ttlMs: 1 });
|
||||
const key = "runtime";
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import { embeddedAgentLog } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
isFutureDateTimestampMs,
|
||||
resolveDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { JsonValue, v2 } from "./protocol.js";
|
||||
|
||||
@@ -76,7 +71,7 @@ export class CodexAppInventoryCache {
|
||||
}
|
||||
|
||||
read(params: RefreshParams): CodexAppInventoryCacheRead {
|
||||
const nowMs = resolveDateTimestampMs(params.nowMs);
|
||||
const nowMs = params.nowMs ?? Date.now();
|
||||
const entry = this.entries.get(params.key);
|
||||
if (!entry) {
|
||||
const refreshScheduled = params.suppressRefresh ? false : this.scheduleRefresh(params);
|
||||
@@ -92,9 +87,7 @@ export class CodexAppInventoryCache {
|
||||
}
|
||||
|
||||
const state: CodexAppInventoryReadState =
|
||||
entry.invalidated || !isFutureDateTimestampMs(entry.expiresAtMs, { nowMs })
|
||||
? "stale"
|
||||
: "fresh";
|
||||
entry.invalidated || entry.expiresAtMs <= nowMs ? "stale" : "fresh";
|
||||
const refreshScheduled =
|
||||
state === "fresh" && !params.forceRefetch ? false : this.scheduleRefresh(params);
|
||||
return {
|
||||
@@ -170,16 +163,15 @@ export class CodexAppInventoryCache {
|
||||
params: RefreshParams,
|
||||
refreshToken: number,
|
||||
): Promise<CodexAppInventorySnapshot> {
|
||||
const nowMs = resolveDateTimestampMs(params.nowMs);
|
||||
const nowMs = params.nowMs ?? Date.now();
|
||||
try {
|
||||
const apps = await listAllApps(params.request, params.forceRefetch ?? false);
|
||||
this.revision += 1;
|
||||
const expiresAtMs = resolveExpiresAtMsFromDurationMs(this.ttlMs, { nowMs }) ?? 0;
|
||||
const snapshot: CodexAppInventorySnapshot = {
|
||||
key: params.key,
|
||||
apps,
|
||||
fetchedAtMs: nowMs,
|
||||
expiresAtMs,
|
||||
expiresAtMs: nowMs + this.ttlMs,
|
||||
revision: this.revision,
|
||||
};
|
||||
// Only publish this snapshot if no newer refresh started for the same key
|
||||
|
||||
@@ -13,20 +13,13 @@ export type CodexAppServerClientFactory = (
|
||||
config?: AuthProfileOrderConfig,
|
||||
) => Promise<CodexAppServerClient>;
|
||||
|
||||
let sharedClientModulePromise: Promise<typeof import("./shared-client.js")> | null = null;
|
||||
|
||||
const loadSharedClientModule = async () => {
|
||||
sharedClientModulePromise ??= import("./shared-client.js");
|
||||
return await sharedClientModulePromise;
|
||||
};
|
||||
|
||||
export const defaultCodexAppServerClientFactory: CodexAppServerClientFactory = (
|
||||
startOptions,
|
||||
authProfileId,
|
||||
agentDir,
|
||||
config,
|
||||
) =>
|
||||
loadSharedClientModule().then(({ getSharedCodexAppServerClient }) =>
|
||||
import("./shared-client.js").then(({ getSharedCodexAppServerClient }) =>
|
||||
getSharedCodexAppServerClient({ startOptions, authProfileId, agentDir, config }),
|
||||
);
|
||||
|
||||
@@ -36,6 +29,6 @@ export const defaultLeasedCodexAppServerClientFactory: CodexAppServerClientFacto
|
||||
agentDir,
|
||||
config,
|
||||
) =>
|
||||
loadSharedClientModule().then(({ getLeasedSharedCodexAppServerClient }) =>
|
||||
import("./shared-client.js").then(({ getLeasedSharedCodexAppServerClient }) =>
|
||||
getLeasedSharedCodexAppServerClient({ startOptions, authProfileId, agentDir, config }),
|
||||
);
|
||||
|
||||
@@ -189,7 +189,7 @@ function resolveEffectiveExecHost(params: {
|
||||
|
||||
function readRuntimeSessionEntryBestEffort(sessionKey: string): SessionEntry | undefined {
|
||||
try {
|
||||
return getSessionEntry({ sessionKey, hydrateSkillPromptRefs: false });
|
||||
return getSessionEntry({ sessionKey });
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -77,31 +77,6 @@ describe("Codex app-server startup binding", () => {
|
||||
expect(savedBinding?.threadId).toBe("thread-existing");
|
||||
});
|
||||
|
||||
it("reuses the session record cache while sessions.json is unchanged", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
await writeSessionRecord(sessionFile, { totalTokens: 12_000 });
|
||||
const sessionsJson = path.join(path.dirname(sessionFile), "sessions.json");
|
||||
const readFileSpy = vi.spyOn(fs, "readFile");
|
||||
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
const binding = await rotateOversizedCodexAppServerStartupBinding({
|
||||
binding: await readCodexAppServerBinding(sessionFile),
|
||||
sessionFile,
|
||||
agentDir,
|
||||
config: undefined,
|
||||
});
|
||||
expect(binding?.threadId).toBe("thread-existing");
|
||||
}
|
||||
|
||||
const sessionStoreReads = readFileSpy.mock.calls.filter(
|
||||
([file]) => typeof file === "string" && file === sessionsJson,
|
||||
);
|
||||
expect(sessionStoreReads).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("checks native rollout token pressure under default compaction config", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
|
||||
@@ -30,14 +30,6 @@ const CODEX_APP_SERVER_BYTE_UNITS: Record<string, number> = {
|
||||
tb: 1024 * 1024 * 1024 * 1024,
|
||||
tib: 1024 * 1024 * 1024 * 1024,
|
||||
};
|
||||
type CodexSessionRecordCacheEntry = {
|
||||
sessionsFile: string;
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
record: (Record<string, unknown> & { sessionKey: string }) | undefined;
|
||||
};
|
||||
|
||||
const codexSessionRecordCache = new Map<string, CodexSessionRecordCacheEntry>();
|
||||
|
||||
function parseCodexAppServerByteLimit(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
@@ -120,34 +112,16 @@ async function readCodexSessionRecordForSessionFile(
|
||||
sessionFile: string,
|
||||
): Promise<(Record<string, unknown> & { sessionKey: string }) | undefined> {
|
||||
const sessionsFile = path.join(path.dirname(sessionFile), "sessions.json");
|
||||
const resolvedSessionFile = path.resolve(sessionFile);
|
||||
let stat: Awaited<ReturnType<typeof fs.stat>>;
|
||||
try {
|
||||
stat = await fs.stat(sessionsFile);
|
||||
} catch {
|
||||
codexSessionRecordCache.delete(resolvedSessionFile);
|
||||
return undefined;
|
||||
}
|
||||
const cached = codexSessionRecordCache.get(resolvedSessionFile);
|
||||
if (
|
||||
cached?.sessionsFile === sessionsFile &&
|
||||
cached.mtimeMs === stat.mtimeMs &&
|
||||
cached.size === stat.size
|
||||
) {
|
||||
return cached.record;
|
||||
}
|
||||
let store: JsonValue | undefined;
|
||||
try {
|
||||
store = JSON.parse(await fs.readFile(sessionsFile, "utf8")) as JsonValue;
|
||||
} catch {
|
||||
codexSessionRecordCache.delete(resolvedSessionFile);
|
||||
return undefined;
|
||||
}
|
||||
if (!isJsonObject(store)) {
|
||||
codexSessionRecordCache.delete(resolvedSessionFile);
|
||||
return undefined;
|
||||
}
|
||||
let found: (Record<string, unknown> & { sessionKey: string }) | undefined;
|
||||
const resolvedSessionFile = path.resolve(sessionFile);
|
||||
for (const [sessionKey, record] of Object.entries(store)) {
|
||||
if (!isJsonObject(record) || typeof record.sessionFile !== "string") {
|
||||
continue;
|
||||
@@ -155,16 +129,9 @@ async function readCodexSessionRecordForSessionFile(
|
||||
if (path.resolve(record.sessionFile) !== resolvedSessionFile) {
|
||||
continue;
|
||||
}
|
||||
found = { sessionKey, ...record };
|
||||
break;
|
||||
return { sessionKey, ...record };
|
||||
}
|
||||
codexSessionRecordCache.set(resolvedSessionFile, {
|
||||
sessionsFile,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
size: stat.size,
|
||||
record: found,
|
||||
});
|
||||
return found;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
type CodexAppServerRolloutTokenSnapshot = {
|
||||
|
||||
@@ -54,21 +54,6 @@ describe("DiffArtifactStore", () => {
|
||||
expect(await store.readHtml(artifact.id)).toBe("<html>demo</html>");
|
||||
});
|
||||
|
||||
it("caps artifact expiry instead of throwing near the Date boundary", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(8_640_000_000_000_000 - 1_000));
|
||||
|
||||
const artifact = await store.createArtifact({
|
||||
html: "<html>demo</html>",
|
||||
title: "Demo",
|
||||
inputKind: "patch",
|
||||
fileCount: 1,
|
||||
ttlMs: 60_000,
|
||||
});
|
||||
|
||||
expect(artifact.expiresAt).toBe("+275760-09-13T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("expires artifacts after the ttl", async () => {
|
||||
vi.useFakeTimers();
|
||||
const now = new Date("2026-02-27T16:00:00Z");
|
||||
@@ -146,15 +131,6 @@ describe("DiffArtifactStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("caps standalone file expiry instead of throwing near the Date boundary", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(8_640_000_000_000_000 - 1_000));
|
||||
|
||||
const standalone = await store.createStandaloneFileArtifact({ ttlMs: 60_000 });
|
||||
|
||||
expect(standalone.expiresAt).toBe("+275760-09-13T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("expires standalone file artifacts using ttl metadata", async () => {
|
||||
vi.useFakeTimers();
|
||||
const now = new Date("2026-02-27T16:00:00Z");
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { MAX_DATE_TIMESTAMP_MS, timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { root as fsRoot } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { PluginLogger } from "../api.js";
|
||||
@@ -65,16 +64,15 @@ export class DiffArtifactStore {
|
||||
const htmlPath = path.join(artifactDir, "viewer.html");
|
||||
const ttlMs = normalizeTtlMs(params.ttlMs);
|
||||
const createdAt = new Date();
|
||||
const createdAtIso = createdAt.toISOString();
|
||||
const expiresAt = resolveExpiresAtIso(createdAt.getTime(), ttlMs);
|
||||
const expiresAt = new Date(createdAt.getTime() + ttlMs);
|
||||
const meta: DiffArtifactMeta = {
|
||||
id,
|
||||
token,
|
||||
title: params.title,
|
||||
inputKind: params.inputKind,
|
||||
fileCount: params.fileCount,
|
||||
createdAt: createdAtIso,
|
||||
expiresAt,
|
||||
createdAt: createdAt.toISOString(),
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
viewerPath: `${VIEWER_PREFIX}/${id}/${token}`,
|
||||
htmlPath,
|
||||
...(params.context ? { context: params.context } : {}),
|
||||
@@ -146,12 +144,11 @@ export class DiffArtifactStore {
|
||||
const filePath = path.join(artifactDir, `preview.${format}`);
|
||||
const ttlMs = normalizeTtlMs(params.ttlMs);
|
||||
const createdAt = new Date();
|
||||
const createdAtIso = createdAt.toISOString();
|
||||
const expiresAt = resolveExpiresAtIso(createdAt.getTime(), ttlMs);
|
||||
const expiresAt = new Date(createdAt.getTime() + ttlMs).toISOString();
|
||||
const meta: StandaloneFileMeta = {
|
||||
kind: "standalone_file",
|
||||
id,
|
||||
createdAt: createdAtIso,
|
||||
createdAt: createdAt.toISOString(),
|
||||
expiresAt,
|
||||
filePath: this.normalizeStoredPath(filePath, "filePath"),
|
||||
...(params.context ? { context: params.context } : {}),
|
||||
@@ -360,14 +357,6 @@ function normalizeTtlMs(value?: number): number {
|
||||
return Math.min(rounded, MAX_TTL_MS);
|
||||
}
|
||||
|
||||
function resolveExpiresAtIso(createdAtMs: number, ttlMs: number): string {
|
||||
return (
|
||||
timestampMsToIsoString(createdAtMs + ttlMs) ??
|
||||
timestampMsToIsoString(MAX_DATE_TIMESTAMP_MS) ??
|
||||
"1970-01-01T00:00:00.000Z"
|
||||
);
|
||||
}
|
||||
|
||||
function isExpired(meta: { expiresAt: string }): boolean {
|
||||
const expiresAt = Date.parse(meta.expiresAt);
|
||||
if (!Number.isFinite(expiresAt)) {
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import { resolveGlobalMap } from "openclaw/plugin-sdk/global-singleton";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
isFutureDateTimestampMs,
|
||||
resolveDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { DiscordComponentEntry, DiscordModalEntry } from "./components.js";
|
||||
import { getOptionalDiscordRuntime } from "./runtime.js";
|
||||
@@ -169,7 +163,7 @@ function getPersistentModalStore(): DiscordRegistryStore<DiscordModalEntry> | un
|
||||
}
|
||||
|
||||
function isExpired(entry: { expiresAt?: number }, now: number) {
|
||||
return entry.expiresAt !== undefined && !isFutureDateTimestampMs(entry.expiresAt, { nowMs: now });
|
||||
return typeof entry.expiresAt === "number" && entry.expiresAt <= now;
|
||||
}
|
||||
|
||||
function normalizeEntryTimestamps<T extends { createdAt?: number; expiresAt?: number }>(
|
||||
@@ -177,33 +171,11 @@ function normalizeEntryTimestamps<T extends { createdAt?: number; expiresAt?: nu
|
||||
now: number,
|
||||
ttlMs: number,
|
||||
): T {
|
||||
const createdAt = resolveDateTimestampMs(entry.createdAt, now);
|
||||
const expiresAt =
|
||||
asDateTimestampMs(entry.expiresAt) ??
|
||||
resolveExpiresAtMsFromDurationMs(ttlMs, { nowMs: createdAt }) ??
|
||||
0;
|
||||
const createdAt = entry.createdAt ?? now;
|
||||
const expiresAt = entry.expiresAt ?? createdAt + ttlMs;
|
||||
return { ...entry, createdAt, expiresAt };
|
||||
}
|
||||
|
||||
function pruneUndefinedRegistryValues<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.filter((entry) => entry !== undefined)
|
||||
.map((entry) => pruneUndefinedRegistryValues(entry)) as T;
|
||||
}
|
||||
if (!value || typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
if (entry === undefined) {
|
||||
continue;
|
||||
}
|
||||
result[key] = pruneUndefinedRegistryValues(entry);
|
||||
}
|
||||
return result as T;
|
||||
}
|
||||
|
||||
function registerEntries<
|
||||
T extends { id: string; messageId?: string; createdAt?: number; expiresAt?: number },
|
||||
>(
|
||||
@@ -265,9 +237,8 @@ function registerPersistentRegistryEntries<T extends { id: string }>(params: {
|
||||
return;
|
||||
}
|
||||
for (const entry of params.entries) {
|
||||
const persistedEntry = pruneUndefinedRegistryValues(entry);
|
||||
void store
|
||||
.register(entry.id, { version: 1, entry: persistedEntry }, { ttlMs: params.ttlMs })
|
||||
.register(entry.id, { version: 1, entry }, { ttlMs: params.ttlMs })
|
||||
.catch(disablePersistentComponentRegistry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { ButtonStyle, MessageFlags } from "discord-api-types/v10";
|
||||
import { MAX_DATE_TIMESTAMP_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DiscordComponentEntry, DiscordModalEntry } from "./components.js";
|
||||
|
||||
let clearDiscordComponentEntries: typeof import("./components-registry.js").clearDiscordComponentEntries;
|
||||
let registerDiscordComponentEntries: typeof import("./components-registry.js").registerDiscordComponentEntries;
|
||||
@@ -380,36 +378,6 @@ describe("discord component registry", () => {
|
||||
second.clearDiscordComponentEntries();
|
||||
});
|
||||
|
||||
it("expires component entries registered while the process clock is invalid", () => {
|
||||
const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
|
||||
try {
|
||||
registerDiscordComponentEntries({
|
||||
entries: [{ id: "btn_invalid_clock", kind: "button", label: "Invalid clock" }],
|
||||
modals: [],
|
||||
ttlMs: 1000,
|
||||
});
|
||||
|
||||
expect(resolveDiscordComponentEntry({ id: "btn_invalid_clock", consume: false })).toBeNull();
|
||||
} finally {
|
||||
dateNowSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("expires component entries whose calculated expiry exceeds the Date range", () => {
|
||||
const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(MAX_DATE_TIMESTAMP_MS);
|
||||
try {
|
||||
registerDiscordComponentEntries({
|
||||
entries: [{ id: "btn_overflow", kind: "button", label: "Overflow" }],
|
||||
modals: [],
|
||||
ttlMs: 1000,
|
||||
});
|
||||
} finally {
|
||||
dateNowSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(resolveDiscordComponentEntry({ id: "btn_overflow", consume: false })).toBeNull();
|
||||
});
|
||||
|
||||
it("persists component and modal entries when runtime state is available", async () => {
|
||||
const componentRegister = vi.fn().mockResolvedValue(undefined);
|
||||
const modalRegister = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -500,92 +468,6 @@ describe("discord component registry", () => {
|
||||
expect(openKeyedStore).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("omits undefined component fields before persisting registry state", async () => {
|
||||
const componentRegister = vi.fn().mockResolvedValue(undefined);
|
||||
const modalRegister = vi.fn().mockResolvedValue(undefined);
|
||||
const componentStore = {
|
||||
register: componentRegister,
|
||||
lookup: vi.fn(),
|
||||
consume: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
entries: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
const modalStore = {
|
||||
register: modalRegister,
|
||||
lookup: vi.fn(),
|
||||
consume: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
entries: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
const openKeyedStore = vi.fn((opts: { namespace: string }) =>
|
||||
opts.namespace === "discord.components" ? componentStore : modalStore,
|
||||
);
|
||||
const { setDiscordRuntime } = await import("./runtime.js");
|
||||
setDiscordRuntime({
|
||||
state: { openKeyedStore },
|
||||
logging: { getChildLogger: () => ({ warn: vi.fn() }) },
|
||||
} as never);
|
||||
|
||||
const componentEntry = Object.assign(
|
||||
{
|
||||
id: "btn_undefined",
|
||||
kind: "button",
|
||||
label: "Approve",
|
||||
callbackData: "approve",
|
||||
} satisfies DiscordComponentEntry,
|
||||
{ modalId: undefined, sessionKey: undefined },
|
||||
);
|
||||
const modalEntry = Object.assign(
|
||||
{
|
||||
id: "mdl_undefined",
|
||||
title: "Details",
|
||||
fields: [
|
||||
Object.assign(
|
||||
{
|
||||
id: "fld_undefined",
|
||||
name: "reason",
|
||||
label: "Reason",
|
||||
type: "text",
|
||||
} satisfies DiscordModalEntry["fields"][number],
|
||||
{ description: undefined, placeholder: undefined },
|
||||
),
|
||||
],
|
||||
} satisfies DiscordModalEntry,
|
||||
{ sessionKey: undefined },
|
||||
);
|
||||
|
||||
registerDiscordComponentEntries({
|
||||
entries: [componentEntry],
|
||||
modals: [modalEntry],
|
||||
ttlMs: 1000,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(componentRegister).toHaveBeenCalledTimes(1));
|
||||
expect(modalRegister).toHaveBeenCalledTimes(1);
|
||||
|
||||
const persistedComponent = componentRegister.mock.calls[0]?.[1] as
|
||||
| { entry: Record<string, unknown> }
|
||||
| undefined;
|
||||
expect(persistedComponent?.entry.callbackData).toBe("approve");
|
||||
expect(persistedComponent?.entry).not.toHaveProperty("modalId");
|
||||
expect(persistedComponent?.entry).not.toHaveProperty("sessionKey");
|
||||
expect(persistedComponent?.entry).not.toHaveProperty("messageId");
|
||||
|
||||
const modalPayload = modalRegister.mock.calls[0]?.[1] as
|
||||
| { entry: { fields?: Array<Record<string, unknown>> } }
|
||||
| undefined;
|
||||
expect(modalPayload?.entry.fields?.[0]).not.toHaveProperty("description");
|
||||
expect(modalPayload?.entry.fields?.[0]).not.toHaveProperty("placeholder");
|
||||
expect(modalPayload?.entry).not.toHaveProperty("sessionKey");
|
||||
expect(modalPayload?.entry).not.toHaveProperty("messageId");
|
||||
|
||||
const inMemoryComponent = resolveDiscordComponentEntry({ id: "btn_undefined", consume: false });
|
||||
expect(inMemoryComponent).toHaveProperty("modalId", undefined);
|
||||
expect(inMemoryComponent).toHaveProperty("sessionKey", undefined);
|
||||
});
|
||||
|
||||
it("deletes sibling persistent component entries when a group entry is consumed", async () => {
|
||||
const componentDelete = vi.fn().mockResolvedValue(true);
|
||||
const componentStore = {
|
||||
|
||||
@@ -342,47 +342,6 @@ describe("Client.deployCommands", () => {
|
||||
await client.fetchChannel("c1");
|
||||
expect(get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not reuse cached REST objects while the process clock is invalid", async () => {
|
||||
const client = createInternalTestClient();
|
||||
const get = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: "c1", type: 0, name: "old" })
|
||||
.mockResolvedValueOnce({ id: "c1", type: 0, name: "fresh" })
|
||||
.mockResolvedValueOnce({ id: "c1", type: 0, name: "recovered" });
|
||||
attachRestMock(client, { get });
|
||||
|
||||
const first = await client.fetchChannel("c1");
|
||||
expect(first.name).toBe("old");
|
||||
|
||||
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
|
||||
const second = await client.fetchChannel("c1");
|
||||
|
||||
expect(second.name).toBe("fresh");
|
||||
|
||||
vi.mocked(Date.now).mockReturnValue(1_000);
|
||||
const third = await client.fetchChannel("c1");
|
||||
|
||||
expect(third.name).toBe("recovered");
|
||||
expect(get).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("does not cache REST objects when the cache expiry would exceed the Date range", async () => {
|
||||
const client = createInternalTestClient();
|
||||
const get = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: "c1", type: 0, name: "first" })
|
||||
.mockResolvedValueOnce({ id: "c1", type: 0, name: "second" });
|
||||
attachRestMock(client, { get });
|
||||
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
|
||||
|
||||
const first = await client.fetchChannel("c1");
|
||||
const second = await client.fetchChannel("c1");
|
||||
|
||||
expect(first.name).toBe("first");
|
||||
expect(second.name).toBe("second");
|
||||
expect(get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Client gateway event queue", () => {
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { GatewayDispatchEvents } from "discord-api-types/v10";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { getChannel, getGuild, getGuildMember, getUser } from "./api.js";
|
||||
import type { RequestClient } from "./rest.js";
|
||||
import { Guild, GuildMember, User, channelFactory, type StructureClient } from "./structures.js";
|
||||
@@ -83,23 +79,15 @@ export class DiscordEntityCache {
|
||||
|
||||
private async fetchCached<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
||||
const ttl = this.params.ttlMs ?? DEFAULT_REST_CACHE_TTL_MS;
|
||||
const rawNow = Date.now();
|
||||
const now = asDateTimestampMs(rawNow);
|
||||
if (ttl > 0) {
|
||||
const cached = this.entries.get(key) as CacheEntry<T> | undefined;
|
||||
if (cached && now !== undefined && cached.expiresAt > now) {
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.value;
|
||||
}
|
||||
if (cached) {
|
||||
this.entries.delete(key);
|
||||
}
|
||||
}
|
||||
const value = await fetcher();
|
||||
if (ttl > 0) {
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(ttl, { nowMs: rawNow });
|
||||
if (expiresAt !== undefined) {
|
||||
this.entries.set(key, { expiresAt, value });
|
||||
}
|
||||
this.entries.set(key, { expiresAt: Date.now() + ttl, value });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -12,9 +12,6 @@ type DiscordInboundJobRuntimeField =
|
||||
| "guildHistories"
|
||||
| "client"
|
||||
| "threadBindings"
|
||||
// Function-backed feedback stays runtime-only; payload must remain
|
||||
// materializable data so queued jobs cannot accidentally serialize it.
|
||||
| "replyTypingFeedback"
|
||||
| "discordRestFetch";
|
||||
|
||||
type DiscordInboundJobRuntime = Pick<DiscordMessagePreflightContext, DiscordInboundJobRuntimeField>;
|
||||
@@ -29,8 +26,6 @@ export type DiscordInboundJob = {
|
||||
};
|
||||
|
||||
export function resolveDiscordInboundJobQueueKey(ctx: DiscordMessagePreflightContext): string {
|
||||
// This key is both the run-queue serialization key and the typing prestart
|
||||
// dedupe key, so keep it aligned with the eventual session route.
|
||||
const sessionKey = ctx.route.sessionKey?.trim();
|
||||
if (sessionKey) {
|
||||
return sessionKey;
|
||||
@@ -52,7 +47,6 @@ export function buildDiscordInboundJob(
|
||||
guildHistories,
|
||||
client,
|
||||
threadBindings,
|
||||
replyTypingFeedback,
|
||||
discordRestFetch,
|
||||
message,
|
||||
data,
|
||||
@@ -78,7 +72,6 @@ export function buildDiscordInboundJob(
|
||||
guildHistories,
|
||||
client,
|
||||
threadBindings,
|
||||
replyTypingFeedback,
|
||||
discordRestFetch,
|
||||
},
|
||||
replayKeys: options?.replayKeys ? [...options.replayKeys] : undefined,
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { normalizeOptionalStringifiedId } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { ChannelType, Message } from "../internal/discord.js";
|
||||
@@ -34,22 +30,6 @@ export function resetDiscordChannelInfoCacheForTest() {
|
||||
DISCORD_CHANNEL_INFO_CACHE.clear();
|
||||
}
|
||||
|
||||
function resolveDiscordChannelInfoCacheExpiresAt(ttlMs: number, nowMs: number): number | undefined {
|
||||
return resolveExpiresAtMsFromDurationMs(ttlMs, { nowMs });
|
||||
}
|
||||
|
||||
function cacheDiscordChannelInfo(
|
||||
channelId: string,
|
||||
value: DiscordChannelInfo | null,
|
||||
ttlMs: number,
|
||||
nowMs: number,
|
||||
): void {
|
||||
const expiresAt = resolveDiscordChannelInfoCacheExpiresAt(ttlMs, nowMs);
|
||||
if (expiresAt !== undefined) {
|
||||
DISCORD_CHANNEL_INFO_CACHE.set(channelId, { value, expiresAt });
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDiscordChannelId(value: unknown): string {
|
||||
return normalizeOptionalStringifiedId(value) ?? "";
|
||||
}
|
||||
@@ -71,11 +51,9 @@ export async function resolveDiscordChannelInfo(
|
||||
client: DiscordChannelInfoClient,
|
||||
channelId: string,
|
||||
): Promise<DiscordChannelInfo | null> {
|
||||
const rawNow = Date.now();
|
||||
const now = asDateTimestampMs(rawNow);
|
||||
const cached = DISCORD_CHANNEL_INFO_CACHE.get(channelId);
|
||||
if (cached) {
|
||||
if (now !== undefined && cached.expiresAt > now) {
|
||||
if (cached.expiresAt > Date.now()) {
|
||||
return cached.value;
|
||||
}
|
||||
DISCORD_CHANNEL_INFO_CACHE.delete(channelId);
|
||||
@@ -83,7 +61,10 @@ export async function resolveDiscordChannelInfo(
|
||||
try {
|
||||
const channel = await client.fetchChannel(channelId);
|
||||
if (!channel) {
|
||||
cacheDiscordChannelInfo(channelId, null, DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS, rawNow);
|
||||
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
|
||||
value: null,
|
||||
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const channelInfo = resolveDiscordChannelInfoSafe(channel);
|
||||
@@ -99,11 +80,17 @@ export async function resolveDiscordChannelInfo(
|
||||
parentId: channelInfo.parentId,
|
||||
ownerId: channelInfo.ownerId,
|
||||
};
|
||||
cacheDiscordChannelInfo(channelId, payload, DISCORD_CHANNEL_INFO_CACHE_TTL_MS, rawNow);
|
||||
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
|
||||
value: payload,
|
||||
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_CACHE_TTL_MS,
|
||||
});
|
||||
return payload;
|
||||
} catch (err) {
|
||||
logVerbose(`discord: failed to fetch channel ${channelId}: ${String(err)}`);
|
||||
cacheDiscordChannelInfo(channelId, null, DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS, rawNow);
|
||||
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
|
||||
value: null,
|
||||
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2252,40 +2252,4 @@ describe("shouldIgnoreBoundThreadWebhookMessage", () => {
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not suppress unbound thread webhook echoes when echo expiry overflows", async () => {
|
||||
const manager = createThreadBindingManager({
|
||||
cfg: DEFAULT_PREFLIGHT_CFG,
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
});
|
||||
const binding = await manager.bindTarget({
|
||||
threadId: "thread-overflow",
|
||||
channelId: "parent-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:child-1",
|
||||
agentId: "main",
|
||||
webhookId: "wh-overflow",
|
||||
webhookToken: "tok-1",
|
||||
});
|
||||
expect(binding).not.toBeNull();
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
|
||||
try {
|
||||
manager.unbindThread({
|
||||
threadId: "thread-overflow",
|
||||
sendFarewell: false,
|
||||
});
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(
|
||||
shouldIgnoreBoundThreadWebhookMessage({
|
||||
accountId: "default",
|
||||
threadId: "thread-overflow",
|
||||
webhookId: "wh-overflow",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { ChannelType, Client, User } from "../internal/discord.js";
|
||||
import type { DiscordChannelConfigResolved, DiscordGuildEntryResolved } from "./allow-list.js";
|
||||
import type { DiscordChannelInfo } from "./message-utils.js";
|
||||
import type { DiscordThreadBindingLookup } from "./reply-delivery.js";
|
||||
import type { DiscordReplyTypingFeedback } from "./reply-typing-feedback.js";
|
||||
import type { DiscordSenderIdentity } from "./sender-identity.js";
|
||||
|
||||
export type { DiscordSenderIdentity } from "./sender-identity.js";
|
||||
@@ -96,7 +95,6 @@ export type DiscordMessagePreflightContext = DiscordMessagePreflightSharedFields
|
||||
|
||||
historyEntry?: HistoryEntry;
|
||||
threadBindings: DiscordThreadBindingLookup;
|
||||
replyTypingFeedback?: DiscordReplyTypingFeedback;
|
||||
discordRestFetch?: typeof fetch;
|
||||
botLoopProtection?: ChannelBotLoopProtectionFacts;
|
||||
};
|
||||
|
||||
@@ -86,16 +86,6 @@ vi.mock("../send.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const typingMocks = vi.hoisted(() => ({
|
||||
sendTyping: vi.fn<(params: { rest: unknown; channelId: string }) => Promise<void>>(
|
||||
async () => {},
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./typing.js", () => ({
|
||||
sendTyping: typingMocks.sendTyping,
|
||||
}));
|
||||
|
||||
const discordTargetMocks = vi.hoisted(() => ({
|
||||
resolveDiscordTargetChannelId: vi.fn(async (target: string, _opts?: unknown) => ({
|
||||
channelId: target === "user:u1" ? "dm-u1" : target,
|
||||
@@ -179,7 +169,6 @@ type DispatchInboundParams = {
|
||||
onPartialReply?: (payload: { text?: string }) => Promise<void> | void;
|
||||
onAssistantMessageStart?: () => Promise<void> | void;
|
||||
allowProgressCallbacksWhenSourceDeliverySuppressed?: boolean;
|
||||
onTypingCleanup?: () => Promise<void> | void;
|
||||
};
|
||||
};
|
||||
const dispatchInboundMessage = vi.hoisted(() =>
|
||||
@@ -244,7 +233,6 @@ let createThreadBindingManager: typeof import("./thread-bindings.js").createThre
|
||||
let processDiscordMessage: typeof import("./message-handler.process.js").processDiscordMessage;
|
||||
let formatDiscordReplySkip: typeof import("./message-handler.process.js").formatDiscordReplySkip;
|
||||
let notifyDiscordInboundEventOutboundSuccess: typeof import("../inbound-event-delivery.js").notifyDiscordInboundEventOutboundSuccess;
|
||||
let createDiscordReplyTypingFeedback: typeof import("./reply-typing-feedback.js").createDiscordReplyTypingFeedback;
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({
|
||||
dispatchReplyWithBufferedBlockDispatcher: async (params: {
|
||||
@@ -256,14 +244,6 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({
|
||||
deliver: (payload: unknown, info: { kind: "block" | "final" }) => Promise<void> | void;
|
||||
onError?: (err: unknown, info: { kind: "block" | "final" }) => void;
|
||||
transformReplyPayload?: (payload: ReplyPayload) => ReplyPayload | null;
|
||||
typingCallbacks?: {
|
||||
onReplyStart?: () => Promise<void> | void;
|
||||
onIdle?: () => void;
|
||||
onCleanup?: () => void;
|
||||
};
|
||||
onReplyStart?: () => Promise<void> | void;
|
||||
onIdle?: () => void;
|
||||
onCleanup?: () => void;
|
||||
onSettled?: () => unknown;
|
||||
onFreshSettledDelivery?: () => unknown;
|
||||
};
|
||||
@@ -293,16 +273,10 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({
|
||||
pendingDeliveries.push(delivery);
|
||||
return true;
|
||||
};
|
||||
const typingCallbacks = params.dispatcherOptions.typingCallbacks;
|
||||
const replyOptions = {
|
||||
...params.replyOptions,
|
||||
onReplyStart: params.dispatcherOptions.onReplyStart ?? typingCallbacks?.onReplyStart,
|
||||
onTypingCleanup: params.dispatcherOptions.onCleanup ?? typingCallbacks?.onCleanup,
|
||||
};
|
||||
try {
|
||||
return await dispatchInboundMessage({
|
||||
ctx: params.ctx,
|
||||
replyOptions,
|
||||
replyOptions: params.replyOptions,
|
||||
dispatcher: {
|
||||
sendBlockReply: vi.fn((payload: ReplyPayload) =>
|
||||
queueDelivery(payload, { kind: "block" }),
|
||||
@@ -318,8 +292,6 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", () => ({
|
||||
} finally {
|
||||
await params.dispatcherOptions.onSettled?.();
|
||||
await params.dispatcherOptions.onFreshSettledDelivery?.();
|
||||
params.dispatcherOptions.onIdle?.();
|
||||
typingCallbacks?.onIdle?.();
|
||||
}
|
||||
},
|
||||
dispatchInboundMessage: (params: DispatchInboundParams) => dispatchInboundMessage(params),
|
||||
@@ -484,15 +456,12 @@ beforeAll(async () => {
|
||||
({ processDiscordMessage, formatDiscordReplySkip } =
|
||||
await import("./message-handler.process.js"));
|
||||
({ notifyDiscordInboundEventOutboundSuccess } = await import("../inbound-event-delivery.js"));
|
||||
({ createDiscordReplyTypingFeedback } = await import("./reply-typing-feedback.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
sendMocks.reactMessageDiscord.mockClear();
|
||||
sendMocks.removeReactionDiscord.mockClear();
|
||||
typingMocks.sendTyping.mockClear();
|
||||
typingMocks.sendTyping.mockResolvedValue(undefined);
|
||||
discordTargetMocks.resolveDiscordTargetChannelId.mockClear();
|
||||
editMessageDiscord.mockClear();
|
||||
deliverDiscordReply.mockClear();
|
||||
@@ -904,70 +873,6 @@ describe("processDiscordMessage ack reactions", () => {
|
||||
expect(feedbackRest).not.toBe(deliveryRest);
|
||||
});
|
||||
|
||||
it("reuses accepted typing feedback through reply dispatch", async () => {
|
||||
const replyTypingFeedback = {
|
||||
onReplyStart: vi.fn(async () => {}),
|
||||
onIdle: vi.fn(),
|
||||
onCleanup: vi.fn(),
|
||||
updateChannelId: vi.fn(),
|
||||
getChannelId: vi.fn(() => "c1"),
|
||||
restartForDispatch: vi.fn(),
|
||||
};
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
await params?.replyOptions?.onReplyStart?.();
|
||||
return createNoQueuedDispatchResult();
|
||||
});
|
||||
const ctx = await createAutomaticSourceDeliveryContext({
|
||||
replyTypingFeedback,
|
||||
});
|
||||
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(replyTypingFeedback.updateChannelId).not.toHaveBeenCalled();
|
||||
expect(replyTypingFeedback.restartForDispatch).toHaveBeenCalledWith("c1");
|
||||
expect(replyTypingFeedback.onReplyStart).toHaveBeenCalledTimes(1);
|
||||
expect(replyTypingFeedback.onIdle).toHaveBeenCalledTimes(1);
|
||||
expect(replyTypingFeedback.onCleanup).toHaveBeenCalledTimes(1);
|
||||
expect(typingMocks.sendTyping).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restarts stale carried typing feedback before dispatch", async () => {
|
||||
vi.useFakeTimers();
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const rest = { kind: "feedback-rest" };
|
||||
try {
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
await params?.replyOptions?.onReplyStart?.();
|
||||
await vi.advanceTimersByTimeAsync(3_500);
|
||||
return createNoQueuedDispatchResult();
|
||||
});
|
||||
const ctx = await createAutomaticSourceDeliveryContext();
|
||||
ctx.replyTypingFeedback = createDiscordReplyTypingFeedback({
|
||||
cfg: ctx.cfg,
|
||||
token: ctx.token,
|
||||
accountId: ctx.accountId,
|
||||
channelId: "c1",
|
||||
rest: rest as never,
|
||||
log: vi.fn(),
|
||||
maxDurationMs: 5_000,
|
||||
});
|
||||
await ctx.replyTypingFeedback.onReplyStart();
|
||||
await vi.advanceTimersByTimeAsync(5_100);
|
||||
typingMocks.sendTyping.mockClear();
|
||||
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(typingMocks.sendTyping.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
expect(
|
||||
typingMocks.sendTyping.mock.calls.every(
|
||||
([params]) => params.channelId === "c1" && params.rest === rest,
|
||||
),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("debounces intermediate phase reactions and jumps to done for short runs", async () => {
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
await params?.replyOptions?.onReasoningStream?.();
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
createStatusReactionController,
|
||||
DEFAULT_TIMING,
|
||||
logAckFailure,
|
||||
logTypingFailure,
|
||||
shouldAckReaction as shouldAckReactionGate,
|
||||
} from "openclaw/plugin-sdk/channel-feedback";
|
||||
import {
|
||||
@@ -65,11 +66,11 @@ import { createDiscordDraftPreviewController } from "./message-handler.draft-pre
|
||||
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
|
||||
import { resolveForwardedMediaList, resolveMediaList } from "./message-utils.js";
|
||||
import { deliverDiscordReply } from "./reply-delivery.js";
|
||||
import { createDiscordReplyTypingFeedback } from "./reply-typing-feedback.js";
|
||||
import {
|
||||
DISCORD_ATTACHMENT_IDLE_TIMEOUT_MS,
|
||||
DISCORD_ATTACHMENT_TOTAL_TIMEOUT_MS,
|
||||
} from "./timeouts.js";
|
||||
import { sendTyping } from "./typing.js";
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
@@ -77,6 +78,7 @@ function sleep(ms: number): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
const DISCORD_TYPING_MAX_DURATION_MS = 20 * 60_000;
|
||||
let replyRuntimePromise: Promise<typeof import("openclaw/plugin-sdk/reply-runtime")> | undefined;
|
||||
|
||||
async function loadReplyRuntime() {
|
||||
@@ -152,17 +154,6 @@ function readToolBooleanArg(args: Record<string, unknown>, key: string): boolean
|
||||
export async function processDiscordMessage(
|
||||
ctx: DiscordMessagePreflightContext,
|
||||
observer?: DiscordMessageProcessObserver,
|
||||
) {
|
||||
try {
|
||||
await processDiscordMessageInner(ctx, observer);
|
||||
} finally {
|
||||
ctx.replyTypingFeedback?.onCleanup?.();
|
||||
}
|
||||
}
|
||||
|
||||
async function processDiscordMessageInner(
|
||||
ctx: DiscordMessagePreflightContext,
|
||||
observer?: DiscordMessageProcessObserver,
|
||||
) {
|
||||
const dispatchStartedAt = Date.now();
|
||||
const {
|
||||
@@ -193,7 +184,6 @@ async function processDiscordMessageInner(
|
||||
discordRestFetch,
|
||||
abortSignal,
|
||||
botLoopProtection,
|
||||
replyTypingFeedback,
|
||||
} = ctx;
|
||||
if (isProcessAborted(abortSignal)) {
|
||||
return;
|
||||
@@ -442,32 +432,25 @@ async function processDiscordMessageInner(
|
||||
const typingChannelId = deliverTarget.startsWith("channel:")
|
||||
? deliverTarget.slice("channel:".length)
|
||||
: messageChannelId;
|
||||
// Deliver target can move into a thread after preflight accepted the message.
|
||||
// The typing owner follows the final target before reply dispatch starts.
|
||||
const typingFeedback =
|
||||
replyTypingFeedback ??
|
||||
createDiscordReplyTypingFeedback({
|
||||
cfg,
|
||||
token,
|
||||
accountId,
|
||||
channelId: typingChannelId,
|
||||
rest: feedbackRest,
|
||||
log: logVerbose,
|
||||
});
|
||||
if (replyTypingFeedback) {
|
||||
// A carried prestart only covers queue wait time; dispatch needs a fresh
|
||||
// controller after retargeting so an expired TTL cannot silence the run.
|
||||
replyTypingFeedback.restartForDispatch(typingChannelId);
|
||||
} else {
|
||||
typingFeedback.updateChannelId(typingChannelId);
|
||||
}
|
||||
|
||||
const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
channel: "discord",
|
||||
accountId: route.accountId,
|
||||
typingCallbacks: typingFeedback,
|
||||
typing: {
|
||||
start: () => sendTyping({ rest: feedbackRest, channelId: typingChannelId }),
|
||||
onStartError: (err) => {
|
||||
logTypingFailure({
|
||||
log: logVerbose,
|
||||
channel: "discord",
|
||||
target: typingChannelId,
|
||||
error: err,
|
||||
});
|
||||
},
|
||||
// Long tool-heavy runs are expected on Discord; keep heartbeats alive.
|
||||
maxDurationMs: DISCORD_TYPING_MAX_DURATION_MS,
|
||||
},
|
||||
});
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
|
||||
@@ -11,16 +11,25 @@ import {
|
||||
createDiscordPreflightContext,
|
||||
} from "./message-handler.test-helpers.js";
|
||||
|
||||
const earlyTypingMocks = vi.hoisted(() => ({
|
||||
createDiscordRestClient: vi.fn(() => ({
|
||||
token: "test-token",
|
||||
rest: { kind: "discord-rest" },
|
||||
account: { accountId: "default", config: {} },
|
||||
})),
|
||||
sendTyping: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../client.js", () => ({
|
||||
createDiscordRestClient: earlyTypingMocks.createDiscordRestClient,
|
||||
}));
|
||||
|
||||
vi.mock("./typing.js", () => ({
|
||||
sendTyping: earlyTypingMocks.sendTyping,
|
||||
}));
|
||||
|
||||
type SetStatusFn = (patch: Record<string, unknown>) => void;
|
||||
type MockCallSource = { mock: { calls: Array<Array<unknown>> } };
|
||||
type ReplyTypingFeedbackMock = {
|
||||
onReplyStart: ReturnType<typeof vi.fn<() => Promise<void>>>;
|
||||
onIdle: ReturnType<typeof vi.fn<() => void>>;
|
||||
onCleanup: ReturnType<typeof vi.fn<() => void>>;
|
||||
updateChannelId: ReturnType<typeof vi.fn<(channelId: string) => void>>;
|
||||
getChannelId: ReturnType<typeof vi.fn<() => string>>;
|
||||
restartForDispatch: ReturnType<typeof vi.fn<(channelId: string) => void>>;
|
||||
};
|
||||
|
||||
function mockCall(source: MockCallSource, label: string, callIndex = 0): Array<unknown> {
|
||||
const call = source.mock.calls[callIndex];
|
||||
@@ -95,22 +104,9 @@ function createPreflightContext(channelId = "ch-1") {
|
||||
cfg,
|
||||
accountId: "default",
|
||||
token: "test-token",
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: (code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
},
|
||||
},
|
||||
textLimit: 2_000,
|
||||
replyToMode: "off" as const,
|
||||
discordConfig,
|
||||
messageText: "hello",
|
||||
isDirectMessage: false,
|
||||
isGuildMessage: true,
|
||||
isGroupDm: false,
|
||||
inboundEventKind: "message" as const,
|
||||
effectiveWasMentioned: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -125,17 +121,6 @@ function createAcceptedDmPreflightContext(overrides: Record<string, unknown> = {
|
||||
};
|
||||
}
|
||||
|
||||
function createReplyTypingFeedbackMock(channelId = "ch-1"): ReplyTypingFeedbackMock {
|
||||
return {
|
||||
onReplyStart: vi.fn(async () => {}),
|
||||
onIdle: vi.fn(),
|
||||
onCleanup: vi.fn(),
|
||||
updateChannelId: vi.fn(),
|
||||
getChannelId: vi.fn(() => channelId),
|
||||
restartForDispatch: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createHandlerWithDefaultPreflight(overrides?: { setStatus?: SetStatusFn }) {
|
||||
preflightDiscordMessageMock.mockImplementation(async (params: { data: { channel_id: string } }) =>
|
||||
createPreflightContext(params.data.channel_id),
|
||||
@@ -187,224 +172,126 @@ async function createLifecycleStopScenario(params: {
|
||||
|
||||
describe("createDiscordMessageHandler queue behavior", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
earlyTypingMocks.createDiscordRestClient.mockReset().mockReturnValue({
|
||||
token: "test-token",
|
||||
rest: { kind: "discord-rest" },
|
||||
account: { accountId: "default", config: {} },
|
||||
});
|
||||
earlyTypingMocks.sendTyping.mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("starts accepted DM typing feedback before queued processing starts", async () => {
|
||||
it("sends an accepted DM typing cue before queued processing starts", async () => {
|
||||
preflightDiscordMessageMock.mockReset();
|
||||
processDiscordMessageMock.mockReset();
|
||||
preflightDiscordMessageMock.mockImplementation(async () => createAcceptedDmPreflightContext());
|
||||
preflightDiscordMessageMock.mockResolvedValue(createAcceptedDmPreflightContext());
|
||||
processDiscordMessageMock.mockResolvedValue(undefined);
|
||||
const replyTypingFeedback = createReplyTypingFeedbackMock("dm-1");
|
||||
const createReplyTypingFeedback = vi.fn(() => replyTypingFeedback);
|
||||
|
||||
const handler = createDiscordMessageHandler({
|
||||
...createDiscordHandlerParams(),
|
||||
testing: { createReplyTypingFeedback },
|
||||
});
|
||||
const handler = createDiscordMessageHandler(createDiscordHandlerParams());
|
||||
await expect(
|
||||
handler(createMessageData("m-typing", "dm-1") as never, {} as never),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
await flushQueueWork();
|
||||
|
||||
expect(createReplyTypingFeedback).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accountId: "default",
|
||||
token: "test-token",
|
||||
channelId: "dm-1",
|
||||
}),
|
||||
expect(earlyTypingMocks.createDiscordRestClient).toHaveBeenCalledTimes(1);
|
||||
const [restClientParams] = mockCall(
|
||||
earlyTypingMocks.createDiscordRestClient,
|
||||
"createDiscordRestClient",
|
||||
);
|
||||
expect(replyTypingFeedback.onReplyStart).toHaveBeenCalledTimes(1);
|
||||
expect(replyTypingFeedback.onReplyStart.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
expect((restClientParams as { accountId?: unknown } | undefined)?.accountId).toBe("default");
|
||||
expect((restClientParams as { token?: unknown } | undefined)?.token).toBe("test-token");
|
||||
expect(earlyTypingMocks.sendTyping).toHaveBeenCalledWith({
|
||||
rest: { kind: "discord-rest" },
|
||||
channelId: "dm-1",
|
||||
});
|
||||
expect(earlyTypingMocks.sendTyping.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
processDiscordMessageMock.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps accepted DM dispatch running when accepted typing feedback fails", async () => {
|
||||
it("keeps accepted DM dispatch running when the early typing cue fails", async () => {
|
||||
preflightDiscordMessageMock.mockReset();
|
||||
processDiscordMessageMock.mockReset();
|
||||
preflightDiscordMessageMock.mockImplementation(async () => createAcceptedDmPreflightContext());
|
||||
earlyTypingMocks.sendTyping.mockRejectedValueOnce(new Error("typing failed"));
|
||||
preflightDiscordMessageMock.mockResolvedValue(createAcceptedDmPreflightContext());
|
||||
processDiscordMessageMock.mockResolvedValue(undefined);
|
||||
const replyTypingFeedback = createReplyTypingFeedbackMock("dm-1");
|
||||
replyTypingFeedback.onReplyStart.mockRejectedValueOnce(new Error("typing failed"));
|
||||
|
||||
const handler = createDiscordMessageHandler({
|
||||
...createDiscordHandlerParams(),
|
||||
testing: { createReplyTypingFeedback: vi.fn(() => replyTypingFeedback) },
|
||||
});
|
||||
const handler = createDiscordMessageHandler(createDiscordHandlerParams());
|
||||
await expect(
|
||||
handler(createMessageData("m-typing-fails", "dm-1") as never, {} as never),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
await flushQueueWork();
|
||||
|
||||
expect(replyTypingFeedback.onReplyStart).toHaveBeenCalledTimes(1);
|
||||
expect(earlyTypingMocks.sendTyping).toHaveBeenCalledTimes(1);
|
||||
expect(processDiscordMessageMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not start accepted typing feedback when preflight rejects the message", async () => {
|
||||
it("does not send early typing when preflight rejects the message", async () => {
|
||||
preflightDiscordMessageMock.mockReset();
|
||||
processDiscordMessageMock.mockReset();
|
||||
preflightDiscordMessageMock.mockResolvedValue(null);
|
||||
const createReplyTypingFeedback = vi.fn();
|
||||
|
||||
const handler = createDiscordMessageHandler({
|
||||
...createDiscordHandlerParams(),
|
||||
testing: { createReplyTypingFeedback },
|
||||
});
|
||||
const handler = createDiscordMessageHandler(createDiscordHandlerParams());
|
||||
await expect(
|
||||
handler(createMessageData("m-rejected", "dm-1") as never, {} as never),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
await flushQueueWork();
|
||||
|
||||
expect(createReplyTypingFeedback).not.toHaveBeenCalled();
|
||||
expect(earlyTypingMocks.sendTyping).not.toHaveBeenCalled();
|
||||
expect(processDiscordMessageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["message", "thinking", "never"] as const)(
|
||||
"does not start accepted typing feedback when typing mode is %s",
|
||||
async (typingMode) => {
|
||||
preflightDiscordMessageMock.mockReset();
|
||||
processDiscordMessageMock.mockReset();
|
||||
preflightDiscordMessageMock.mockResolvedValue(
|
||||
createAcceptedDmPreflightContext({
|
||||
cfg: {
|
||||
...createPreflightContext().cfg,
|
||||
agents: {
|
||||
defaults: {
|
||||
typingMode,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
processDiscordMessageMock.mockResolvedValue(undefined);
|
||||
const createReplyTypingFeedback = vi.fn();
|
||||
|
||||
const handler = createDiscordMessageHandler({
|
||||
...createDiscordHandlerParams(),
|
||||
testing: { createReplyTypingFeedback },
|
||||
});
|
||||
await expect(
|
||||
handler(createMessageData(`m-${typingMode}-mode`, "dm-1") as never, {} as never),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
await flushQueueWork();
|
||||
|
||||
expect(createReplyTypingFeedback).not.toHaveBeenCalled();
|
||||
expect(processDiscordMessageMock).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not start default accepted typing feedback for unmentioned guild replies", async () => {
|
||||
preflightDiscordMessageMock.mockReset();
|
||||
processDiscordMessageMock.mockReset();
|
||||
preflightDiscordMessageMock.mockResolvedValue(
|
||||
createAcceptedDmPreflightContext({
|
||||
isDirectMessage: false,
|
||||
isGuildMessage: true,
|
||||
messageChannelId: "guild-channel",
|
||||
effectiveWasMentioned: false,
|
||||
}),
|
||||
);
|
||||
processDiscordMessageMock.mockResolvedValue(undefined);
|
||||
const createReplyTypingFeedback = vi.fn();
|
||||
|
||||
const handler = createDiscordMessageHandler({
|
||||
...createDiscordHandlerParams(),
|
||||
testing: { createReplyTypingFeedback },
|
||||
});
|
||||
await expect(
|
||||
handler(createMessageData("m-guild", "guild-channel") as never, {} as never),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
await flushQueueWork();
|
||||
|
||||
expect(createReplyTypingFeedback).not.toHaveBeenCalled();
|
||||
expect(processDiscordMessageMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("starts accepted typing feedback for message-tool-only guild replies", async () => {
|
||||
it("does not send early typing when typing mode is not instant", async () => {
|
||||
preflightDiscordMessageMock.mockReset();
|
||||
processDiscordMessageMock.mockReset();
|
||||
preflightDiscordMessageMock.mockResolvedValue(
|
||||
createAcceptedDmPreflightContext({
|
||||
cfg: {
|
||||
...createPreflightContext().cfg,
|
||||
messages: {
|
||||
inbound: { debounceMs: 0 },
|
||||
groupChat: { visibleReplies: "message_tool" },
|
||||
agents: {
|
||||
defaults: {
|
||||
typingMode: "message",
|
||||
},
|
||||
},
|
||||
},
|
||||
isDirectMessage: false,
|
||||
isGuildMessage: true,
|
||||
messageChannelId: "guild-channel",
|
||||
effectiveWasMentioned: false,
|
||||
}),
|
||||
);
|
||||
processDiscordMessageMock.mockResolvedValue(undefined);
|
||||
const replyTypingFeedback = createReplyTypingFeedbackMock("guild-channel");
|
||||
const createReplyTypingFeedback = vi.fn(() => replyTypingFeedback);
|
||||
|
||||
const handler = createDiscordMessageHandler({
|
||||
...createDiscordHandlerParams(),
|
||||
testing: { createReplyTypingFeedback },
|
||||
});
|
||||
const handler = createDiscordMessageHandler(createDiscordHandlerParams());
|
||||
await expect(
|
||||
handler(createMessageData("m-guild-tool", "guild-channel") as never, {} as never),
|
||||
handler(createMessageData("m-message-mode", "dm-1") as never, {} as never),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
await flushQueueWork();
|
||||
|
||||
expect(replyTypingFeedback.onReplyStart).toHaveBeenCalledTimes(1);
|
||||
expect(earlyTypingMocks.sendTyping).not.toHaveBeenCalled();
|
||||
expect(processDiscordMessageMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("deduplicates accepted typing feedback while same-session runs are queued", async () => {
|
||||
it("does not send early typing for guild messages", async () => {
|
||||
preflightDiscordMessageMock.mockReset();
|
||||
processDiscordMessageMock.mockReset();
|
||||
preflightDiscordMessageMock.mockResolvedValue(
|
||||
createAcceptedDmPreflightContext({
|
||||
isDirectMessage: false,
|
||||
isGuildMessage: true,
|
||||
messageChannelId: "guild-channel",
|
||||
}),
|
||||
);
|
||||
processDiscordMessageMock.mockResolvedValue(undefined);
|
||||
|
||||
const firstRun = createDeferred();
|
||||
const processedContexts: Array<Record<string, unknown>> = [];
|
||||
processDiscordMessageMock
|
||||
.mockImplementationOnce(async (ctx: Record<string, unknown>) => {
|
||||
processedContexts.push(ctx);
|
||||
await firstRun.promise;
|
||||
})
|
||||
.mockImplementationOnce(async (ctx: Record<string, unknown>) => {
|
||||
processedContexts.push(ctx);
|
||||
});
|
||||
preflightDiscordMessageMock.mockImplementation(async () => createAcceptedDmPreflightContext());
|
||||
const replyTypingFeedback = createReplyTypingFeedbackMock("dm-1");
|
||||
const createReplyTypingFeedback = vi.fn(() => replyTypingFeedback);
|
||||
|
||||
const handler = createDiscordMessageHandler({
|
||||
...createDiscordHandlerParams(),
|
||||
testing: { createReplyTypingFeedback },
|
||||
});
|
||||
const handler = createDiscordMessageHandler(createDiscordHandlerParams());
|
||||
await expect(
|
||||
handler(createMessageData("m-1", "dm-1") as never, {} as never),
|
||||
handler(createMessageData("m-guild", "guild-channel") as never, {} as never),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
await flushQueueWork();
|
||||
|
||||
expect(earlyTypingMocks.sendTyping).not.toHaveBeenCalled();
|
||||
expect(processDiscordMessageMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await expect(
|
||||
handler(createMessageData("m-2", "dm-1") as never, {} as never),
|
||||
).resolves.toBeUndefined();
|
||||
await flushQueueWork();
|
||||
|
||||
expect(createReplyTypingFeedback).toHaveBeenCalledTimes(1);
|
||||
expect(replyTypingFeedback.onReplyStart).toHaveBeenCalledTimes(1);
|
||||
expect(processedContexts[0]?.replyTypingFeedback).toBe(replyTypingFeedback);
|
||||
|
||||
firstRun.resolve();
|
||||
await firstRun.promise;
|
||||
await flushQueueWork();
|
||||
|
||||
expect(processDiscordMessageMock).toHaveBeenCalledTimes(2);
|
||||
expect(processedContexts[1]?.replyTypingFeedback).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resets busy counters when the handler is created", () => {
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
|
||||
import { resolveDiscordAcceptedTypingPrestart } from "./message-handler.reply-typing-policy.js";
|
||||
import { createDiscordPreflightContext } from "./message-handler.test-helpers.js";
|
||||
|
||||
function createPolicyContext(
|
||||
overrides: Partial<DiscordMessagePreflightContext> = {},
|
||||
): DiscordMessagePreflightContext {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
discord: {
|
||||
enabled: true,
|
||||
token: "test-token",
|
||||
groupPolicy: "allowlist",
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
inbound: {
|
||||
debounceMs: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
return {
|
||||
...createDiscordPreflightContext("c1"),
|
||||
cfg,
|
||||
accountId: "default",
|
||||
token: "test-token",
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: (code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
},
|
||||
},
|
||||
discordConfig: cfg.channels?.discord,
|
||||
messageText: "hello",
|
||||
isDirectMessage: true,
|
||||
isGuildMessage: false,
|
||||
isGroupDm: false,
|
||||
inboundEventKind: "message",
|
||||
effectiveWasMentioned: false,
|
||||
...overrides,
|
||||
} as DiscordMessagePreflightContext;
|
||||
}
|
||||
|
||||
describe("resolveDiscordAcceptedTypingPrestart", () => {
|
||||
it.each([
|
||||
["default direct message", createPolicyContext(), true, "direct"],
|
||||
[
|
||||
"default mentioned guild message",
|
||||
createPolicyContext({
|
||||
isDirectMessage: false,
|
||||
isGuildMessage: true,
|
||||
effectiveWasMentioned: true,
|
||||
}),
|
||||
true,
|
||||
"mentioned-group",
|
||||
],
|
||||
[
|
||||
"default unmentioned guild message",
|
||||
createPolicyContext({
|
||||
isDirectMessage: false,
|
||||
isGuildMessage: true,
|
||||
effectiveWasMentioned: false,
|
||||
}),
|
||||
false,
|
||||
"defer-to-message",
|
||||
],
|
||||
[
|
||||
"message-tool-only guild message",
|
||||
createPolicyContext({
|
||||
cfg: {
|
||||
...createPolicyContext().cfg,
|
||||
messages: {
|
||||
inbound: { debounceMs: 0 },
|
||||
groupChat: { visibleReplies: "message_tool" },
|
||||
},
|
||||
},
|
||||
isDirectMessage: false,
|
||||
isGuildMessage: true,
|
||||
effectiveWasMentioned: false,
|
||||
}),
|
||||
true,
|
||||
"tool-only",
|
||||
],
|
||||
[
|
||||
"room event",
|
||||
createPolicyContext({
|
||||
inboundEventKind: "room_event",
|
||||
}),
|
||||
false,
|
||||
"room-event",
|
||||
],
|
||||
[
|
||||
"configured instant",
|
||||
createPolicyContext({
|
||||
cfg: {
|
||||
...createPolicyContext().cfg,
|
||||
agents: { defaults: { typingMode: "instant" } },
|
||||
},
|
||||
}),
|
||||
true,
|
||||
"configured-instant",
|
||||
],
|
||||
[
|
||||
"configured message",
|
||||
createPolicyContext({
|
||||
cfg: {
|
||||
...createPolicyContext().cfg,
|
||||
agents: { defaults: { typingMode: "message" } },
|
||||
},
|
||||
}),
|
||||
false,
|
||||
"configured-not-instant",
|
||||
],
|
||||
] as const)("%s", (_label, ctx, shouldPrestart, reason) => {
|
||||
expect(resolveDiscordAcceptedTypingPrestart(ctx)).toMatchObject({
|
||||
shouldPrestart,
|
||||
reason,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import { resolveChannelMessageSourceReplyDeliveryMode } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.types.js";
|
||||
|
||||
type SourceReplyDeliveryMode = ReturnType<typeof resolveChannelMessageSourceReplyDeliveryMode>;
|
||||
|
||||
export type DiscordAcceptedTypingPrestartDecision = {
|
||||
sourceReplyDeliveryMode: SourceReplyDeliveryMode;
|
||||
shouldPrestart: boolean;
|
||||
reason:
|
||||
| "aborted"
|
||||
| "empty"
|
||||
| "room-event"
|
||||
| "configured-instant"
|
||||
| "configured-not-instant"
|
||||
| "tool-only"
|
||||
| "direct"
|
||||
| "mentioned-group"
|
||||
| "defer-to-message";
|
||||
};
|
||||
|
||||
export function resolveDiscordSourceReplyDeliveryMode(
|
||||
ctx: DiscordMessagePreflightContext,
|
||||
): SourceReplyDeliveryMode {
|
||||
// Keep prestart policy keyed to the same source-reply mode as dispatch.
|
||||
// Otherwise message-tool-only group replies would wait behind "message" mode.
|
||||
return resolveChannelMessageSourceReplyDeliveryMode({
|
||||
cfg: ctx.cfg,
|
||||
ctx: {
|
||||
ChatType: ctx.isDirectMessage
|
||||
? "direct"
|
||||
: ctx.isGroupDm
|
||||
? "group"
|
||||
: ctx.isGuildMessage
|
||||
? "channel"
|
||||
: undefined,
|
||||
InboundEventKind: ctx.inboundEventKind,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveDiscordAcceptedTypingPrestart(
|
||||
ctx: DiscordMessagePreflightContext,
|
||||
): DiscordAcceptedTypingPrestartDecision {
|
||||
const sourceReplyDeliveryMode = resolveDiscordSourceReplyDeliveryMode(ctx);
|
||||
if (ctx.abortSignal?.aborted) {
|
||||
return { sourceReplyDeliveryMode, shouldPrestart: false, reason: "aborted" };
|
||||
}
|
||||
if (!ctx.messageText.trim()) {
|
||||
return { sourceReplyDeliveryMode, shouldPrestart: false, reason: "empty" };
|
||||
}
|
||||
if (ctx.inboundEventKind === "room_event") {
|
||||
return { sourceReplyDeliveryMode, shouldPrestart: false, reason: "room-event" };
|
||||
}
|
||||
const configuredTypingMode = ctx.cfg.session?.typingMode ?? ctx.cfg.agents?.defaults?.typingMode;
|
||||
if (configuredTypingMode !== undefined) {
|
||||
// Explicit operator config wins over Discord heuristics.
|
||||
// Non-instant modes intentionally defer to the normal reply pipeline.
|
||||
return {
|
||||
sourceReplyDeliveryMode,
|
||||
shouldPrestart: configuredTypingMode === "instant",
|
||||
reason: configuredTypingMode === "instant" ? "configured-instant" : "configured-not-instant",
|
||||
};
|
||||
}
|
||||
if (sourceReplyDeliveryMode === "message_tool_only") {
|
||||
// Message-tool-only replies have no visible default response path.
|
||||
// Prestart preserves user feedback while the tool-delivered reply waits.
|
||||
return { sourceReplyDeliveryMode, shouldPrestart: true, reason: "tool-only" };
|
||||
}
|
||||
if (!ctx.isGuildMessage && !ctx.isGroupDm) {
|
||||
return { sourceReplyDeliveryMode, shouldPrestart: true, reason: "direct" };
|
||||
}
|
||||
if (ctx.effectiveWasMentioned) {
|
||||
return { sourceReplyDeliveryMode, shouldPrestart: true, reason: "mentioned-group" };
|
||||
}
|
||||
return { sourceReplyDeliveryMode, shouldPrestart: false, reason: "defer-to-message" };
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy";
|
||||
import { createDiscordRestClient } from "../client.js";
|
||||
import type { Client } from "../internal/discord.js";
|
||||
import {
|
||||
buildDiscordInboundReplayKey,
|
||||
@@ -13,14 +14,11 @@ import {
|
||||
DiscordRetryableInboundError,
|
||||
releaseDiscordInboundReplay,
|
||||
} from "./inbound-dedupe.js";
|
||||
import { buildDiscordInboundJob, resolveDiscordInboundJobQueueKey } from "./inbound-job.js";
|
||||
import { buildDiscordInboundJob } from "./inbound-job.js";
|
||||
import type { DiscordMessageEvent, DiscordMessageHandler } from "./listeners.js";
|
||||
import { applyImplicitReplyBatchGate } from "./message-handler.batch-gate.js";
|
||||
import type {
|
||||
DiscordMessagePreflightContext,
|
||||
DiscordMessagePreflightParams,
|
||||
} from "./message-handler.preflight.types.js";
|
||||
import { resolveDiscordAcceptedTypingPrestart } from "./message-handler.reply-typing-policy.js";
|
||||
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
|
||||
import type { DiscordMessagePreflightParams } from "./message-handler.preflight.types.js";
|
||||
import {
|
||||
createDiscordMessageRunQueue,
|
||||
type DiscordMessageRunQueueTestingHooks,
|
||||
@@ -30,15 +28,11 @@ import {
|
||||
resolveDiscordMessageChannelId,
|
||||
resolveDiscordMessageText,
|
||||
} from "./message-utils.js";
|
||||
import {
|
||||
createDiscordReplyTypingFeedback,
|
||||
type DiscordReplyTypingFeedback,
|
||||
} from "./reply-typing-feedback.js";
|
||||
import type { DiscordMonitorStatusSink } from "./status.js";
|
||||
import { sendTyping } from "./typing.js";
|
||||
|
||||
type PreflightDiscordMessage =
|
||||
typeof import("./message-handler.preflight.js").preflightDiscordMessage;
|
||||
type CreateDiscordReplyTypingFeedback = typeof createDiscordReplyTypingFeedback;
|
||||
|
||||
type DiscordMessageHandlerParams = Omit<
|
||||
DiscordMessagePreflightParams,
|
||||
@@ -51,12 +45,6 @@ type DiscordMessageHandlerParams = Omit<
|
||||
|
||||
type DiscordMessageHandlerTestingHooks = DiscordMessageRunQueueTestingHooks & {
|
||||
preflightDiscordMessage?: PreflightDiscordMessage;
|
||||
createReplyTypingFeedback?: CreateDiscordReplyTypingFeedback;
|
||||
};
|
||||
|
||||
type PrestartedTypingFeedbackEntry = {
|
||||
channelId: string;
|
||||
feedback: DiscordReplyTypingFeedback;
|
||||
};
|
||||
|
||||
let messagePreflightRuntimePromise:
|
||||
@@ -76,47 +64,34 @@ function isNonEmptyString(value: string | undefined): value is string {
|
||||
return typeof value === "string" && value.length > 0;
|
||||
}
|
||||
|
||||
function startAcceptedTypingFeedback(params: {
|
||||
ctx: DiscordMessagePreflightContext;
|
||||
createFeedback?: CreateDiscordReplyTypingFeedback;
|
||||
dedupeKey: string;
|
||||
activeFeedback: Map<string, PrestartedTypingFeedbackEntry>;
|
||||
}): DiscordReplyTypingFeedback | undefined {
|
||||
const { ctx, createFeedback, dedupeKey, activeFeedback } = params;
|
||||
if (!resolveDiscordAcceptedTypingPrestart(ctx).shouldPrestart) {
|
||||
return undefined;
|
||||
function shouldSendAcceptedDiscordTypingCue(ctx: DiscordMessagePreflightContext): boolean {
|
||||
if (ctx.abortSignal?.aborted) {
|
||||
return false;
|
||||
}
|
||||
const channelId = ctx.messageChannelId.trim();
|
||||
const existing = activeFeedback.get(dedupeKey);
|
||||
if (existing) {
|
||||
// One pre-dispatch keepalive owns each serialized Discord queue key.
|
||||
// Later queued jobs get fresh typing when their dispatch turn starts.
|
||||
return undefined;
|
||||
if (!ctx.isDirectMessage || ctx.isGuildMessage || ctx.isGroupDm) {
|
||||
return false;
|
||||
}
|
||||
const replyTypingFeedback =
|
||||
ctx.replyTypingFeedback ??
|
||||
(createFeedback ?? createDiscordReplyTypingFeedback)({
|
||||
cfg: ctx.cfg,
|
||||
token: ctx.token,
|
||||
accountId: ctx.accountId,
|
||||
channelId: ctx.messageChannelId,
|
||||
log: logVerbose,
|
||||
});
|
||||
const cleanup = replyTypingFeedback.onCleanup;
|
||||
replyTypingFeedback.onCleanup = () => {
|
||||
cleanup?.();
|
||||
// Cleanup is the lease release for both normal dispatch and skipped jobs.
|
||||
// Without this, a stale queue key would suppress future accepted typing.
|
||||
if (activeFeedback.get(dedupeKey)?.feedback === replyTypingFeedback) {
|
||||
activeFeedback.delete(dedupeKey);
|
||||
}
|
||||
};
|
||||
activeFeedback.set(dedupeKey, { channelId, feedback: replyTypingFeedback });
|
||||
ctx.replyTypingFeedback = replyTypingFeedback;
|
||||
void replyTypingFeedback.onReplyStart().catch((err) => {
|
||||
logVerbose(`discord accepted typing feedback failed: ${String(err)}`);
|
||||
if (!ctx.messageText.trim()) {
|
||||
return false;
|
||||
}
|
||||
const configuredTypingMode = ctx.cfg.session?.typingMode ?? ctx.cfg.agents?.defaults?.typingMode;
|
||||
return configuredTypingMode === undefined || configuredTypingMode === "instant";
|
||||
}
|
||||
|
||||
function queueAcceptedDiscordTypingCue(ctx: DiscordMessagePreflightContext): void {
|
||||
if (!shouldSendAcceptedDiscordTypingCue(ctx)) {
|
||||
return;
|
||||
}
|
||||
const { rest } = createDiscordRestClient({
|
||||
cfg: ctx.cfg,
|
||||
token: ctx.token,
|
||||
accountId: ctx.accountId,
|
||||
});
|
||||
void sendTyping({ rest, channelId: ctx.messageChannelId }).catch((err) => {
|
||||
logVerbose(
|
||||
`discord early typing cue failed for channel ${ctx.messageChannelId}: ${String(err)}`,
|
||||
);
|
||||
});
|
||||
return replyTypingFeedback;
|
||||
}
|
||||
|
||||
export function createDiscordMessageHandler(
|
||||
@@ -133,9 +108,6 @@ export function createDiscordMessageHandler(
|
||||
"group-mentions";
|
||||
const preflightDiscordMessageImpl = params.testing?.preflightDiscordMessage;
|
||||
const replayGuard = createDiscordInboundReplayGuard();
|
||||
// The map owns pre-dispatch typing leases, not queued work itself.
|
||||
// Each lease is released by the feedback cleanup hook installed below.
|
||||
const prestartedTypingFeedback = new Map<string, PrestartedTypingFeedbackEntry>();
|
||||
const messageRunQueue = createDiscordMessageRunQueue({
|
||||
runtime: params.runtime,
|
||||
setStatus: params.setStatus,
|
||||
@@ -213,14 +185,8 @@ export function createDiscordMessageHandler(
|
||||
await commitDiscordInboundReplay({ replayKeys, replayGuard });
|
||||
return;
|
||||
}
|
||||
const queueKey = resolveDiscordInboundJobQueueKey(ctx);
|
||||
startAcceptedTypingFeedback({
|
||||
ctx,
|
||||
createFeedback: params.testing?.createReplyTypingFeedback,
|
||||
dedupeKey: queueKey,
|
||||
activeFeedback: prestartedTypingFeedback,
|
||||
});
|
||||
applyImplicitReplyBatchGate(ctx, params.replyToMode, false);
|
||||
queueAcceptedDiscordTypingCue(ctx);
|
||||
messageRunQueue.enqueue(buildDiscordInboundJob(ctx, { replayKeys }));
|
||||
return;
|
||||
}
|
||||
@@ -269,13 +235,6 @@ export function createDiscordMessageHandler(
|
||||
await commitDiscordInboundReplay({ replayKeys, replayGuard });
|
||||
return;
|
||||
}
|
||||
const queueKey = resolveDiscordInboundJobQueueKey(ctx);
|
||||
startAcceptedTypingFeedback({
|
||||
ctx,
|
||||
createFeedback: params.testing?.createReplyTypingFeedback,
|
||||
dedupeKey: queueKey,
|
||||
activeFeedback: prestartedTypingFeedback,
|
||||
});
|
||||
applyImplicitReplyBatchGate(ctx, params.replyToMode, true);
|
||||
if (entries.length > 1) {
|
||||
const ids = entries.map((entry) => entry.data.message?.id).filter(isNonEmptyString);
|
||||
@@ -290,6 +249,7 @@ export function createDiscordMessageHandler(
|
||||
ctxBatch.MessageSidLast = ids[ids.length - 1];
|
||||
}
|
||||
}
|
||||
queueAcceptedDiscordTypingCue(ctx);
|
||||
messageRunQueue.enqueue(buildDiscordInboundJob(ctx, { replayKeys }));
|
||||
} catch (error) {
|
||||
if (error instanceof DiscordRetryableInboundError) {
|
||||
|
||||
@@ -31,8 +31,6 @@ export type DiscordMessageRunQueueTestingHooks = {
|
||||
processDiscordMessage?: ProcessDiscordMessage;
|
||||
};
|
||||
|
||||
type SkippedQueuedMessageCleanup = () => void;
|
||||
|
||||
let messageProcessRuntimePromise:
|
||||
| Promise<typeof import("./message-handler.process.js")>
|
||||
| undefined;
|
||||
@@ -75,28 +73,10 @@ async function processDiscordQueuedMessage(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupSkippedDiscordQueuedMessage(params: {
|
||||
job: DiscordInboundJob;
|
||||
replayGuard: ClaimableDedupe;
|
||||
}) {
|
||||
try {
|
||||
// Skipped jobs never reach processDiscordMessage's finally block.
|
||||
// Clean carried typing here before reopening the replay key for retry.
|
||||
params.job.runtime.replyTypingFeedback?.onCleanup?.();
|
||||
} finally {
|
||||
releaseDiscordInboundReplay({
|
||||
replayKeys: params.job.replayKeys,
|
||||
error: new DiscordRetryableInboundError("discord queued run skipped before processing"),
|
||||
replayGuard: params.replayGuard,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createDiscordMessageRunQueue(
|
||||
params: DiscordMessageRunQueueParams,
|
||||
): DiscordMessageRunQueue {
|
||||
const replayGuard = params.replayGuard ?? createDiscordInboundReplayGuard();
|
||||
const skippedCleanup = new Set<SkippedQueuedMessageCleanup>();
|
||||
const runQueue = createChannelRunQueue({
|
||||
setStatus: params.setStatus,
|
||||
abortSignal: params.abortSignal,
|
||||
@@ -104,42 +84,10 @@ export function createDiscordMessageRunQueue(
|
||||
params.runtime.error(danger(`discord message run failed: ${String(error)}`));
|
||||
},
|
||||
});
|
||||
let lifecycleActive = !params.abortSignal?.aborted;
|
||||
|
||||
const cleanupSkippedQueuedMessages = () => {
|
||||
// These callbacks represent jobs accepted into the queue but not started.
|
||||
// Running jobs remove their callback before processDiscordMessage owns cleanup.
|
||||
if (!lifecycleActive && skippedCleanup.size === 0) {
|
||||
return;
|
||||
}
|
||||
lifecycleActive = false;
|
||||
const cleanups = [...skippedCleanup];
|
||||
skippedCleanup.clear();
|
||||
for (const cleanup of cleanups) {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
if (params.abortSignal?.aborted) {
|
||||
cleanupSkippedQueuedMessages();
|
||||
} else {
|
||||
params.abortSignal?.addEventListener("abort", cleanupSkippedQueuedMessages, { once: true });
|
||||
}
|
||||
|
||||
return {
|
||||
enqueue(job) {
|
||||
const cleanupSkipped = () => {
|
||||
cleanupSkippedDiscordQueuedMessage({ job, replayGuard });
|
||||
};
|
||||
if (!lifecycleActive) {
|
||||
cleanupSkipped();
|
||||
return;
|
||||
}
|
||||
skippedCleanup.add(cleanupSkipped);
|
||||
runQueue.enqueue(job.queueKey, async ({ lifecycleSignal }) => {
|
||||
// Once the task starts, normal process/commit handling owns cleanup.
|
||||
// Leaving it in skippedCleanup would double-release replay/typing state.
|
||||
skippedCleanup.delete(cleanupSkipped);
|
||||
await processDiscordQueuedMessage({
|
||||
job,
|
||||
lifecycleSignal,
|
||||
@@ -148,9 +96,6 @@ export function createDiscordMessageRunQueue(
|
||||
});
|
||||
});
|
||||
},
|
||||
deactivate() {
|
||||
runQueue.deactivate();
|
||||
cleanupSkippedQueuedMessages();
|
||||
},
|
||||
deactivate: runQueue.deactivate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
MessageReferenceType,
|
||||
StickerFormatType,
|
||||
} from "discord-api-types/v10";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ChannelType, type Client, type Message } from "../internal/discord.js";
|
||||
|
||||
const readRemoteMediaBuffer = vi.fn();
|
||||
@@ -65,10 +65,6 @@ beforeAll(async () => {
|
||||
} = await import("./message-utils.js"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function asMessage(payload: Record<string, unknown>): Message {
|
||||
return payload as unknown as Message;
|
||||
}
|
||||
@@ -1235,37 +1231,4 @@ describe("resolveDiscordChannelInfo", () => {
|
||||
expect(second).toBeNull();
|
||||
expect(fetchChannel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not reuse cached channel info while the process clock is invalid", async () => {
|
||||
const fetchChannel = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ type: ChannelType.GuildText, name: "old" })
|
||||
.mockResolvedValueOnce({ type: ChannelType.GuildText, name: "fresh" });
|
||||
const client = { fetchChannel } as unknown as Client;
|
||||
|
||||
const first = await resolveDiscordChannelInfo(client, "invalid-clock-channel");
|
||||
expect(first?.name).toBe("old");
|
||||
|
||||
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
|
||||
const second = await resolveDiscordChannelInfo(client, "invalid-clock-channel");
|
||||
|
||||
expect(second?.name).toBe("fresh");
|
||||
expect(fetchChannel).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not cache channel info when the cache expiry would exceed the Date range", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
|
||||
const fetchChannel = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ type: ChannelType.GuildText, name: "first" })
|
||||
.mockResolvedValueOnce({ type: ChannelType.GuildText, name: "second" });
|
||||
const client = { fetchChannel } as unknown as Client;
|
||||
|
||||
const first = await resolveDiscordChannelInfo(client, "overflow-cache-channel");
|
||||
const second = await resolveDiscordChannelInfo(client, "overflow-cache-channel");
|
||||
|
||||
expect(first?.name).toBe("first");
|
||||
expect(second?.name).toBe("second");
|
||||
expect(fetchChannel).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,88 +63,4 @@ describe("Discord model picker preference migration", () => {
|
||||
updatedAt: "2026-05-29T00:00:00.001Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("plans legacy JSON import with max Date timestamps", async () => {
|
||||
const stateDir = await makeStateDir();
|
||||
const sourcePath = path.join(stateDir, "discord", "model-picker-preferences.json");
|
||||
await fs.mkdir(path.dirname(sourcePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
sourcePath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
entries: {
|
||||
"discord:default:dm:user:max-date": {
|
||||
recent: ["openai/gpt-5", "openai/gpt-4.1"],
|
||||
updatedAt: "+275760-09-13T00:00:00.000Z",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const plans = await Promise.resolve(
|
||||
detectDiscordLegacyStateMigrations({
|
||||
cfg: {},
|
||||
env: {},
|
||||
oauthDir: path.join(stateDir, "credentials"),
|
||||
stateDir,
|
||||
}),
|
||||
);
|
||||
|
||||
const plan = plans?.[0];
|
||||
if (plan?.kind !== "plugin-state-import") {
|
||||
throw new Error("expected plugin-state import plan");
|
||||
}
|
||||
const entries = await plan.readEntries();
|
||||
expect(
|
||||
entries.map((entry) => {
|
||||
const value = entry.value as { updatedAt?: unknown };
|
||||
return value.updatedAt;
|
||||
}),
|
||||
).toEqual(["+275760-09-13T00:00:00.000Z", "+275760-09-12T23:59:59.999Z"]);
|
||||
});
|
||||
|
||||
it("keeps legacy JSON import order near max Date", async () => {
|
||||
const stateDir = await makeStateDir();
|
||||
const sourcePath = path.join(stateDir, "discord", "model-picker-preferences.json");
|
||||
await fs.mkdir(path.dirname(sourcePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
sourcePath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
entries: {
|
||||
"discord:default:dm:user:near-max-date": {
|
||||
recent: ["openai/gpt-5", "openai/gpt-4.1"],
|
||||
updatedAt: "+275760-09-12T23:59:59.999Z",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const plans = await Promise.resolve(
|
||||
detectDiscordLegacyStateMigrations({
|
||||
cfg: {},
|
||||
env: {},
|
||||
oauthDir: path.join(stateDir, "credentials"),
|
||||
stateDir,
|
||||
}),
|
||||
);
|
||||
|
||||
const plan = plans?.[0];
|
||||
if (plan?.kind !== "plugin-state-import") {
|
||||
throw new Error("expected plugin-state import plan");
|
||||
}
|
||||
const entries = await plan.readEntries();
|
||||
expect(
|
||||
entries.map((entry) => {
|
||||
const value = entry.value as { modelRef?: unknown };
|
||||
return value.modelRef;
|
||||
}),
|
||||
).toEqual(["openai/gpt-5", "openai/gpt-4.1"]);
|
||||
expect(
|
||||
entries.map((entry) => {
|
||||
const value = entry.value as { updatedAt?: unknown };
|
||||
return value.updatedAt;
|
||||
}),
|
||||
).toEqual(["+275760-09-13T00:00:00.000Z", "+275760-09-12T23:59:59.999Z"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { BundledChannelLegacyStateMigrationDetector } from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
import { MAX_DATE_TIMESTAMP_MS, timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
|
||||
const PREFERENCE_MAX_ENTRIES = 2_000;
|
||||
@@ -93,15 +92,7 @@ function timestampMs(value: unknown): number {
|
||||
}
|
||||
|
||||
function legacyUpdatedAtForIndex(updatedAt: unknown, index: number, total: number): string {
|
||||
const baseMs = timestampMs(updatedAt);
|
||||
const anchorMs = Math.min(baseMs + Math.max(0, total), MAX_DATE_TIMESTAMP_MS);
|
||||
const shiftedMs = anchorMs - Math.max(0, index);
|
||||
return (
|
||||
timestampMsToIsoString(shiftedMs) ??
|
||||
timestampMsToIsoString(baseMs) ??
|
||||
timestampMsToIsoString(Math.max(0, total - index)) ??
|
||||
"1970-01-01T00:00:00.000Z"
|
||||
);
|
||||
return new Date(timestampMs(updatedAt) + Math.max(0, total - index)).toISOString();
|
||||
}
|
||||
|
||||
export const detectDiscordLegacyStateMigrations: BundledChannelLegacyStateMigrationDetector = ({
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
createPluginStateKeyedStoreForTests,
|
||||
resetPluginStateStoreForTests,
|
||||
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { setDiscordRuntime, type DiscordRuntime } from "../runtime.js";
|
||||
import {
|
||||
buildDiscordModelPickerPreferenceKey,
|
||||
@@ -163,68 +163,6 @@ describe("discord model picker preferences", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("imports legacy JSON preferences with max Date timestamps", async () => {
|
||||
const env = await createStateEnv();
|
||||
const scope = { accountId: "main", guildId: "guild-max-date", userId: "user-max-date" };
|
||||
const key = buildDiscordModelPickerPreferenceKey(scope);
|
||||
expect(key).toBeTruthy();
|
||||
const legacyPath = path.join(
|
||||
env.OPENCLAW_STATE_DIR as string,
|
||||
"discord",
|
||||
"model-picker-preferences.json",
|
||||
);
|
||||
await fs.mkdir(path.dirname(legacyPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
legacyPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
entries: {
|
||||
[key as string]: {
|
||||
recent: ["openai/gpt-4.1", "openai/gpt-4o"],
|
||||
updatedAt: "+275760-09-13T00:00:00.000Z",
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await expect(readDiscordModelPickerRecentModels({ env, scope })).resolves.toEqual([
|
||||
"openai/gpt-4.1",
|
||||
"openai/gpt-4o",
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves legacy JSON preference order near max Date", async () => {
|
||||
const env = await createStateEnv();
|
||||
const scope = { accountId: "main", guildId: "guild-near-max-date", userId: "user-near-max" };
|
||||
const key = buildDiscordModelPickerPreferenceKey(scope);
|
||||
expect(key).toBeTruthy();
|
||||
const legacyPath = path.join(
|
||||
env.OPENCLAW_STATE_DIR as string,
|
||||
"discord",
|
||||
"model-picker-preferences.json",
|
||||
);
|
||||
await fs.mkdir(path.dirname(legacyPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
legacyPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
entries: {
|
||||
[key as string]: {
|
||||
recent: ["openai/gpt-4.1", "openai/gpt-4o"],
|
||||
updatedAt: "+275760-09-12T23:59:59.999Z",
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await expect(readDiscordModelPickerRecentModels({ env, scope })).resolves.toEqual([
|
||||
"openai/gpt-4.1",
|
||||
"openai/gpt-4o",
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips malformed legacy JSON entries during import", async () => {
|
||||
const env = await createStateEnv();
|
||||
const scope = { userId: "valid-legacy-user" };
|
||||
@@ -269,34 +207,4 @@ describe("discord model picker preferences", () => {
|
||||
const recent = await readDiscordModelPickerRecentModels({ env, scope });
|
||||
expect(new Set(recent)).toEqual(new Set(["openai/gpt-4o", "openai/gpt-4.1"]));
|
||||
});
|
||||
|
||||
it("keeps selections recent when the process clock is outside the Date range", async () => {
|
||||
const env = await createStateEnv();
|
||||
const scope = { userId: "invalid-clock-user" };
|
||||
await recordDiscordModelPickerRecentModel({ env, scope, modelRef: "openai/gpt-4.1" });
|
||||
await recordDiscordModelPickerRecentModel({ env, scope, modelRef: "openai/gpt-4o" });
|
||||
const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
|
||||
|
||||
try {
|
||||
await recordDiscordModelPickerRecentModel({
|
||||
env,
|
||||
scope,
|
||||
modelRef: "openai/gpt-5.5",
|
||||
limit: 2,
|
||||
});
|
||||
await recordDiscordModelPickerRecentModel({
|
||||
env,
|
||||
scope,
|
||||
modelRef: "openai/gpt-5.6",
|
||||
limit: 2,
|
||||
});
|
||||
} finally {
|
||||
dateNowSpy.mockRestore();
|
||||
}
|
||||
|
||||
await expect(readDiscordModelPickerRecentModels({ env, scope, limit: 3 })).resolves.toEqual([
|
||||
"openai/gpt-5.6",
|
||||
"openai/gpt-5.5",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,12 +3,6 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { normalizeAccountId as normalizeSharedAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { readJsonFileWithFallback } from "openclaw/plugin-sdk/json-store";
|
||||
import {
|
||||
MAX_DATE_TIMESTAMP_MS,
|
||||
resolveDateTimestampMs,
|
||||
resolveTimestampMsToIsoString,
|
||||
timestampMsToIsoString,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
@@ -19,13 +13,11 @@ const PREFERENCE_MAX_ENTRIES = 2_000;
|
||||
const MAX_PLUGIN_STATE_KEY_BYTES = 512;
|
||||
const textEncoder = new TextEncoder();
|
||||
let lastPreferenceTimestampMs = 0;
|
||||
let lastPreferenceOrder = 0;
|
||||
|
||||
type ModelPickerPreferencesEntry = {
|
||||
scopeKey: string;
|
||||
modelRef: string;
|
||||
updatedAt: string;
|
||||
updatedOrder?: number;
|
||||
};
|
||||
|
||||
type LegacyModelPickerPreferencesEntry = {
|
||||
@@ -132,7 +124,6 @@ function sanitizeStoredPreferenceEntry(value: unknown): ModelPickerPreferencesEn
|
||||
scopeKey?: unknown;
|
||||
modelRef?: unknown;
|
||||
updatedAt?: unknown;
|
||||
updatedOrder?: unknown;
|
||||
};
|
||||
if (typeof typedValue.scopeKey !== "string" || typeof typedValue.modelRef !== "string") {
|
||||
return undefined;
|
||||
@@ -145,10 +136,6 @@ function sanitizeStoredPreferenceEntry(value: unknown): ModelPickerPreferencesEn
|
||||
scopeKey: typedValue.scopeKey,
|
||||
modelRef,
|
||||
updatedAt: typeof typedValue.updatedAt === "string" ? typedValue.updatedAt : "",
|
||||
updatedOrder:
|
||||
typeof typedValue.updatedOrder === "number" && Number.isSafeInteger(typedValue.updatedOrder)
|
||||
? typedValue.updatedOrder
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -165,58 +152,13 @@ function timestampMs(value: string): number {
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function timestampOrder(value?: number): number {
|
||||
return value !== undefined && value >= 0 ? value : 0;
|
||||
}
|
||||
|
||||
function comparePreferenceEntries(
|
||||
left: { key: string; value: ModelPickerPreferencesEntry },
|
||||
right: { key: string; value: ModelPickerPreferencesEntry },
|
||||
): number {
|
||||
return (
|
||||
timestampMs(right.value.updatedAt) - timestampMs(left.value.updatedAt) ||
|
||||
timestampOrder(right.value.updatedOrder) - timestampOrder(left.value.updatedOrder) ||
|
||||
left.key.localeCompare(right.key)
|
||||
);
|
||||
}
|
||||
|
||||
function legacyUpdatedAtForIndex(updatedAt: string, index: number, total: number): string {
|
||||
const baseMs = timestampMs(updatedAt);
|
||||
const anchorMs = Math.min(baseMs + Math.max(0, total), MAX_DATE_TIMESTAMP_MS);
|
||||
const shiftedMs = anchorMs - Math.max(0, index);
|
||||
return (
|
||||
timestampMsToIsoString(shiftedMs) ??
|
||||
timestampMsToIsoString(baseMs) ??
|
||||
timestampMsToIsoString(Math.max(0, total - index)) ??
|
||||
"1970-01-01T00:00:00.000Z"
|
||||
);
|
||||
return new Date(timestampMs(updatedAt) + Math.max(0, total - index)).toISOString();
|
||||
}
|
||||
|
||||
function nextPreferenceTimestamp(existingEntries: ModelPickerPreferencesEntry[]): {
|
||||
updatedAt: string;
|
||||
updatedOrder: number;
|
||||
} {
|
||||
const existingMaxTimestampMs = existingEntries.reduce(
|
||||
(max, entry) => Math.max(max, timestampMs(entry.updatedAt)),
|
||||
0,
|
||||
);
|
||||
lastPreferenceTimestampMs = Math.min(
|
||||
Math.max(
|
||||
resolveDateTimestampMs(Date.now(), 0),
|
||||
lastPreferenceTimestampMs + 1,
|
||||
existingMaxTimestampMs + 1,
|
||||
),
|
||||
MAX_DATE_TIMESTAMP_MS,
|
||||
);
|
||||
const existingMaxOrder = existingEntries.reduce(
|
||||
(max, entry) => Math.max(max, timestampOrder(entry.updatedOrder)),
|
||||
0,
|
||||
);
|
||||
lastPreferenceOrder = Math.max(lastPreferenceOrder + 1, existingMaxOrder + 1);
|
||||
return {
|
||||
updatedAt: resolveTimestampMsToIsoString(lastPreferenceTimestampMs),
|
||||
updatedOrder: lastPreferenceOrder,
|
||||
};
|
||||
function nextPreferenceTimestampIso(): string {
|
||||
lastPreferenceTimestampMs = Math.max(Date.now(), lastPreferenceTimestampMs + 1);
|
||||
return new Date(lastPreferenceTimestampMs).toISOString();
|
||||
}
|
||||
|
||||
function normalizeLegacyPreferenceKey(key: string): string | undefined {
|
||||
@@ -295,13 +237,10 @@ export async function readDiscordModelPickerRecentModels(params: {
|
||||
await importLegacyPreferences(params.env);
|
||||
const store = openPreferenceStore(params.env);
|
||||
const recent = (await store.entries())
|
||||
.map((entry) => ({ key: entry.key, value: sanitizeStoredPreferenceEntry(entry.value) }))
|
||||
.filter(
|
||||
(entry): entry is { key: string; value: ModelPickerPreferencesEntry } =>
|
||||
entry.value?.scopeKey === key,
|
||||
)
|
||||
.toSorted(comparePreferenceEntries)
|
||||
.map((entry) => entry.value.modelRef);
|
||||
.map((entry) => sanitizeStoredPreferenceEntry(entry.value))
|
||||
.filter((entry): entry is ModelPickerPreferencesEntry => entry?.scopeKey === key)
|
||||
.toSorted((left, right) => timestampMs(right.updatedAt) - timestampMs(left.updatedAt))
|
||||
.map((entry) => entry.modelRef);
|
||||
if (!params.allowedModelRefs || params.allowedModelRefs.size === 0) {
|
||||
return sanitizeRecentModels(recent, limit);
|
||||
}
|
||||
@@ -329,14 +268,10 @@ export async function recordDiscordModelPickerRecentModel(params: {
|
||||
try {
|
||||
await importLegacyPreferences(params.env);
|
||||
const store = openPreferenceStore(params.env);
|
||||
const existingEntries = (await store.entries())
|
||||
.map((entry) => sanitizeStoredPreferenceEntry(entry.value))
|
||||
.filter((entry): entry is ModelPickerPreferencesEntry => entry?.scopeKey === key);
|
||||
const timestamp = nextPreferenceTimestamp(existingEntries);
|
||||
await store.register(buildPreferenceModelKey(key, normalizedModelRef), {
|
||||
scopeKey: key,
|
||||
modelRef: normalizedModelRef,
|
||||
...timestamp,
|
||||
updatedAt: nextPreferenceTimestampIso(),
|
||||
});
|
||||
const limit = Math.max(1, Math.min(params.limit ?? DEFAULT_RECENT_LIMIT, 10));
|
||||
const scopedEntries = (await store.entries())
|
||||
@@ -345,7 +280,11 @@ export async function recordDiscordModelPickerRecentModel(params: {
|
||||
(entry): entry is { key: string; value: ModelPickerPreferencesEntry } =>
|
||||
entry.value?.scopeKey === key,
|
||||
)
|
||||
.toSorted(comparePreferenceEntries);
|
||||
.toSorted(
|
||||
(left, right) =>
|
||||
timestampMs(right.value.updatedAt) - timestampMs(left.value.updatedAt) ||
|
||||
left.key.localeCompare(right.key),
|
||||
);
|
||||
await Promise.all(scopedEntries.slice(limit).map((entry) => store.delete(entry.key)));
|
||||
} catch {
|
||||
return;
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
|
||||
import { createTypingCallbacks } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { createDiscordRestClient } from "../client.js";
|
||||
import type { RequestClient } from "../internal/discord.js";
|
||||
import { sendTyping } from "./typing.js";
|
||||
|
||||
export const DISCORD_REPLY_TYPING_MAX_DURATION_MS = 20 * 60_000;
|
||||
|
||||
// Discord can keep long tool-heavy replies alive, but not forever.
|
||||
// The dispatch restart path refreshes this TTL after queue wait time.
|
||||
export type DiscordReplyTypingFeedback = ReturnType<typeof createTypingCallbacks> & {
|
||||
updateChannelId: (channelId: string) => void;
|
||||
getChannelId: () => string;
|
||||
restartForDispatch: (channelId: string) => void;
|
||||
};
|
||||
|
||||
export function createDiscordReplyTypingFeedback(params: {
|
||||
cfg: OpenClawConfig;
|
||||
token: string;
|
||||
accountId: string;
|
||||
channelId: string;
|
||||
rest?: RequestClient;
|
||||
log: (message: string) => void;
|
||||
maxDurationMs?: number;
|
||||
}): DiscordReplyTypingFeedback {
|
||||
let channelId = params.channelId;
|
||||
const rest =
|
||||
params.rest ??
|
||||
createDiscordRestClient({
|
||||
cfg: params.cfg,
|
||||
token: params.token,
|
||||
accountId: params.accountId,
|
||||
}).rest;
|
||||
const createCallbacks = () =>
|
||||
createTypingCallbacks({
|
||||
start: () => sendTyping({ rest, channelId }),
|
||||
onStartError: (err) => {
|
||||
logTypingFailure({
|
||||
log: params.log,
|
||||
channel: "discord",
|
||||
target: channelId,
|
||||
error: err,
|
||||
});
|
||||
},
|
||||
maxDurationMs: params.maxDurationMs ?? DISCORD_REPLY_TYPING_MAX_DURATION_MS,
|
||||
});
|
||||
const updateChannelId = (nextChannelId: string) => {
|
||||
const trimmed = nextChannelId.trim();
|
||||
if (trimmed) {
|
||||
channelId = trimmed;
|
||||
}
|
||||
};
|
||||
let callbacks = createCallbacks();
|
||||
return {
|
||||
// Expose one stable owner while allowing the inner typing controller to
|
||||
// rotate between prequeue feedback and the actual dispatch lifecycle.
|
||||
onReplyStart: () => callbacks.onReplyStart(),
|
||||
onIdle: () => callbacks.onIdle?.(),
|
||||
onCleanup: () => callbacks.onCleanup?.(),
|
||||
updateChannelId,
|
||||
restartForDispatch: (nextChannelId) => {
|
||||
updateChannelId(nextChannelId);
|
||||
// Prequeue typing may have hit its TTL before the job starts.
|
||||
// Rotate the inner controller so dispatch always owns a live heartbeat.
|
||||
callbacks.onCleanup?.();
|
||||
callbacks = createCallbacks();
|
||||
},
|
||||
getChannelId: () => channelId,
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { loadJsonFile, saveJsonFile } from "openclaw/plugin-sdk/json-store";
|
||||
import {
|
||||
isFutureDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { normalizeAccountId, resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing";
|
||||
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
||||
import {
|
||||
@@ -349,14 +345,9 @@ export function rememberRecentUnboundWebhookEcho(record: ThreadBindingRecord) {
|
||||
if (!bindingKey) {
|
||||
return;
|
||||
}
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(RECENT_UNBOUND_WEBHOOK_ECHO_WINDOW_MS);
|
||||
if (expiresAt === undefined) {
|
||||
RECENT_UNBOUND_WEBHOOK_ECHOES_BY_BINDING_KEY.delete(bindingKey);
|
||||
return;
|
||||
}
|
||||
RECENT_UNBOUND_WEBHOOK_ECHOES_BY_BINDING_KEY.set(bindingKey, {
|
||||
webhookId,
|
||||
expiresAt,
|
||||
expiresAt: Date.now() + RECENT_UNBOUND_WEBHOOK_ECHO_WINDOW_MS,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -417,7 +408,7 @@ export function isRecentlyUnboundThreadWebhookMessage(params: {
|
||||
if (!suppressed) {
|
||||
return false;
|
||||
}
|
||||
if (!isFutureDateTimestampMs(suppressed.expiresAt)) {
|
||||
if (suppressed.expiresAt <= Date.now()) {
|
||||
RECENT_UNBOUND_WEBHOOK_ECHOES_BY_BINDING_KEY.delete(bindingKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -121,7 +121,6 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -323,18 +322,6 @@ describe("sendMessageDiscord", () => {
|
||||
).toBeTypeOf("string");
|
||||
});
|
||||
|
||||
it("rejects timeout durations outside Date range", async () => {
|
||||
const { rest, patchMock } = makeDiscordRest();
|
||||
|
||||
await expect(
|
||||
timeoutMemberDiscord(
|
||||
{ guildId: "g1", userId: "u1", durationMinutes: 8_640_000_000_000_001 },
|
||||
discordClientOpts(rest),
|
||||
),
|
||||
).rejects.toThrow("Discord timeout duration is outside the supported Date range");
|
||||
expect(patchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adds and removes roles", async () => {
|
||||
const { rest, putMock, deleteMock } = makeDiscordRest();
|
||||
putMock.mockResolvedValue({});
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
APIVoiceState,
|
||||
RESTPostAPIGuildScheduledEventJSONBody,
|
||||
} from "discord-api-types/v10";
|
||||
import { timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { loadWebMediaRaw } from "openclaw/plugin-sdk/web-media";
|
||||
import {
|
||||
@@ -140,10 +139,7 @@ export async function timeoutMemberDiscord(
|
||||
let until = payload.until;
|
||||
if (!until && payload.durationMinutes) {
|
||||
const ms = payload.durationMinutes * 60 * 1000;
|
||||
until = timestampMsToIsoString(Date.now() + ms);
|
||||
if (!until) {
|
||||
throw new Error("Discord timeout duration is outside the supported Date range");
|
||||
}
|
||||
until = new Date(Date.now() + ms).toISOString();
|
||||
}
|
||||
return await timeoutGuildMember(rest, payload.guildId, payload.userId, {
|
||||
body: { communication_disabled_until: until ?? null },
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { PassThrough } from "node:stream";
|
||||
import type { DiscordAccountConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
buildRealtimeVoiceAgentConsultChatMessage,
|
||||
buildRealtimeVoiceAgentConsultPolicyInstructions,
|
||||
@@ -1501,14 +1497,10 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(DISCORD_REALTIME_WAKE_NAME_FOLLOWUP_TTL_MS);
|
||||
if (expiresAt === undefined) {
|
||||
return;
|
||||
}
|
||||
this.pendingWakeNameFollowup = {
|
||||
context,
|
||||
startedAt: turn?.startedAt ?? Date.now(),
|
||||
expiresAt,
|
||||
expiresAt: Date.now() + DISCORD_REALTIME_WAKE_NAME_FOLLOWUP_TTL_MS,
|
||||
};
|
||||
logger.info(
|
||||
`discord voice: realtime wake-name follow-up armed speaker=${context.speakerLabel} voiceSession=${this.params.entry.voiceSessionKey} agent=${this.params.entry.route.agentId}`,
|
||||
@@ -1518,9 +1510,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
|
||||
private consumePendingWakeNameFollowup(): TranscriptUtteranceAttribution | undefined {
|
||||
const pending = this.pendingWakeNameFollowup;
|
||||
this.pendingWakeNameFollowup = undefined;
|
||||
const now = asDateTimestampMs(Date.now());
|
||||
const expiresAt = pending ? asDateTimestampMs(pending.expiresAt) : undefined;
|
||||
if (!pending || now === undefined || expiresAt === undefined || now > expiresAt) {
|
||||
if (!pending || Date.now() > pending.expiresAt) {
|
||||
return undefined;
|
||||
}
|
||||
const currentTurn = this.peekPendingSpeakerTurn();
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DiscordRealtimeVoiceSession } from "./realtime.js";
|
||||
|
||||
type WakeNameFollowupTestSession = {
|
||||
armWakeNameFollowup: () => void;
|
||||
consumePendingWakeNameFollowup: () => unknown;
|
||||
pendingWakeNameFollowup?: unknown;
|
||||
speakerTurns: {
|
||||
consumeAudioContext: () => unknown;
|
||||
peekAudioTurn: () => unknown;
|
||||
};
|
||||
};
|
||||
|
||||
function createSession(): WakeNameFollowupTestSession {
|
||||
return new DiscordRealtimeVoiceSession({
|
||||
cfg: {},
|
||||
discordConfig: { voice: { realtime: {} } },
|
||||
entry: {
|
||||
voiceSessionKey: "voice-1",
|
||||
route: { agentId: "agent-1" },
|
||||
},
|
||||
mode: "agent-proxy",
|
||||
runAgentTurn: vi.fn(),
|
||||
} as never) as unknown as WakeNameFollowupTestSession;
|
||||
}
|
||||
|
||||
describe("DiscordRealtimeVoiceSession wake-name follow-up cache", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("arms and consumes a valid wake-name follow-up", () => {
|
||||
const session = createSession();
|
||||
session.speakerTurns = {
|
||||
consumeAudioContext: vi.fn(() => ({
|
||||
userId: "u1",
|
||||
speakerLabel: "Ada",
|
||||
senderIsOwner: true,
|
||||
})),
|
||||
peekAudioTurn: vi.fn(() => undefined),
|
||||
};
|
||||
|
||||
session.armWakeNameFollowup();
|
||||
|
||||
expect(session.consumePendingWakeNameFollowup()).toMatchObject({
|
||||
context: { userId: "u1", speakerLabel: "Ada" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not arm follow-ups when the expiry would exceed Date range", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(8_640_000_000_000_000));
|
||||
const session = createSession();
|
||||
session.speakerTurns = {
|
||||
consumeAudioContext: vi.fn(() => ({
|
||||
userId: "u1",
|
||||
speakerLabel: "Ada",
|
||||
senderIsOwner: true,
|
||||
})),
|
||||
peekAudioTurn: vi.fn(() => undefined),
|
||||
};
|
||||
|
||||
session.armWakeNameFollowup();
|
||||
|
||||
expect(session.pendingWakeNameFollowup).toBeUndefined();
|
||||
expect(session.consumePendingWakeNameFollowup()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Client } from "../internal/discord.js";
|
||||
import { DiscordVoiceSpeakerContextResolver } from "./speaker-context.js";
|
||||
|
||||
function createClient(fetchMember: ReturnType<typeof vi.fn>): Client {
|
||||
return {
|
||||
fetchMember,
|
||||
fetchUser: vi.fn(),
|
||||
} as unknown as Client;
|
||||
}
|
||||
|
||||
describe("DiscordVoiceSpeakerContextResolver", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("reuses cached speaker context for repeated speaker lookups", async () => {
|
||||
const fetchMember = vi.fn().mockResolvedValue({
|
||||
nickname: "Ada",
|
||||
roles: [],
|
||||
user: { id: "u1", username: "ada", globalName: "Ada" },
|
||||
});
|
||||
const resolver = new DiscordVoiceSpeakerContextResolver({
|
||||
client: createClient(fetchMember),
|
||||
});
|
||||
|
||||
await expect(resolver.resolveContext("g1", "u1")).resolves.toMatchObject({ label: "Ada" });
|
||||
await expect(resolver.resolveContext("g1", "u1")).resolves.toMatchObject({ label: "Ada" });
|
||||
|
||||
expect(fetchMember).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not cache speaker context when the cache expiry would exceed Date range", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(8_640_000_000_000_000));
|
||||
const fetchMember = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
nickname: "Ada",
|
||||
roles: [],
|
||||
user: { id: "u1", username: "ada", globalName: "Ada" },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
nickname: "Grace",
|
||||
roles: [],
|
||||
user: { id: "u1", username: "grace", globalName: "Grace" },
|
||||
});
|
||||
const resolver = new DiscordVoiceSpeakerContextResolver({
|
||||
client: createClient(fetchMember),
|
||||
});
|
||||
|
||||
await expect(resolver.resolveContext("g1", "u1")).resolves.toMatchObject({ label: "Ada" });
|
||||
await expect(resolver.resolveContext("g1", "u1")).resolves.toMatchObject({ label: "Grace" });
|
||||
|
||||
expect(fetchMember).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,3 @@
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import type { Client } from "../internal/discord.js";
|
||||
import { resolveDiscordOwnerAccess } from "../monitor/allow-list.js";
|
||||
import { formatDiscordUserTag } from "../monitor/format.js";
|
||||
@@ -108,9 +104,7 @@ export class DiscordVoiceSpeakerContextResolver {
|
||||
if (!cached) {
|
||||
return undefined;
|
||||
}
|
||||
const now = asDateTimestampMs(Date.now());
|
||||
const expiresAt = asDateTimestampMs(cached.expiresAt);
|
||||
if (now === undefined || expiresAt === undefined || expiresAt <= now) {
|
||||
if (cached.expiresAt <= Date.now()) {
|
||||
this.cache.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
@@ -125,12 +119,9 @@ export class DiscordVoiceSpeakerContextResolver {
|
||||
|
||||
private setCachedContext(guildId: string, userId: string, context: VoiceSpeakerContext): void {
|
||||
const key = this.resolveCacheKey(guildId, userId);
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(SPEAKER_CONTEXT_CACHE_TTL_MS);
|
||||
if (expiresAt !== undefined) {
|
||||
this.cache.set(key, {
|
||||
...context,
|
||||
expiresAt,
|
||||
});
|
||||
}
|
||||
this.cache.set(key, {
|
||||
...context,
|
||||
expiresAt: Date.now() + SPEAKER_CONTEXT_CACHE_TTL_MS,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,37 +84,6 @@ describe("resolveGroupName", () => {
|
||||
expect(mockGetChatInfo).toHaveBeenCalledOnce(); // only 1 API call
|
||||
});
|
||||
|
||||
it("does not cache group names when the expiry would exceed a valid Date", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(8_640_000_000_000_000));
|
||||
try {
|
||||
mockGetChatInfo.mockResolvedValue({ name: "Boundary Group" });
|
||||
|
||||
const first = await resolveGroupName({ account, chatId: "oc_boundary", log });
|
||||
const second = await resolveGroupName({ account, chatId: "oc_boundary", log });
|
||||
|
||||
expect(first).toBe("Boundary Group");
|
||||
expect(second).toBe("Boundary Group");
|
||||
expect(mockGetChatInfo).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("evicts cached group names when the current clock is invalid", async () => {
|
||||
mockGetChatInfo.mockResolvedValue({ name: "Cached Group" });
|
||||
await resolveGroupName({ account, chatId: "oc_invalid_clock", log });
|
||||
const dateNow = vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
|
||||
try {
|
||||
const result = await resolveGroupName({ account, chatId: "oc_invalid_clock", log });
|
||||
|
||||
expect(result).toBe("Cached Group");
|
||||
} finally {
|
||||
dateNow.mockRestore();
|
||||
}
|
||||
expect(mockGetChatInfo).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("caches negative result (API failure) and skips retry", async () => {
|
||||
mockGetChatInfo.mockRejectedValue(new Error("fail"));
|
||||
await resolveGroupName({ account, chatId: "oc_test5", log });
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveFeishuSenderName } from "./bot-sender-name.js";
|
||||
import { FeishuConfigSchema } from "./config-schema.js";
|
||||
import type { ResolvedFeishuAccount } from "./types.js";
|
||||
|
||||
const createFeishuClientMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
createFeishuClient: createFeishuClientMock,
|
||||
}));
|
||||
|
||||
const account = {
|
||||
accountId: "main",
|
||||
selectionSource: "explicit",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
appId: "app-id",
|
||||
appSecret: "secret",
|
||||
domain: "feishu",
|
||||
config: FeishuConfigSchema.parse({}),
|
||||
} satisfies ResolvedFeishuAccount;
|
||||
|
||||
function mockUserNames(...names: string[]): ReturnType<typeof vi.fn> {
|
||||
const get = vi.fn();
|
||||
for (const name of names) {
|
||||
get.mockResolvedValueOnce({ data: { user: { name } } });
|
||||
}
|
||||
createFeishuClientMock.mockReturnValue({
|
||||
contact: { user: { get } },
|
||||
});
|
||||
return get;
|
||||
}
|
||||
|
||||
describe("resolveFeishuSenderName", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
createFeishuClientMock.mockReset();
|
||||
});
|
||||
|
||||
it("reuses a cached sender name within the TTL", async () => {
|
||||
const get = mockUserNames("Ada");
|
||||
|
||||
await expect(
|
||||
resolveFeishuSenderName({ account, senderId: "ou_sender_cache", log: vi.fn() }),
|
||||
).resolves.toEqual({ name: "Ada" });
|
||||
await expect(
|
||||
resolveFeishuSenderName({ account, senderId: "ou_sender_cache", log: vi.fn() }),
|
||||
).resolves.toEqual({ name: "Ada" });
|
||||
|
||||
expect(get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not cache sender names when the expiry would exceed Date range", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(8_640_000_000_000_000));
|
||||
const get = mockUserNames("Ada", "Grace");
|
||||
|
||||
await expect(
|
||||
resolveFeishuSenderName({ account, senderId: "ou_sender_overflow", log: vi.fn() }),
|
||||
).resolves.toEqual({ name: "Ada" });
|
||||
await expect(
|
||||
resolveFeishuSenderName({ account, senderId: "ou_sender_overflow", log: vi.fn() }),
|
||||
).resolves.toEqual({ name: "Grace" });
|
||||
|
||||
expect(get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,3 @@
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import type { ResolvedFeishuAccount } from "./types.js";
|
||||
@@ -93,14 +89,10 @@ export async function resolveFeishuSenderName(params: {
|
||||
}
|
||||
|
||||
const cached = senderNameCache.get(normalizedSenderId);
|
||||
const now = asDateTimestampMs(Date.now());
|
||||
const cachedExpireAt = cached ? asDateTimestampMs(cached.expireAt) : undefined;
|
||||
if (cached && now !== undefined && cachedExpireAt !== undefined && cachedExpireAt > now) {
|
||||
const now = Date.now();
|
||||
if (cached && cached.expireAt > now) {
|
||||
return { name: cached.name };
|
||||
}
|
||||
if (cached) {
|
||||
senderNameCache.delete(normalizedSenderId);
|
||||
}
|
||||
|
||||
try {
|
||||
const client = createFeishuClient(account);
|
||||
@@ -113,10 +105,7 @@ export async function resolveFeishuSenderName(params: {
|
||||
const name = user?.name ?? user?.nickname ?? user?.en_name;
|
||||
|
||||
if (name) {
|
||||
const expireAt = resolveExpiresAtMsFromDurationMs(SENDER_NAME_TTL_MS);
|
||||
if (expireAt !== undefined) {
|
||||
senderNameCache.set(normalizedSenderId, { name, expireAt });
|
||||
}
|
||||
senderNameCache.set(normalizedSenderId, { name, expireAt: now + SENDER_NAME_TTL_MS });
|
||||
return { name };
|
||||
}
|
||||
return {};
|
||||
|
||||
@@ -270,45 +270,6 @@ describe("Feishu Card Action Handler", () => {
|
||||
expect(handleFeishuMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not open approval cards when the expiry would exceed a valid Date", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(8_640_000_000_000_000));
|
||||
try {
|
||||
const event: FeishuCardActionEvent = {
|
||||
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
|
||||
token: "tok4-boundary",
|
||||
action: {
|
||||
value: createFeishuCardInteractionEnvelope({
|
||||
k: "meta",
|
||||
a: FEISHU_APPROVAL_REQUEST_ACTION,
|
||||
m: {
|
||||
command: "/new",
|
||||
prompt: "Start a fresh session?",
|
||||
},
|
||||
c: {
|
||||
u: "u123",
|
||||
h: "chat1",
|
||||
t: "group",
|
||||
s: "agent:codex:feishu:chat:chat1",
|
||||
e: 8_640_000_000_000_000,
|
||||
},
|
||||
}),
|
||||
tag: "button",
|
||||
},
|
||||
context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
|
||||
};
|
||||
|
||||
await handleFeishuCardAction({ cfg, event, runtime, accountId: "main" });
|
||||
|
||||
expect(sendCardFeishuMock).not.toHaveBeenCalled();
|
||||
const sendMessage = sendMessageCall();
|
||||
expect(sendMessage.to).toBe("chat:chat1");
|
||||
expect(String(sendMessage.text)).toContain("payload is invalid");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("runs approval confirmation through the normal message path", async () => {
|
||||
const event = createStructuredQuickActionEvent({
|
||||
token: "tok5",
|
||||
@@ -415,39 +376,6 @@ describe("Feishu Card Action Handler", () => {
|
||||
expect(createFeishuClientMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not cache resolved chat type when expiry would exceed a valid Date", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(8_640_000_000_000_000));
|
||||
try {
|
||||
const getChat = vi.fn().mockResolvedValue({ code: 0, data: { chat_type: "p2p" } });
|
||||
createFeishuClientMock.mockReturnValue({
|
||||
im: {
|
||||
chat: {
|
||||
get: getChat,
|
||||
},
|
||||
},
|
||||
});
|
||||
const firstEvent = createCardActionEvent({
|
||||
token: "tok9b-boundary-1",
|
||||
chatId: "oc_dm_chat_boundary",
|
||||
actionValue: { text: "/help" },
|
||||
});
|
||||
const secondEvent = createCardActionEvent({
|
||||
token: "tok9b-boundary-2",
|
||||
chatId: "oc_dm_chat_boundary",
|
||||
actionValue: { text: "/help" },
|
||||
});
|
||||
|
||||
await handleFeishuCardAction({ cfg, event: firstEvent, runtime });
|
||||
await handleFeishuCardAction({ cfg, event: secondEvent, runtime });
|
||||
|
||||
expect(getChat).toHaveBeenCalledTimes(2);
|
||||
expect(handleFeishuMessage).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses resolved DM chat type when building approval cards without stored context", async () => {
|
||||
createFeishuClientMock.mockReturnValueOnce({
|
||||
im: {
|
||||
@@ -531,20 +459,6 @@ describe("Feishu Card Action Handler", () => {
|
||||
expect(handleFeishuMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not cache callback tokens when token ttl expiry overflows", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(8_640_000_000_000_000));
|
||||
const event = createCardActionEvent({
|
||||
token: "tok10-boundary",
|
||||
actionValue: { text: "/help" },
|
||||
});
|
||||
|
||||
await handleFeishuCardAction({ cfg, event, runtime });
|
||||
await handleFeishuCardAction({ cfg, event, runtime });
|
||||
|
||||
expect(handleFeishuMessage).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("rejects empty callback tokens before dispatch", async () => {
|
||||
const log = vi.fn();
|
||||
const event = createStructuredQuickActionEvent({
|
||||
|
||||
@@ -10,11 +10,7 @@ import {
|
||||
resolveConfiguredBindingRoute,
|
||||
resolveRuntimeConversationBindingRoute,
|
||||
} from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
parseStrictNonNegativeInteger,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
createChannelHistoryWindow,
|
||||
@@ -112,14 +108,9 @@ function isFeishuTopicSessionScope(scope: FeishuGroupSessionScope): boolean {
|
||||
}
|
||||
|
||||
function evictGroupNameCache(): void {
|
||||
const now = asDateTimestampMs(Date.now());
|
||||
if (now === undefined) {
|
||||
groupNameCache.clear();
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
for (const [key, val] of groupNameCache) {
|
||||
const expiresAt = asDateTimestampMs(val.expiresAt);
|
||||
if (expiresAt === undefined || expiresAt <= now) {
|
||||
if (val.expiresAt <= now) {
|
||||
groupNameCache.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -137,12 +128,9 @@ function evictGroupNameCache(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function setCacheEntry(key: string, name: string): void {
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(GROUP_NAME_CACHE_TTL_MS);
|
||||
function setCacheEntry(key: string, value: { name: string; expiresAt: number }): void {
|
||||
groupNameCache.delete(key);
|
||||
if (expiresAt !== undefined) {
|
||||
groupNameCache.set(key, { name, expiresAt });
|
||||
}
|
||||
groupNameCache.set(key, value);
|
||||
}
|
||||
|
||||
export function clearGroupNameCache(): void {
|
||||
@@ -162,34 +150,37 @@ export async function resolveGroupName(params: {
|
||||
const cacheKey = `${account.accountId}:${chatId}`;
|
||||
|
||||
const cached = groupNameCache.get(cacheKey);
|
||||
if (cached) {
|
||||
const now = asDateTimestampMs(Date.now());
|
||||
const expiresAt = asDateTimestampMs(cached.expiresAt);
|
||||
if (now !== undefined && expiresAt !== undefined && expiresAt > now) {
|
||||
return cached.name || undefined;
|
||||
}
|
||||
groupNameCache.delete(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.name || undefined;
|
||||
}
|
||||
|
||||
let resolvedName: string | undefined;
|
||||
try {
|
||||
const client = createFeishuClient(account);
|
||||
const chatInfo = await getChatInfo(client, chatId);
|
||||
const name = chatInfo?.name?.trim();
|
||||
if (name) {
|
||||
setCacheEntry(cacheKey, name);
|
||||
resolvedName = name;
|
||||
setCacheEntry(cacheKey, {
|
||||
name,
|
||||
expiresAt: Date.now() + GROUP_NAME_CACHE_TTL_MS,
|
||||
});
|
||||
} else {
|
||||
setCacheEntry(cacheKey, "");
|
||||
setCacheEntry(cacheKey, {
|
||||
name: "",
|
||||
expiresAt: Date.now() + GROUP_NAME_CACHE_TTL_MS,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
log(`feishu[${account.accountId}]: getChatInfo failed for ${chatId}: ${String(err)}`);
|
||||
setCacheEntry(cacheKey, "");
|
||||
setCacheEntry(cacheKey, {
|
||||
name: "",
|
||||
expiresAt: Date.now() + GROUP_NAME_CACHE_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
const result = groupNameCache.get(cacheKey)?.name || undefined;
|
||||
evictGroupNameCache();
|
||||
|
||||
return resolvedName;
|
||||
return result;
|
||||
}
|
||||
|
||||
async function resolveFeishuAudioPreflightTranscript(params: {
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
isFutureDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js";
|
||||
import { resolveFeishuRuntimeAccount } from "./accounts.js";
|
||||
import { handleFeishuMessage, type FeishuMessageEvent } from "./bot.js";
|
||||
@@ -52,26 +47,16 @@ export class FeishuRetryableCardActionError extends Error {
|
||||
|
||||
export function resetProcessedFeishuCardActionTokensForTests(): void {
|
||||
processedCardActionTokens.clear();
|
||||
resolvedChatTypeCache.clear();
|
||||
}
|
||||
|
||||
function pruneProcessedCardActionTokens(now: number): void {
|
||||
const validNow = asDateTimestampMs(now);
|
||||
if (validNow === undefined) {
|
||||
processedCardActionTokens.clear();
|
||||
return;
|
||||
}
|
||||
for (const [key, entry] of processedCardActionTokens.entries()) {
|
||||
if (!isFutureDateTimestampMs(entry.expiresAt, { nowMs: validNow })) {
|
||||
if (entry.expiresAt <= now) {
|
||||
processedCardActionTokens.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveProcessedCardActionTokenExpiresAt(now: number): number | undefined {
|
||||
return resolveExpiresAtMsFromDurationMs(FEISHU_CARD_ACTION_TOKEN_TTL_MS, { nowMs: now });
|
||||
}
|
||||
|
||||
function beginFeishuCardActionToken(params: {
|
||||
token: string;
|
||||
accountId: string;
|
||||
@@ -85,17 +70,13 @@ function beginFeishuCardActionToken(params: {
|
||||
}
|
||||
const key = `${params.accountId}:${normalizedToken}`;
|
||||
const existing = processedCardActionTokens.get(key);
|
||||
if (existing && isFutureDateTimestampMs(existing.expiresAt, { nowMs: now })) {
|
||||
if (existing && existing.expiresAt > now) {
|
||||
return false;
|
||||
}
|
||||
processedCardActionTokens.delete(key);
|
||||
const expiresAt = resolveProcessedCardActionTokenExpiresAt(now);
|
||||
if (expiresAt !== undefined) {
|
||||
processedCardActionTokens.set(key, {
|
||||
status: "inflight",
|
||||
expiresAt,
|
||||
});
|
||||
}
|
||||
processedCardActionTokens.set(key, {
|
||||
status: "inflight",
|
||||
expiresAt: now + FEISHU_CARD_ACTION_TOKEN_TTL_MS,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -109,15 +90,9 @@ function completeFeishuCardActionToken(params: {
|
||||
if (!normalizedToken) {
|
||||
return;
|
||||
}
|
||||
const key = `${params.accountId}:${normalizedToken}`;
|
||||
const expiresAt = resolveProcessedCardActionTokenExpiresAt(now);
|
||||
if (expiresAt === undefined) {
|
||||
processedCardActionTokens.delete(key);
|
||||
return;
|
||||
}
|
||||
processedCardActionTokens.set(key, {
|
||||
processedCardActionTokens.set(`${params.accountId}:${normalizedToken}`, {
|
||||
status: "completed",
|
||||
expiresAt,
|
||||
expiresAt: now + FEISHU_CARD_ACTION_TOKEN_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -210,14 +185,8 @@ const CHAT_TYPE_CACHE_TTL_MS = 30 * 60_000;
|
||||
const CHAT_TYPE_CACHE_MAX_SIZE = 5_000;
|
||||
|
||||
function pruneChatTypeCache(now: number): void {
|
||||
const validNow = asDateTimestampMs(now);
|
||||
if (validNow === undefined) {
|
||||
resolvedChatTypeCache.clear();
|
||||
return;
|
||||
}
|
||||
for (const [key, entry] of resolvedChatTypeCache.entries()) {
|
||||
const expiresAt = asDateTimestampMs(entry.expiresAt);
|
||||
if (expiresAt === undefined || expiresAt <= validNow) {
|
||||
if (entry.expiresAt <= now) {
|
||||
resolvedChatTypeCache.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -237,25 +206,6 @@ function sanitizeLogValue(v: string): string {
|
||||
return v.replace(/[\r\n]/g, " ").slice(0, 500);
|
||||
}
|
||||
|
||||
function resolveFeishuApprovalCardExpiresAt(nowRaw = Date.now()): number | undefined {
|
||||
const now = asDateTimestampMs(nowRaw);
|
||||
return now === undefined
|
||||
? undefined
|
||||
: resolveExpiresAtMsFromDurationMs(FEISHU_APPROVAL_CARD_TTL_MS, { nowMs: now });
|
||||
}
|
||||
|
||||
function cacheResolvedCardActionChatType(
|
||||
cacheKey: string,
|
||||
value: "p2p" | "group",
|
||||
now: number,
|
||||
): void {
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(CHAT_TYPE_CACHE_TTL_MS, { nowMs: now });
|
||||
resolvedChatTypeCache.delete(cacheKey);
|
||||
if (expiresAt !== undefined) {
|
||||
resolvedChatTypeCache.set(cacheKey, { value, expiresAt });
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveCardActionChatType(params: {
|
||||
event: FeishuCardActionEvent;
|
||||
account: ReturnType<typeof resolveFeishuRuntimeAccount>;
|
||||
@@ -276,12 +226,8 @@ async function resolveCardActionChatType(params: {
|
||||
const now = Date.now();
|
||||
pruneChatTypeCache(now);
|
||||
const cached = resolvedChatTypeCache.get(cacheKey);
|
||||
const cachedExpiresAt = cached ? asDateTimestampMs(cached.expiresAt) : undefined;
|
||||
if (cached && cachedExpiresAt !== undefined) {
|
||||
return cached.value;
|
||||
}
|
||||
if (cached) {
|
||||
resolvedChatTypeCache.delete(cacheKey);
|
||||
return cached.value;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -293,7 +239,10 @@ async function resolveCardActionChatType(params: {
|
||||
normalizeResolvedCardActionChatType(response.data?.chat_mode) ??
|
||||
normalizeResolvedCardActionChatType(response.data?.chat_type);
|
||||
if (resolvedChatType) {
|
||||
cacheResolvedCardActionChatType(cacheKey, resolvedChatType, now);
|
||||
resolvedChatTypeCache.set(cacheKey, {
|
||||
value: resolvedChatType,
|
||||
expiresAt: now + CHAT_TYPE_CACHE_TTL_MS,
|
||||
});
|
||||
return resolvedChatType;
|
||||
}
|
||||
params.log(
|
||||
@@ -400,17 +349,6 @@ export async function handleFeishuCardAction(params: {
|
||||
typeof envelope.m?.prompt === "string" && envelope.m.prompt.trim()
|
||||
? envelope.m.prompt
|
||||
: `Run \`${command}\` in this Feishu conversation?`;
|
||||
const expiresAt = resolveFeishuApprovalCardExpiresAt();
|
||||
if (expiresAt === undefined) {
|
||||
await sendInvalidInteractionNotice({
|
||||
cfg,
|
||||
event,
|
||||
reason: "malformed",
|
||||
accountId,
|
||||
});
|
||||
completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
|
||||
return;
|
||||
}
|
||||
await sendCardFeishu({
|
||||
cfg,
|
||||
to: resolveCallbackTarget(event),
|
||||
@@ -420,7 +358,7 @@ export async function handleFeishuCardAction(params: {
|
||||
command,
|
||||
prompt,
|
||||
sessionKey: envelope.c?.s,
|
||||
expiresAt,
|
||||
expiresAt: Date.now() + FEISHU_APPROVAL_CARD_TTL_MS,
|
||||
chatType: await resolveCardActionChatType({
|
||||
event,
|
||||
account,
|
||||
|
||||
@@ -88,25 +88,6 @@ describe("feishu quick-action launcher", () => {
|
||||
expectFirstSentCardUsesFillWidthOnly(sendCardFeishuMock);
|
||||
});
|
||||
|
||||
it("does not send launcher cards when expiry would exceed a valid Date", async () => {
|
||||
const runtime: RuntimeEnv = createRuntimeEnv();
|
||||
|
||||
const handled = await maybeHandleFeishuQuickActionMenu({
|
||||
cfg,
|
||||
eventKey: "quick-actions",
|
||||
operatorOpenId: "u123",
|
||||
accountId: "main",
|
||||
runtime,
|
||||
now: 8_640_000_000_000_000,
|
||||
});
|
||||
|
||||
expect(handled).toBe(false);
|
||||
expect(sendCardFeishuMock).not.toHaveBeenCalled();
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
"feishu[main]: failed to open quick-action launcher for u123: invalid expiry clock",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to legacy menu handling when launcher send fails", async () => {
|
||||
sendCardFeishuMock.mockRejectedValueOnce(new Error("network"));
|
||||
const runtime: RuntimeEnv = createRuntimeEnv();
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js";
|
||||
import { createFeishuCardInteractionEnvelope } from "./card-interaction.js";
|
||||
@@ -100,17 +96,7 @@ export async function maybeHandleFeishuQuickActionMenu(params: {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = asDateTimestampMs(params.now ?? Date.now());
|
||||
const expiresAt =
|
||||
now === undefined
|
||||
? undefined
|
||||
: resolveExpiresAtMsFromDurationMs(FEISHU_QUICK_ACTION_CARD_TTL_MS, { nowMs: now });
|
||||
if (expiresAt === undefined) {
|
||||
params.runtime?.log?.(
|
||||
`feishu[${params.accountId ?? "default"}]: failed to open quick-action launcher for ${params.operatorOpenId}: invalid expiry clock`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const expiresAt = (params.now ?? Date.now()) + FEISHU_QUICK_ACTION_CARD_TTL_MS;
|
||||
try {
|
||||
await sendCardFeishu({
|
||||
cfg: params.cfg,
|
||||
|
||||
@@ -187,32 +187,6 @@ describe("probeFeishu", () => {
|
||||
expect(requestFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not cache probe results when the expiry would exceed a valid Date", async () => {
|
||||
await withFakeTimers(async () => {
|
||||
vi.setSystemTime(new Date(8_640_000_000_000_000));
|
||||
const requestFn = setupSuccessClient();
|
||||
|
||||
const { first, second } = await readSequentialDefaultProbePair();
|
||||
|
||||
expect(first).toEqual(second);
|
||||
expect(requestFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("evicts cached probe results when the current clock is invalid", async () => {
|
||||
const requestFn = setupSuccessClient();
|
||||
|
||||
await probeFeishu(DEFAULT_CREDS);
|
||||
const dateNow = vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
|
||||
try {
|
||||
await probeFeishu(DEFAULT_CREDS);
|
||||
} finally {
|
||||
dateNow.mockRestore();
|
||||
}
|
||||
|
||||
expect(requestFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("makes a fresh API call after cache expires", async () => {
|
||||
await withFakeTimers(async () => {
|
||||
const requestFn = setupSuccessClient();
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { raceWithTimeoutAndAbort } from "./async.js";
|
||||
import { createFeishuClient, type FeishuClientCredentials } from "./client.js";
|
||||
import type { FeishuProbeResult } from "./types.js";
|
||||
@@ -42,12 +38,7 @@ function setCachedProbeResult(
|
||||
result: FeishuProbeResult,
|
||||
ttlMs: number,
|
||||
): FeishuProbeResult {
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(ttlMs);
|
||||
if (expiresAt === undefined) {
|
||||
probeCache.delete(cacheKey);
|
||||
return result;
|
||||
}
|
||||
probeCache.set(cacheKey, { result, expiresAt });
|
||||
probeCache.set(cacheKey, { result, expiresAt: Date.now() + ttlMs });
|
||||
if (probeCache.size > MAX_PROBE_CACHE_SIZE) {
|
||||
const oldest = probeCache.keys().next().value;
|
||||
if (oldest !== undefined) {
|
||||
@@ -83,13 +74,8 @@ export async function probeFeishu(
|
||||
// pollute each other's cache entry.
|
||||
const cacheKey = creds.accountId ?? `${creds.appId}:${creds.appSecret.slice(0, 8)}`;
|
||||
const cached = probeCache.get(cacheKey);
|
||||
if (cached) {
|
||||
const now = asDateTimestampMs(Date.now());
|
||||
const expiresAt = asDateTimestampMs(cached.expiresAt);
|
||||
if (now !== undefined && expiresAt !== undefined && expiresAt > now) {
|
||||
return cached.result;
|
||||
}
|
||||
probeCache.delete(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.result;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -5,7 +5,6 @@ type StreamingSessionStub = {
|
||||
start: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
discard: ReturnType<typeof vi.fn>;
|
||||
isActive: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
@@ -85,9 +84,6 @@ vi.mock("./streaming-card.js", () => {
|
||||
close = vi.fn(async () => {
|
||||
this.active = false;
|
||||
});
|
||||
discard = vi.fn(async () => {
|
||||
this.active = false;
|
||||
});
|
||||
isActive = vi.fn(() => this.active);
|
||||
|
||||
constructor() {
|
||||
@@ -167,7 +163,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function useNonStreamingAutoAccount() {
|
||||
function setupNonStreamingAutoDispatcher() {
|
||||
resolveFeishuAccountMock.mockReturnValue({
|
||||
accountId: "main",
|
||||
appId: "app_id",
|
||||
@@ -178,10 +174,6 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
streaming: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function setupNonStreamingAutoDispatcher() {
|
||||
useNonStreamingAutoAccount();
|
||||
|
||||
createFeishuReplyDispatcher({
|
||||
cfg: {} as never,
|
||||
@@ -386,66 +378,16 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("streams auto mode plain final text when streaming is enabled", async () => {
|
||||
it("keeps auto mode plain text on non-streaming send path", async () => {
|
||||
const { options } = createDispatcherHarness();
|
||||
await options.deliver({ text: "plain text" }, { kind: "final" });
|
||||
await options.onIdle?.();
|
||||
|
||||
expect(streamingInstances).toHaveLength(1);
|
||||
expect(streamingInstances[0].close).toHaveBeenCalledWith("plain text", {
|
||||
note: "Agent: agent",
|
||||
});
|
||||
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
||||
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps auto mode plain tool text on the message path when streaming is enabled", async () => {
|
||||
const { options } = createDispatcherHarness();
|
||||
await options.deliver({ text: "tool summary" }, { kind: "tool" });
|
||||
|
||||
expect(streamingInstances).toHaveLength(0);
|
||||
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
|
||||
expectMockArgFields(sendMessageFeishuMock, "message send params", {
|
||||
text: "tool summary",
|
||||
});
|
||||
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps active auto mode streaming sessions from swallowing tool text", async () => {
|
||||
const { result, options } = createDispatcherHarness({
|
||||
runtime: createRuntimeLogger(),
|
||||
});
|
||||
|
||||
await options.onReplyStart?.();
|
||||
result.replyOptions.onAssistantMessageStart?.();
|
||||
await options.deliver({ text: "tool summary" }, { kind: "tool" });
|
||||
await options.deliver({ text: "plain final answer" }, { kind: "final" });
|
||||
await options.onIdle?.();
|
||||
|
||||
expect(streamingInstances).toHaveLength(1);
|
||||
expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
|
||||
expectMockArgFields(sendMessageFeishuMock, "message send params", {
|
||||
text: "tool summary",
|
||||
});
|
||||
expect(streamingInstances[0].close).toHaveBeenCalledWith("plain final answer", {
|
||||
note: "Agent: agent",
|
||||
});
|
||||
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps auto mode plain text on the message path when streaming is disabled", async () => {
|
||||
const options = setupNonStreamingAutoDispatcher();
|
||||
await options.deliver({ text: "plain text" }, { kind: "final" });
|
||||
|
||||
expect(streamingInstances).toHaveLength(0);
|
||||
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not attach automatic mentions to non-streaming plain text replies", async () => {
|
||||
useNonStreamingAutoAccount();
|
||||
|
||||
it("does not attach automatic mentions to plain text replies", async () => {
|
||||
const { options } = createDispatcherHarness({
|
||||
replyToMessageId: "om_msg",
|
||||
});
|
||||
@@ -669,55 +611,6 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("waits for deliverable text before starting a card after assistant message start", async () => {
|
||||
const { result, options } = createDispatcherHarness({
|
||||
runtime: createRuntimeLogger(),
|
||||
});
|
||||
|
||||
await options.onReplyStart?.();
|
||||
result.replyOptions.onAssistantMessageStart?.();
|
||||
await options.deliver({ text: "plain final answer" }, { kind: "final" });
|
||||
await options.onIdle?.();
|
||||
|
||||
expect(streamingInstances).toHaveLength(1);
|
||||
expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
|
||||
expect(streamingInstances[0].close).toHaveBeenCalledWith("plain final answer", {
|
||||
note: "Agent: agent",
|
||||
});
|
||||
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not create an empty card when assistant message start has no deliverable final", async () => {
|
||||
const { result, options } = createDispatcherHarness({
|
||||
runtime: createRuntimeLogger(),
|
||||
});
|
||||
|
||||
await options.onReplyStart?.();
|
||||
result.replyOptions.onAssistantMessageStart?.();
|
||||
await options.onIdle?.();
|
||||
|
||||
expect(streamingInstances).toHaveLength(0);
|
||||
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
||||
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
||||
expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts a streaming card from partial snapshots in auto mode", async () => {
|
||||
const { result, options } = createDispatcherHarness({
|
||||
runtime: createRuntimeLogger(),
|
||||
});
|
||||
|
||||
result.replyOptions.onPartialReply?.({ text: "plain" });
|
||||
result.replyOptions.onPartialReply?.({ text: "plain streamed answer" });
|
||||
await options.onIdle?.();
|
||||
|
||||
expect(streamingInstances).toHaveLength(1);
|
||||
expect(streamingInstances[0].close).toHaveBeenCalledWith("plain streamed answer", {
|
||||
note: "Agent: agent",
|
||||
});
|
||||
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips distinct late final text after streaming card close", async () => {
|
||||
resolveFeishuAccountMock.mockReturnValue({
|
||||
accountId: "main",
|
||||
@@ -951,61 +844,6 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("discards partial streaming text when final replies send voice media", async () => {
|
||||
const { result, options } = createDispatcherHarness({
|
||||
runtime: createRuntimeLogger(),
|
||||
});
|
||||
|
||||
result.replyOptions.onPartialReply?.({ text: "spoken reply" });
|
||||
await options.deliver(
|
||||
{
|
||||
text: "spoken reply",
|
||||
mediaUrl: "https://example.com/reply.mp3",
|
||||
audioAsVoice: true,
|
||||
},
|
||||
{ kind: "final" },
|
||||
);
|
||||
await options.onIdle?.();
|
||||
|
||||
expect(streamingInstances).toHaveLength(1);
|
||||
expect(streamingInstances[0].discard).toHaveBeenCalledTimes(1);
|
||||
expect(streamingInstances[0].close).not.toHaveBeenCalled();
|
||||
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
||||
expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
|
||||
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
|
||||
expectMockArgFields(sendMediaFeishuMock, "media send params", {
|
||||
mediaUrl: "https://example.com/reply.mp3",
|
||||
audioAsVoice: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps partial streaming text when final replies send regular media only", async () => {
|
||||
const { result, options } = createDispatcherHarness({
|
||||
runtime: createRuntimeLogger(),
|
||||
});
|
||||
|
||||
result.replyOptions.onPartialReply?.({ text: "caption from stream" });
|
||||
await options.deliver(
|
||||
{
|
||||
mediaUrl: "https://example.com/image.png",
|
||||
},
|
||||
{ kind: "final" },
|
||||
);
|
||||
await options.onIdle?.();
|
||||
|
||||
expect(streamingInstances).toHaveLength(1);
|
||||
expect(streamingInstances[0].discard).not.toHaveBeenCalled();
|
||||
expect(streamingInstances[0].close).toHaveBeenCalledWith("caption from stream", {
|
||||
note: "Agent: agent",
|
||||
});
|
||||
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
||||
expect(sendStructuredCardFeishuMock).not.toHaveBeenCalled();
|
||||
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
|
||||
expectMockArgFields(sendMediaFeishuMock, "media send params", {
|
||||
mediaUrl: "https://example.com/image.png",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends skipped voice text when final voice media degrades to a file attachment", async () => {
|
||||
sendMediaFeishuMock.mockResolvedValueOnce({
|
||||
messageId: "file_msg",
|
||||
@@ -1051,7 +889,6 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
});
|
||||
|
||||
it("preserves captions for regular audio attachments", async () => {
|
||||
useNonStreamingAutoAccount();
|
||||
const { options } = createDispatcherHarness();
|
||||
await options.deliver(
|
||||
{
|
||||
@@ -1091,7 +928,6 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
});
|
||||
|
||||
it("falls back to legacy mediaUrl when mediaUrls is an empty array", async () => {
|
||||
useNonStreamingAutoAccount();
|
||||
const { options } = createDispatcherHarness();
|
||||
await options.deliver(
|
||||
{ text: "caption", mediaUrl: "https://example.com/a.png", mediaUrls: [] },
|
||||
@@ -1125,7 +961,6 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
});
|
||||
|
||||
it("passes replyInThread to sendMessageFeishu for plain text", async () => {
|
||||
useNonStreamingAutoAccount();
|
||||
const { options } = createDispatcherHarness({
|
||||
replyToMessageId: "om_msg",
|
||||
replyInThread: true,
|
||||
@@ -1139,7 +974,6 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
});
|
||||
|
||||
it("allows top-level fallback for normal group quoted replies", async () => {
|
||||
useNonStreamingAutoAccount();
|
||||
const { options } = createDispatcherHarness({
|
||||
replyToMessageId: "om_quote_reply",
|
||||
replyInThread: true,
|
||||
@@ -1156,7 +990,6 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
});
|
||||
|
||||
it("keeps native topic replies opted out of top-level fallback", async () => {
|
||||
useNonStreamingAutoAccount();
|
||||
const { options } = createDispatcherHarness({
|
||||
replyToMessageId: "om_topic_root",
|
||||
replyInThread: true,
|
||||
|
||||
@@ -375,18 +375,6 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
})();
|
||||
};
|
||||
|
||||
const resetStreamingState = () => {
|
||||
streaming = null;
|
||||
streamingStartPromise = null;
|
||||
partialUpdateQueue = Promise.resolve();
|
||||
streamText = "";
|
||||
lastPartial = "";
|
||||
reasoningText = "";
|
||||
statusLine = "";
|
||||
snapshotBaseText = "";
|
||||
lastSnapshotTextLength = 0;
|
||||
};
|
||||
|
||||
const closeStreaming = async (options?: { markClosedForReply?: boolean }) => {
|
||||
try {
|
||||
if (streamingStartPromise) {
|
||||
@@ -409,31 +397,21 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
resetStreamingState();
|
||||
streaming = null;
|
||||
streamingStartPromise = null;
|
||||
partialUpdateQueue = Promise.resolve();
|
||||
streamText = "";
|
||||
lastPartial = "";
|
||||
reasoningText = "";
|
||||
statusLine = "";
|
||||
snapshotBaseText = "";
|
||||
lastSnapshotTextLength = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const discardStreamingPreview = async () => {
|
||||
try {
|
||||
if (streamingStartPromise) {
|
||||
await streamingStartPromise;
|
||||
}
|
||||
await partialUpdateQueue;
|
||||
if (streaming?.isActive()) {
|
||||
await streaming.discard();
|
||||
}
|
||||
} finally {
|
||||
resetStreamingState();
|
||||
}
|
||||
};
|
||||
|
||||
const updateStreamingStatusLine = (
|
||||
nextStatusLine: string,
|
||||
options?: { startIfNeeded?: boolean },
|
||||
) => {
|
||||
const updateStreamingStatusLine = (nextStatusLine: string) => {
|
||||
statusLine = nextStatusLine;
|
||||
const hasStreamingSession = Boolean(streaming?.isActive() || streamingStartPromise);
|
||||
if (!hasStreamingSession && (options?.startIfNeeded === false || renderMode !== "card")) {
|
||||
if (!streaming?.isActive() && !streamingStartPromise && renderMode !== "card") {
|
||||
return;
|
||||
}
|
||||
startStreaming();
|
||||
@@ -558,11 +536,9 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
...(payload.audioAsVoice === true ? { audioAsVoice: true } : {}),
|
||||
}),
|
||||
);
|
||||
const streamingCardEnabledForReplyKind = streamingEnabled && info?.kind === "final";
|
||||
const useCard =
|
||||
hasText &&
|
||||
(streamingCardEnabledForReplyKind ||
|
||||
renderMode === "card" ||
|
||||
(renderMode === "card" ||
|
||||
(info?.kind === "block" && coreBlockStreamingEnabled && renderMode !== "raw") ||
|
||||
(renderMode === "auto" && shouldUseCard(text)));
|
||||
const skipTextForDuplicateFinal =
|
||||
@@ -579,19 +555,11 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
!hasVoiceMedia &&
|
||||
!skipTextForDuplicateFinal &&
|
||||
!skipTextForClosedStreamingFinal;
|
||||
const shouldDiscardStreamingPreview =
|
||||
info?.kind === "final" &&
|
||||
hasMedia &&
|
||||
((hasVoiceMedia && !shouldDeliverText) || skipTextForDuplicateFinal);
|
||||
|
||||
if (!shouldDeliverText && !hasMedia) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldDiscardStreamingPreview) {
|
||||
await discardStreamingPreview();
|
||||
}
|
||||
|
||||
if (shouldDeliverText) {
|
||||
if (info?.kind === "block") {
|
||||
// Drop internal block chunks unless we can safely consume them as
|
||||
@@ -612,8 +580,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
}
|
||||
}
|
||||
|
||||
const shouldStreamText = info?.kind === "block" || info?.kind === "final";
|
||||
if (streaming?.isActive() && shouldStreamText) {
|
||||
if (streaming?.isActive()) {
|
||||
if (info?.kind === "block") {
|
||||
// Some runtimes emit block payloads without onPartial/final callbacks.
|
||||
// Mirror block text into streamText so onIdle close still sends content.
|
||||
@@ -717,7 +684,6 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
if (!cleaned) {
|
||||
return;
|
||||
}
|
||||
startStreaming();
|
||||
queueStreamingUpdate(cleaned, {
|
||||
dedupeWithLastPartial: true,
|
||||
mode: "snapshot",
|
||||
@@ -763,7 +729,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
: undefined,
|
||||
onAssistantMessageStart: streamingEnabled
|
||||
? () => {
|
||||
updateStreamingStatusLine("", { startIfNeeded: false });
|
||||
updateStreamingStatusLine("");
|
||||
}
|
||||
: undefined,
|
||||
onCompactionStart: streamingEnabled
|
||||
|
||||
@@ -251,13 +251,6 @@ function applyNewAppSecurityPolicy(
|
||||
// Scan-to-create flow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let appRegistrationModulePromise: Promise<typeof import("./app-registration.js")> | null = null;
|
||||
|
||||
const loadAppRegistrationModule = async () => {
|
||||
appRegistrationModulePromise ??= import("./app-registration.js");
|
||||
return await appRegistrationModulePromise;
|
||||
};
|
||||
|
||||
async function promptFeishuDomain(params: {
|
||||
prompter: WizardPrompter;
|
||||
initialValue?: FeishuDomain;
|
||||
@@ -288,7 +281,7 @@ async function runScanToCreate(
|
||||
domain: FeishuDomain,
|
||||
): Promise<AppRegistrationResult | null> {
|
||||
const { beginAppRegistration, initAppRegistration, pollAppRegistration, printQrCode } =
|
||||
await loadAppRegistrationModule();
|
||||
await import("./app-registration.js");
|
||||
try {
|
||||
await initAppRegistration(domain);
|
||||
} catch {
|
||||
@@ -399,7 +392,7 @@ async function runNewAppFlow(params: {
|
||||
|
||||
// Fetch openId via API for manual flow.
|
||||
if (appId && appSecretProbeValue) {
|
||||
const { getAppOwnerOpenId } = await loadAppRegistrationModule();
|
||||
const { getAppOwnerOpenId } = await import("./app-registration.js");
|
||||
scanOpenId = await getAppOwnerOpenId({
|
||||
appId,
|
||||
appSecret: appSecretProbeValue,
|
||||
|
||||
@@ -50,7 +50,6 @@ describe("FeishuStreamingSession", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -112,45 +111,6 @@ describe("FeishuStreamingSession", () => {
|
||||
);
|
||||
}
|
||||
|
||||
function mockStreamingTokenStart(resolveAuthJson: (token: string) => Record<string, unknown>): {
|
||||
authTokens: string[];
|
||||
client: ConstructorParameters<typeof FeishuStreamingSession>[0];
|
||||
} {
|
||||
const release = vi.fn(async () => {});
|
||||
const authTokens: string[] = [];
|
||||
fetchWithSsrFGuardMock.mockImplementation(
|
||||
async ({ url }: { url: string; init?: { body?: string } }) => {
|
||||
if (url.includes("/auth/")) {
|
||||
const token = `token-${authTokens.length + 1}`;
|
||||
authTokens.push(token);
|
||||
return {
|
||||
response: { ok: true, json: async () => resolveAuthJson(token) },
|
||||
release,
|
||||
};
|
||||
}
|
||||
return {
|
||||
response: {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
code: 0,
|
||||
msg: "ok",
|
||||
data: { card_id: `card-${authTokens.length}` },
|
||||
}),
|
||||
},
|
||||
release,
|
||||
};
|
||||
},
|
||||
);
|
||||
const client = {
|
||||
im: {
|
||||
message: {
|
||||
create: vi.fn(async () => ({ code: 0, msg: "ok", data: { message_id: "om_1" } })),
|
||||
},
|
||||
},
|
||||
} as unknown as ConstructorParameters<typeof FeishuStreamingSession>[0];
|
||||
return { authTokens, client };
|
||||
}
|
||||
|
||||
it("flushes throttled pending text after the throttle window", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_000);
|
||||
@@ -386,12 +346,46 @@ describe("FeishuStreamingSession", () => {
|
||||
it("bounds streaming token cache lifetime when token expiry overflows", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-29T12:00:00.000Z"));
|
||||
const { authTokens, client } = mockStreamingTokenStart((token) => ({
|
||||
code: 0,
|
||||
msg: "ok",
|
||||
tenant_access_token: token,
|
||||
expire: Number.MAX_SAFE_INTEGER,
|
||||
}));
|
||||
const release = vi.fn(async () => {});
|
||||
const authTokens: string[] = [];
|
||||
fetchWithSsrFGuardMock.mockImplementation(
|
||||
async ({ url }: { url: string; init?: { body?: string } }) => {
|
||||
if (url.includes("/auth/")) {
|
||||
const token = `token-${authTokens.length + 1}`;
|
||||
authTokens.push(token);
|
||||
return {
|
||||
response: {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
code: 0,
|
||||
msg: "ok",
|
||||
tenant_access_token: token,
|
||||
expire: Number.MAX_SAFE_INTEGER,
|
||||
}),
|
||||
},
|
||||
release,
|
||||
};
|
||||
}
|
||||
return {
|
||||
response: {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
code: 0,
|
||||
msg: "ok",
|
||||
data: { card_id: `card-${authTokens.length}` },
|
||||
}),
|
||||
},
|
||||
release,
|
||||
};
|
||||
},
|
||||
);
|
||||
const client = {
|
||||
im: {
|
||||
message: {
|
||||
create: vi.fn(async () => ({ code: 0, msg: "ok", data: { message_id: "om_1" } })),
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
|
||||
await new FeishuStreamingSession(client, {
|
||||
appId: "app_unsafe_token_expiry",
|
||||
@@ -407,55 +401,6 @@ describe("FeishuStreamingSession", () => {
|
||||
|
||||
expect(authTokens).toEqual(["token-1", "token-2"]);
|
||||
});
|
||||
|
||||
it("bounds streaming token fallback lifetime when the process clock is invalid", async () => {
|
||||
const dateNow = vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
|
||||
const { authTokens, client } = mockStreamingTokenStart((token) => ({
|
||||
code: 0,
|
||||
msg: "ok",
|
||||
tenant_access_token: token,
|
||||
}));
|
||||
|
||||
await new FeishuStreamingSession(client, {
|
||||
appId: "app_invalid_clock_token_expiry",
|
||||
appSecret: "secret",
|
||||
}).start("chat_id", "open_id");
|
||||
expect(authTokens).toEqual(["token-1"]);
|
||||
|
||||
dateNow.mockReturnValue(7200 * 1000 - 60_000 + 1);
|
||||
await new FeishuStreamingSession(client, {
|
||||
appId: "app_invalid_clock_token_expiry",
|
||||
appSecret: "secret",
|
||||
}).start("chat_id", "open_id");
|
||||
|
||||
expect(authTokens).toEqual(["token-1", "token-2"]);
|
||||
dateNow.mockRestore();
|
||||
});
|
||||
|
||||
it("treats an invalid process clock as a streaming token cache miss", async () => {
|
||||
const dateNow = vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-05-29T12:00:00.000Z"));
|
||||
const { authTokens, client } = mockStreamingTokenStart((token) => ({
|
||||
code: 0,
|
||||
msg: "ok",
|
||||
tenant_access_token: token,
|
||||
expire: 7200,
|
||||
}));
|
||||
|
||||
await new FeishuStreamingSession(client, {
|
||||
appId: "app_invalid_clock_cache_miss",
|
||||
appSecret: "secret",
|
||||
}).start("chat_id", "open_id");
|
||||
expect(authTokens).toEqual(["token-1"]);
|
||||
|
||||
dateNow.mockReturnValue(8_640_000_000_000_001);
|
||||
await new FeishuStreamingSession(client, {
|
||||
appId: "app_invalid_clock_cache_miss",
|
||||
appSecret: "secret",
|
||||
}).start("chat_id", "open_id");
|
||||
|
||||
expect(authTokens).toEqual(["token-1", "token-2"]);
|
||||
dateNow.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeStreamingText", () => {
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { Client } from "@larksuiteoapi/node-sdk";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationSeconds,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { resolveExpiresAtMsFromDurationSeconds } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { getFeishuUserAgent } from "./client.js";
|
||||
import { resolveFeishuCardTemplate, type CardHeaderConfig } from "./send.js";
|
||||
@@ -52,17 +48,13 @@ const FEISHU_STREAMING_TOKEN_DEFAULT_LIFETIME_SECONDS = 7200;
|
||||
// Token cache (keyed by domain + appId)
|
||||
const tokenCache = new Map<string, { token: string; expiresAt: number }>();
|
||||
|
||||
function resolveStreamingTokenExpiresAt(value: unknown, nowMs = Date.now()): number {
|
||||
const now = resolveDateTimestampMs(nowMs);
|
||||
function resolveStreamingTokenExpiresAt(value: unknown): number {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value <= 0) {
|
||||
return now;
|
||||
return Date.now();
|
||||
}
|
||||
return (
|
||||
resolveExpiresAtMsFromDurationSeconds(value, { nowMs: now }) ??
|
||||
resolveExpiresAtMsFromDurationSeconds(FEISHU_STREAMING_TOKEN_DEFAULT_LIFETIME_SECONDS, {
|
||||
nowMs: now,
|
||||
}) ??
|
||||
now
|
||||
resolveExpiresAtMsFromDurationSeconds(value) ??
|
||||
Date.now() + FEISHU_STREAMING_TOKEN_DEFAULT_LIFETIME_SECONDS * 1000
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,11 +85,7 @@ function resolveAllowedHostnames(domain?: FeishuDomain): string[] {
|
||||
async function getToken(creds: Credentials): Promise<string> {
|
||||
const key = `${creds.domain ?? "feishu"}|${creds.appId}`;
|
||||
const cached = tokenCache.get(key);
|
||||
const rawNow = Date.now();
|
||||
const hasValidClock = asDateTimestampMs(rawNow) !== undefined;
|
||||
const now = resolveDateTimestampMs(rawNow);
|
||||
const minUsableExpiresAt = resolveExpiresAtMsFromDurationSeconds(60, { nowMs: now }) ?? now;
|
||||
if (cached && hasValidClock && cached.expiresAt > minUsableExpiresAt) {
|
||||
if (cached && cached.expiresAt > Date.now() + 60000) {
|
||||
return cached.token;
|
||||
}
|
||||
|
||||
@@ -127,7 +115,7 @@ async function getToken(creds: Credentials): Promise<string> {
|
||||
}
|
||||
tokenCache.set(key, {
|
||||
token: data.tenant_access_token,
|
||||
expiresAt: resolveStreamingTokenExpiresAt(data.expire, now),
|
||||
expiresAt: resolveStreamingTokenExpiresAt(data.expire),
|
||||
});
|
||||
return data.tenant_access_token;
|
||||
}
|
||||
@@ -535,9 +523,8 @@ export class FeishuStreamingSession {
|
||||
const text = finalText ?? pendingMerged;
|
||||
const apiBase = resolveApiBase(this.creds.domain);
|
||||
|
||||
// Only send final update if content differs from what's already displayed.
|
||||
// An explicit empty final text clears a transient preview before closeout.
|
||||
if ((text || finalText !== undefined) && text !== this.state.sentText) {
|
||||
// Only send final update if content differs from what's already displayed
|
||||
if (text && text !== this.state.sentText) {
|
||||
const sent = text.startsWith(this.state.sentText)
|
||||
? await this.updateCardContent(
|
||||
resolveStreamingCardAppendContent(this.state.sentText, text),
|
||||
@@ -590,32 +577,6 @@ export class FeishuStreamingSession {
|
||||
this.log?.(`Closed streaming: cardId=${finalState.cardId}`);
|
||||
}
|
||||
|
||||
async discard(): Promise<void> {
|
||||
if (!this.state || this.closed) {
|
||||
return;
|
||||
}
|
||||
this.closed = true;
|
||||
this.clearFlushTimer();
|
||||
await this.queue;
|
||||
|
||||
const currentState = this.state;
|
||||
try {
|
||||
const response = await this.client.im.message.delete({
|
||||
path: { message_id: currentState.messageId },
|
||||
});
|
||||
if (response.code !== undefined && response.code !== 0) {
|
||||
throw new Error(`Delete streaming card message failed: ${response.msg ?? response.code}`);
|
||||
}
|
||||
this.state = null;
|
||||
this.pendingText = null;
|
||||
this.log?.(`Discarded streaming card: cardId=${currentState.cardId}`);
|
||||
} catch (error) {
|
||||
this.log?.(`Discard failed: ${String(error)}`);
|
||||
this.closed = false;
|
||||
await this.close("");
|
||||
}
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
return this.state !== null && !this.closed;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user