mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-18 20:32:25 +08:00
Compare commits
237 Commits
fix/window
...
fix-plugin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d35b54fe7d | ||
|
|
2ccbc673df | ||
|
|
11b5728faa | ||
|
|
4decdf6245 | ||
|
|
ac0fb976c8 | ||
|
|
1de9f99ea8 | ||
|
|
60f8e18372 | ||
|
|
e52b4bce01 | ||
|
|
f93a558892 | ||
|
|
5ba3505fed | ||
|
|
18e7d28b21 | ||
|
|
02ca283716 | ||
|
|
4f0e3cb621 | ||
|
|
73a69d9e64 | ||
|
|
b1911a7cd3 | ||
|
|
450642a897 | ||
|
|
f7a1903bfc | ||
|
|
61cf22f147 | ||
|
|
55505776fb | ||
|
|
3c91928bae | ||
|
|
6ac7564918 | ||
|
|
23e1aac9b2 | ||
|
|
c65af78853 | ||
|
|
4155ac1c0d | ||
|
|
cfe5544b30 | ||
|
|
d7b901a1e7 | ||
|
|
5225a8c644 | ||
|
|
fc50f949d4 | ||
|
|
f6b40861f7 | ||
|
|
f491d420f7 | ||
|
|
ef0882e17e | ||
|
|
697bafa9c9 | ||
|
|
77761f4a3e | ||
|
|
0e2694ff47 | ||
|
|
5eb71927b7 | ||
|
|
cbd8049b9f | ||
|
|
19f22b5924 | ||
|
|
05634708e0 | ||
|
|
536c00991f | ||
|
|
c94c43d3bb | ||
|
|
8a99c0d17a | ||
|
|
30e1556cda | ||
|
|
ec15f90a55 | ||
|
|
3da34a4673 | ||
|
|
f91ddefbfb | ||
|
|
84385898ec | ||
|
|
6c7642b532 | ||
|
|
9988a37d37 | ||
|
|
37b33d11ce | ||
|
|
7086e34533 | ||
|
|
20fbb8bd14 | ||
|
|
8e90a1cad9 | ||
|
|
7e3ebb8e10 | ||
|
|
06b2bf1c0a | ||
|
|
d649548a7a | ||
|
|
5adc681238 | ||
|
|
53e8dc6a54 | ||
|
|
2d0a0c5e43 | ||
|
|
b668ffe7ca | ||
|
|
6736936cbc | ||
|
|
8539e0283a | ||
|
|
ef88f0f949 | ||
|
|
816c692035 | ||
|
|
c635e560d0 | ||
|
|
ccb59d989b | ||
|
|
642f85dc5b | ||
|
|
53300a5c1a | ||
|
|
b51610a1c3 | ||
|
|
5269924ff8 | ||
|
|
62fa5692cb | ||
|
|
2d4369d176 | ||
|
|
99e8cf22a8 | ||
|
|
e780a6b7ba | ||
|
|
313554059c | ||
|
|
77b334a984 | ||
|
|
ab67a198c1 | ||
|
|
9ef5a9afdc | ||
|
|
c39fbdb698 | ||
|
|
d33d6bfafa | ||
|
|
2209f71a78 | ||
|
|
f13a615036 | ||
|
|
5660b67062 | ||
|
|
1d21646e96 | ||
|
|
55d4456751 | ||
|
|
a80d9f00f1 | ||
|
|
22d635080d | ||
|
|
d5be702f86 | ||
|
|
3d66d203d0 | ||
|
|
a918e93421 | ||
|
|
56eadf36d0 | ||
|
|
912f663173 | ||
|
|
f44af7eebf | ||
|
|
65fe2b7e91 | ||
|
|
941e04e9f3 | ||
|
|
f327073fb3 | ||
|
|
41e5acbb6c | ||
|
|
2333d47a1e | ||
|
|
c9e481ac48 | ||
|
|
462e315953 | ||
|
|
6b14df7792 | ||
|
|
e449392c4f | ||
|
|
326db58229 | ||
|
|
3caf4facec | ||
|
|
c9a97f54e0 | ||
|
|
85506c36a0 | ||
|
|
a176b8ec2f | ||
|
|
2b726457d8 | ||
|
|
6464f8d1d9 | ||
|
|
a17c7a56da | ||
|
|
98a1aa491f | ||
|
|
25b87b111d | ||
|
|
f823123aa5 | ||
|
|
d717ff71bf | ||
|
|
840192caa9 | ||
|
|
61ef6b12dd | ||
|
|
660a6dec7f | ||
|
|
e49ef86945 | ||
|
|
d2f69ecc3b | ||
|
|
a89abcb1e9 | ||
|
|
8bf7bc5b5c | ||
|
|
4e2ef87c31 | ||
|
|
ec58491f75 | ||
|
|
0840fea50d | ||
|
|
cf60e83118 | ||
|
|
7ad2ebb515 | ||
|
|
3c41e1722f | ||
|
|
dd5b70bcc4 | ||
|
|
30c0422a8e | ||
|
|
6d43200248 | ||
|
|
be3153cabb | ||
|
|
56995069f1 | ||
|
|
2238e0ce76 | ||
|
|
38a463fe93 | ||
|
|
e1f462b352 | ||
|
|
ccd635fdb9 | ||
|
|
27dce6c6bb | ||
|
|
9c08d8cd35 | ||
|
|
dc5b3ecc4c | ||
|
|
95f66a34e7 | ||
|
|
1695ee2f43 | ||
|
|
801520b0f0 | ||
|
|
8ba79d72b4 | ||
|
|
5876ba6152 | ||
|
|
5b895f2592 | ||
|
|
fb61363763 | ||
|
|
07e0af44b3 | ||
|
|
059d5405fe | ||
|
|
cd37dbd4e5 | ||
|
|
3e8d06a6be | ||
|
|
2f07e4e6c0 | ||
|
|
15fb3314de | ||
|
|
5a019e7725 | ||
|
|
aea31934d4 | ||
|
|
8ec7e80cb2 | ||
|
|
6c3533d8c4 | ||
|
|
9c313a7826 | ||
|
|
368a719879 | ||
|
|
ec7e3eaf64 | ||
|
|
8bcdab8933 | ||
|
|
c2f0d811e7 | ||
|
|
8f3d3a549d | ||
|
|
d389a52494 | ||
|
|
346b14a51a | ||
|
|
ffa2da8478 | ||
|
|
61a768be75 | ||
|
|
3d8a77a113 | ||
|
|
a6a358f1a6 | ||
|
|
131dc4eaeb | ||
|
|
022fd55bad | ||
|
|
d9820e4098 | ||
|
|
a4ebdc9aa1 | ||
|
|
cf2461f7f6 | ||
|
|
f5f829db79 | ||
|
|
a06daab97e | ||
|
|
09f094057a | ||
|
|
9def042fab | ||
|
|
f6adea5757 | ||
|
|
78f4a5c05f | ||
|
|
731a7af9c5 | ||
|
|
ffa4342a6a | ||
|
|
550a134cf9 | ||
|
|
1b43e84d0d | ||
|
|
31f0635f4f | ||
|
|
1c65e2e7c1 | ||
|
|
b6f3fe7938 | ||
|
|
d65b3a68aa | ||
|
|
e2b54fecd8 | ||
|
|
b8067d073a | ||
|
|
e420c001d0 | ||
|
|
44b6b79a66 | ||
|
|
3ef2935ac9 | ||
|
|
fced29de17 | ||
|
|
4f074c3235 | ||
|
|
5df00520cb | ||
|
|
b2c85bc0a2 | ||
|
|
5e2e78a75a | ||
|
|
2196f107da | ||
|
|
ff56a2d7b3 | ||
|
|
24cff8a3bc | ||
|
|
b495ac2abb | ||
|
|
3f2585424d | ||
|
|
9d1a3007d9 | ||
|
|
b5c163dffa | ||
|
|
ee0cf9e5bb | ||
|
|
37fdfa0e0b | ||
|
|
d550b804b8 | ||
|
|
05988500bc | ||
|
|
b01290cf64 | ||
|
|
117f6fb254 | ||
|
|
c363816fea | ||
|
|
aeed31cdb1 | ||
|
|
58c8c022c5 | ||
|
|
2cfae61743 | ||
|
|
c6b4daf426 | ||
|
|
348fabe04d | ||
|
|
6c83e8e7e4 | ||
|
|
817b6259c4 | ||
|
|
959af0fa5b | ||
|
|
669b26a3dc | ||
|
|
67c139fc36 | ||
|
|
8b6829e1bc | ||
|
|
86e6fbcf52 | ||
|
|
9b4b3aa348 | ||
|
|
51ab2c0d79 | ||
|
|
bdd9c70787 | ||
|
|
1ff95ff3e6 | ||
|
|
7c5b55c5ff | ||
|
|
b0d6076208 | ||
|
|
4385e57dce | ||
|
|
eb45c1c623 | ||
|
|
adf981de89 | ||
|
|
023a101b91 | ||
|
|
8b92aca27f | ||
|
|
b13fb788b5 | ||
|
|
87c0ee7685 | ||
|
|
eef32e94c7 | ||
|
|
1350efcfd8 |
@@ -6,18 +6,10 @@ class: standard
|
||||
capacity:
|
||||
market: spot
|
||||
strategy: most-available
|
||||
fallback: on-demand-after-120s
|
||||
# Fail closed instead of silently falling back to on-demand while the
|
||||
# Azure-backed billing account is the default runner path.
|
||||
fallback: spot-only
|
||||
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
|
||||
@@ -37,6 +29,8 @@ 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/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/**') }}
|
||||
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/**') }}
|
||||
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', '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') }}
|
||||
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') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-extension-package-boundary-v1-
|
||||
|
||||
@@ -1420,14 +1420,22 @@ 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 {} +
|
||||
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 \
|
||||
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=(
|
||||
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 \
|
||||
@@ -1435,6 +1443,12 @@ 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:
|
||||
|
||||
15
CHANGELOG.md
15
CHANGELOG.md
@@ -24,6 +24,7 @@ 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.
|
||||
@@ -33,6 +34,20 @@ 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.
|
||||
- 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,6 +5,8 @@
|
||||
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,6 +29,14 @@ 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)
|
||||
|
||||
@@ -314,6 +322,7 @@ 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"]
|
||||
@@ -321,11 +330,21 @@ 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,
|
||||
skip_screenshots: ENV["DELIVER_SCREENSHOTS"] != "1",
|
||||
app_version: version_metadata[:short_version],
|
||||
copyright: "2026 OpenClaw",
|
||||
primary_category: "PRODUCTIVITY",
|
||||
secondary_category: "UTILITIES",
|
||||
skip_screenshots: !screenshot_upload_requested?,
|
||||
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,18 +1,19 @@
|
||||
OpenClaw is a personal AI assistant you run on your own devices.
|
||||
|
||||
Pair this iPhone app with your OpenClaw Gateway to connect your phone as a secure node for voice, camera, and device automation.
|
||||
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.
|
||||
|
||||
What you can do:
|
||||
- Pair with your private OpenClaw Gateway by QR code or setup code
|
||||
- Chat with your assistant from iPhone
|
||||
- Use voice wake and push-to-talk
|
||||
- Capture photos and short clips on request
|
||||
- Record screen snippets for troubleshooting and workflows
|
||||
- Use realtime Talk mode and push-to-talk
|
||||
- Review Gateway action approvals from your phone
|
||||
- Share text, links, and media directly from iOS into OpenClaw
|
||||
- Run location-aware and device-aware automations
|
||||
- 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
|
||||
|
||||
OpenClaw is local-first: you control your gateway, keys, and configuration.
|
||||
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.
|
||||
|
||||
Getting started:
|
||||
1) Set up your OpenClaw Gateway
|
||||
2) Open the iOS app and pair with your gateway
|
||||
3) Start using commands and automations from your phone
|
||||
3) Start using chat, Talk mode, approvals, and automations from your phone
|
||||
|
||||
@@ -1 +1 @@
|
||||
openclaw,ai assistant,local ai,voice assistant,automation,gateway,chat,agent,node
|
||||
openclaw,ai assistant,local ai,iphone ai,voice assistant,automation,gateway,chat,agent
|
||||
|
||||
@@ -1 +1 @@
|
||||
Run OpenClaw from your iPhone: pair with your own gateway, trigger automations, and use voice, camera, and share actions.
|
||||
Pair your iPhone with your OpenClaw Gateway for chat, realtime voice, approvals, device capabilities, and private automation.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
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,6 +326,8 @@ 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.
|
||||
|
||||
|
||||
@@ -214,7 +214,8 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and
|
||||
}
|
||||
```
|
||||
|
||||
- Loaded from `~/.openclaw/extensions`, `<workspace>/.openclaw/extensions`, plus `plugins.load.paths`.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -7,7 +7,7 @@ read_when:
|
||||
title: "iOS app"
|
||||
---
|
||||
|
||||
Availability: internal preview. The iOS app is not publicly distributed yet.
|
||||
Availability: iPhone app builds are distributed through Apple channels when enabled for a release. Local development builds can also run from source.
|
||||
|
||||
## What it does
|
||||
|
||||
|
||||
@@ -108,6 +108,18 @@ 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
|
||||
@@ -119,6 +131,12 @@ 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,
|
||||
@@ -126,9 +144,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, and
|
||||
blocks expired claims or timed-out runs so operators can recover them from the
|
||||
board.
|
||||
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.
|
||||
|
||||
## Session lifecycle sync
|
||||
|
||||
|
||||
@@ -4130,6 +4130,50 @@ 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,7 +13,11 @@ 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 { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
parseStrictPositiveInteger,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
resolveLivePluginConfigObject,
|
||||
resolvePluginConfigObject,
|
||||
@@ -1360,7 +1364,12 @@ function getCachedResult(cacheKey: string): ActiveRecallResult | undefined {
|
||||
if (!cached) {
|
||||
return undefined;
|
||||
}
|
||||
if (cached.expiresAt <= Date.now()) {
|
||||
const now = asDateTimestampMs(Date.now());
|
||||
if (
|
||||
now === undefined ||
|
||||
asDateTimestampMs(cached.expiresAt) === undefined ||
|
||||
cached.expiresAt <= now
|
||||
) {
|
||||
activeRecallCache.delete(cacheKey);
|
||||
return undefined;
|
||||
}
|
||||
@@ -1368,19 +1377,27 @@ function getCachedResult(cacheKey: string): ActiveRecallResult | undefined {
|
||||
}
|
||||
|
||||
function setCachedResult(cacheKey: string, result: ActiveRecallResult, ttlMs: number): void {
|
||||
const now = Date.now();
|
||||
const rawNow = Date.now();
|
||||
const now = asDateTimestampMs(rawNow);
|
||||
if (
|
||||
activeRecallCache.size >= DEFAULT_MAX_CACHE_ENTRIES ||
|
||||
now - lastActiveRecallCacheSweepAt >= CACHE_SWEEP_INTERVAL_MS
|
||||
(now !== undefined && now - lastActiveRecallCacheSweepAt >= CACHE_SWEEP_INTERVAL_MS)
|
||||
) {
|
||||
sweepExpiredCacheEntries(now);
|
||||
lastActiveRecallCacheSweepAt = now;
|
||||
if (now !== undefined) {
|
||||
lastActiveRecallCacheSweepAt = now;
|
||||
}
|
||||
}
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(ttlMs, { nowMs: rawNow });
|
||||
if (expiresAt === undefined) {
|
||||
activeRecallCache.delete(cacheKey);
|
||||
return;
|
||||
}
|
||||
if (activeRecallCache.has(cacheKey)) {
|
||||
activeRecallCache.delete(cacheKey);
|
||||
}
|
||||
activeRecallCache.set(cacheKey, {
|
||||
expiresAt: now + ttlMs,
|
||||
expiresAt,
|
||||
result,
|
||||
});
|
||||
while (activeRecallCache.size > DEFAULT_MAX_CACHE_ENTRIES) {
|
||||
@@ -1392,9 +1409,13 @@ function setCachedResult(cacheKey: string, result: ActiveRecallResult, ttlMs: nu
|
||||
}
|
||||
}
|
||||
|
||||
function sweepExpiredCacheEntries(now = Date.now()): void {
|
||||
function sweepExpiredCacheEntries(now = asDateTimestampMs(Date.now())): void {
|
||||
if (now === undefined) {
|
||||
activeRecallCache.clear();
|
||||
return;
|
||||
}
|
||||
for (const [cacheKey, cached] of activeRecallCache.entries()) {
|
||||
if (cached.expiresAt <= now) {
|
||||
if (asDateTimestampMs(cached.expiresAt) === undefined || cached.expiresAt <= now) {
|
||||
activeRecallCache.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +230,32 @@ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
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,
|
||||
@@ -92,9 +96,10 @@ function getCachedIamTokenEntry(
|
||||
now: number = Date.now(),
|
||||
): { token: string; expiresAt: number } | undefined {
|
||||
const cached = iamTokenCache.get(region);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
if (cached && isFutureDateTimestampMs(cached.expiresAt, { nowMs: now })) {
|
||||
return cached;
|
||||
}
|
||||
iamTokenCache.delete(region);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -123,7 +128,10 @@ export async function generateBearerTokenFromIam(params: {
|
||||
region: params.region,
|
||||
expiresInSeconds: 7200, // 2 hours
|
||||
})();
|
||||
iamTokenCache.set(params.region, { token, expiresAt: now + IAM_TOKEN_TTL_MS });
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(IAM_TOKEN_TTL_MS, { nowMs: now });
|
||||
if (expiresAt !== undefined) {
|
||||
iamTokenCache.set(params.region, { token, expiresAt });
|
||||
}
|
||||
return token;
|
||||
} catch (error) {
|
||||
log.debug?.("Mantle IAM token generation unavailable", {
|
||||
|
||||
@@ -256,6 +256,28 @@ 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,6 +5,10 @@ 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,
|
||||
@@ -503,11 +507,16 @@ export async function discoverBedrockModels(params: {
|
||||
|
||||
if (refreshIntervalSeconds > 0) {
|
||||
const cached = discoveryCache.get(cacheKey);
|
||||
if (cached?.value && cached.expiresAt > now) {
|
||||
return cached.value;
|
||||
if (cached && isFutureDateTimestampMs(cached.expiresAt, { nowMs: now })) {
|
||||
if (cached.value) {
|
||||
return cached.value;
|
||||
}
|
||||
if (cached.inFlight) {
|
||||
return cached.inFlight;
|
||||
}
|
||||
}
|
||||
if (cached?.inFlight) {
|
||||
return cached.inFlight;
|
||||
if (cached) {
|
||||
discoveryCache.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,19 +590,27 @@ export async function discoverBedrockModels(params: {
|
||||
})();
|
||||
|
||||
if (refreshIntervalSeconds > 0) {
|
||||
discoveryCache.set(cacheKey, {
|
||||
expiresAt: now + refreshIntervalSeconds * 1000,
|
||||
inFlight: discoveryPromise,
|
||||
});
|
||||
const expiresAt = resolveExpiresAtMsFromDurationSeconds(refreshIntervalSeconds, { nowMs: now });
|
||||
if (expiresAt !== undefined) {
|
||||
discoveryCache.set(cacheKey, {
|
||||
expiresAt,
|
||||
inFlight: discoveryPromise,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const value = await discoveryPromise;
|
||||
if (refreshIntervalSeconds > 0) {
|
||||
discoveryCache.set(cacheKey, {
|
||||
expiresAt: now + refreshIntervalSeconds * 1000,
|
||||
value,
|
||||
const expiresAt = resolveExpiresAtMsFromDurationSeconds(refreshIntervalSeconds, {
|
||||
nowMs: now,
|
||||
});
|
||||
if (expiresAt !== undefined) {
|
||||
discoveryCache.set(cacheKey, {
|
||||
expiresAt,
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
return value;
|
||||
} catch (error) {
|
||||
|
||||
@@ -17,6 +17,13 @@ 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>;
|
||||
@@ -50,7 +57,7 @@ export function createAnthropicVertexStreamFn(
|
||||
baseURL?: string,
|
||||
deps?: AnthropicVertexStreamDeps,
|
||||
): StreamFn {
|
||||
const streamFnPromise = import("./stream-runtime.js").then((runtime) =>
|
||||
const streamFnPromise = loadStreamRuntimeModule().then((runtime) =>
|
||||
runtime.createAnthropicVertexStreamFn(projectId, region, baseURL, deps),
|
||||
);
|
||||
return async (model, context, options) => {
|
||||
@@ -64,7 +71,7 @@ export function createAnthropicVertexStreamFnForModel(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
deps?: AnthropicVertexStreamDeps,
|
||||
): StreamFn {
|
||||
const streamFnPromise = import("./stream-runtime.js").then((runtime) =>
|
||||
const streamFnPromise = loadStreamRuntimeModule().then((runtime) =>
|
||||
runtime.createAnthropicVertexStreamFnForModel(model, env, deps),
|
||||
);
|
||||
return async (...args) => {
|
||||
|
||||
@@ -15,6 +15,15 @@ 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() ?? "");
|
||||
}
|
||||
@@ -51,7 +60,7 @@ function createLazyBrowserTool(opts?: {
|
||||
].join(" "),
|
||||
parameters: BrowserToolSchema,
|
||||
execute: async (toolCallId, args, signal, onUpdate) => {
|
||||
const { createBrowserTool } = await import("./register.runtime.js");
|
||||
const { createBrowserTool } = await loadBrowserRegistrationRuntimeModule();
|
||||
const tool = createBrowserTool(opts);
|
||||
return await tool.execute(toolCallId, args, signal, onUpdate);
|
||||
},
|
||||
@@ -65,7 +74,7 @@ export const browserPluginNodeHostCommands: OpenClawPluginNodeHostCommand[] = [
|
||||
command: "browser.proxy",
|
||||
cap: "browser",
|
||||
handle: async (paramsJSON) => {
|
||||
const { runBrowserProxyCommand } = await import("./register.runtime.js");
|
||||
const { runBrowserProxyCommand } = await loadBrowserRegistrationRuntimeModule();
|
||||
return await runBrowserProxyCommand(paramsJSON);
|
||||
},
|
||||
},
|
||||
@@ -73,7 +82,7 @@ export const browserPluginNodeHostCommands: OpenClawPluginNodeHostCommand[] = [
|
||||
|
||||
export const browserSecurityAuditCollectors: OpenClawPluginSecurityAuditCollector[] = [
|
||||
async (ctx) => {
|
||||
const { collectBrowserSecurityAuditFindings } = await import("./register.runtime.js");
|
||||
const { collectBrowserSecurityAuditFindings } = await loadBrowserRegistrationRuntimeModule();
|
||||
return collectBrowserSecurityAuditFindings(ctx);
|
||||
},
|
||||
];
|
||||
@@ -82,7 +91,7 @@ function createLazyBrowserPluginService(): OpenClawPluginService {
|
||||
let service: OpenClawPluginService | null = null;
|
||||
const loadService = async () => {
|
||||
if (!service) {
|
||||
const { createBrowserPluginService } = await import("./register.runtime.js");
|
||||
const { createBrowserPluginService } = await loadBrowserRegistrationRuntimeModule();
|
||||
service = createBrowserPluginService();
|
||||
}
|
||||
return service;
|
||||
@@ -124,7 +133,7 @@ export function registerBrowserPlugin(api: OpenClawPluginApi) {
|
||||
api.registerGatewayMethod(
|
||||
BROWSER_REQUEST_GATEWAY_METHOD,
|
||||
async (opts) => {
|
||||
const { handleBrowserGatewayRequest } = await import("./register.runtime.js");
|
||||
const { handleBrowserGatewayRequest } = await loadBrowserRegistrationRuntimeModule();
|
||||
return await handleBrowserGatewayRequest(opts);
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { redactCdpUrl } from "../cdp.helpers.js";
|
||||
import { snapshotAria } from "../cdp.js";
|
||||
import { getChromeMcpPid } from "../chrome-mcp.js";
|
||||
import { getChromeMcpPid, takeChromeMcpSnapshot } from "../chrome-mcp.js";
|
||||
import { resolveBrowserExecutableForPlatform } from "../chrome.executables.js";
|
||||
import { resolveManagedBrowserHeadlessMode } from "../config.js";
|
||||
import { buildBrowserDoctorReport } from "../doctor.js";
|
||||
@@ -227,7 +227,6 @@ 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,
|
||||
|
||||
@@ -13,13 +13,20 @@ 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,
|
||||
) =>
|
||||
import("./shared-client.js").then(({ getSharedCodexAppServerClient }) =>
|
||||
loadSharedClientModule().then(({ getSharedCodexAppServerClient }) =>
|
||||
getSharedCodexAppServerClient({ startOptions, authProfileId, agentDir, config }),
|
||||
);
|
||||
|
||||
@@ -29,6 +36,6 @@ export const defaultLeasedCodexAppServerClientFactory: CodexAppServerClientFacto
|
||||
agentDir,
|
||||
config,
|
||||
) =>
|
||||
import("./shared-client.js").then(({ getLeasedSharedCodexAppServerClient }) =>
|
||||
loadSharedClientModule().then(({ getLeasedSharedCodexAppServerClient }) =>
|
||||
getLeasedSharedCodexAppServerClient({ startOptions, authProfileId, agentDir, config }),
|
||||
);
|
||||
|
||||
@@ -189,7 +189,7 @@ function resolveEffectiveExecHost(params: {
|
||||
|
||||
function readRuntimeSessionEntryBestEffort(sessionKey: string): SessionEntry | undefined {
|
||||
try {
|
||||
return getSessionEntry({ sessionKey });
|
||||
return getSessionEntry({ sessionKey, hydrateSkillPromptRefs: false });
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -77,6 +77,31 @@ 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,6 +30,14 @@ 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) {
|
||||
@@ -112,16 +120,34 @@ 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;
|
||||
}
|
||||
const resolvedSessionFile = path.resolve(sessionFile);
|
||||
let found: (Record<string, unknown> & { sessionKey: string }) | undefined;
|
||||
for (const [sessionKey, record] of Object.entries(store)) {
|
||||
if (!isJsonObject(record) || typeof record.sessionFile !== "string") {
|
||||
continue;
|
||||
@@ -129,9 +155,16 @@ async function readCodexSessionRecordForSessionFile(
|
||||
if (path.resolve(record.sessionFile) !== resolvedSessionFile) {
|
||||
continue;
|
||||
}
|
||||
return { sessionKey, ...record };
|
||||
found = { sessionKey, ...record };
|
||||
break;
|
||||
}
|
||||
return undefined;
|
||||
codexSessionRecordCache.set(resolvedSessionFile, {
|
||||
sessionsFile,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
size: stat.size,
|
||||
record: found,
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
type CodexAppServerRolloutTokenSnapshot = {
|
||||
|
||||
@@ -54,6 +54,21 @@ 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");
|
||||
@@ -131,6 +146,15 @@ 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,6 +1,7 @@
|
||||
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";
|
||||
@@ -64,15 +65,16 @@ export class DiffArtifactStore {
|
||||
const htmlPath = path.join(artifactDir, "viewer.html");
|
||||
const ttlMs = normalizeTtlMs(params.ttlMs);
|
||||
const createdAt = new Date();
|
||||
const expiresAt = new Date(createdAt.getTime() + ttlMs);
|
||||
const createdAtIso = createdAt.toISOString();
|
||||
const expiresAt = resolveExpiresAtIso(createdAt.getTime(), ttlMs);
|
||||
const meta: DiffArtifactMeta = {
|
||||
id,
|
||||
token,
|
||||
title: params.title,
|
||||
inputKind: params.inputKind,
|
||||
fileCount: params.fileCount,
|
||||
createdAt: createdAt.toISOString(),
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
createdAt: createdAtIso,
|
||||
expiresAt,
|
||||
viewerPath: `${VIEWER_PREFIX}/${id}/${token}`,
|
||||
htmlPath,
|
||||
...(params.context ? { context: params.context } : {}),
|
||||
@@ -144,11 +146,12 @@ export class DiffArtifactStore {
|
||||
const filePath = path.join(artifactDir, `preview.${format}`);
|
||||
const ttlMs = normalizeTtlMs(params.ttlMs);
|
||||
const createdAt = new Date();
|
||||
const expiresAt = new Date(createdAt.getTime() + ttlMs).toISOString();
|
||||
const createdAtIso = createdAt.toISOString();
|
||||
const expiresAt = resolveExpiresAtIso(createdAt.getTime(), ttlMs);
|
||||
const meta: StandaloneFileMeta = {
|
||||
kind: "standalone_file",
|
||||
id,
|
||||
createdAt: createdAt.toISOString(),
|
||||
createdAt: createdAtIso,
|
||||
expiresAt,
|
||||
filePath: this.normalizeStoredPath(filePath, "filePath"),
|
||||
...(params.context ? { context: params.context } : {}),
|
||||
@@ -357,6 +360,14 @@ 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)) {
|
||||
|
||||
@@ -342,6 +342,47 @@ 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,4 +1,8 @@
|
||||
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";
|
||||
@@ -79,15 +83,23 @@ 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 && cached.expiresAt > Date.now()) {
|
||||
if (cached && now !== undefined && cached.expiresAt > now) {
|
||||
return cached.value;
|
||||
}
|
||||
if (cached) {
|
||||
this.entries.delete(key);
|
||||
}
|
||||
}
|
||||
const value = await fetcher();
|
||||
if (ttl > 0) {
|
||||
this.entries.set(key, { expiresAt: Date.now() + ttl, value });
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(ttl, { nowMs: rawNow });
|
||||
if (expiresAt !== undefined) {
|
||||
this.entries.set(key, { expiresAt, value });
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
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";
|
||||
@@ -30,6 +34,22 @@ 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) ?? "";
|
||||
}
|
||||
@@ -51,9 +71,11 @@ 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 (cached.expiresAt > Date.now()) {
|
||||
if (now !== undefined && cached.expiresAt > now) {
|
||||
return cached.value;
|
||||
}
|
||||
DISCORD_CHANNEL_INFO_CACHE.delete(channelId);
|
||||
@@ -61,10 +83,7 @@ export async function resolveDiscordChannelInfo(
|
||||
try {
|
||||
const channel = await client.fetchChannel(channelId);
|
||||
if (!channel) {
|
||||
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
|
||||
value: null,
|
||||
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS,
|
||||
});
|
||||
cacheDiscordChannelInfo(channelId, null, DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS, rawNow);
|
||||
return null;
|
||||
}
|
||||
const channelInfo = resolveDiscordChannelInfoSafe(channel);
|
||||
@@ -80,17 +99,11 @@ export async function resolveDiscordChannelInfo(
|
||||
parentId: channelInfo.parentId,
|
||||
ownerId: channelInfo.ownerId,
|
||||
};
|
||||
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
|
||||
value: payload,
|
||||
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_CACHE_TTL_MS,
|
||||
});
|
||||
cacheDiscordChannelInfo(channelId, payload, DISCORD_CHANNEL_INFO_CACHE_TTL_MS, rawNow);
|
||||
return payload;
|
||||
} catch (err) {
|
||||
logVerbose(`discord: failed to fetch channel ${channelId}: ${String(err)}`);
|
||||
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
|
||||
value: null,
|
||||
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS,
|
||||
});
|
||||
cacheDiscordChannelInfo(channelId, null, DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS, rawNow);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
MessageReferenceType,
|
||||
StickerFormatType,
|
||||
} from "discord-api-types/v10";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ChannelType, type Client, type Message } from "../internal/discord.js";
|
||||
|
||||
const readRemoteMediaBuffer = vi.fn();
|
||||
@@ -65,6 +65,10 @@ beforeAll(async () => {
|
||||
} = await import("./message-utils.js"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function asMessage(payload: Record<string, unknown>): Message {
|
||||
return payload as unknown as Message;
|
||||
}
|
||||
@@ -1231,4 +1235,37 @@ 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,4 +63,88 @@ 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,6 +2,7 @@ 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;
|
||||
@@ -92,7 +93,15 @@ function timestampMs(value: unknown): number {
|
||||
}
|
||||
|
||||
function legacyUpdatedAtForIndex(updatedAt: unknown, index: number, total: number): string {
|
||||
return new Date(timestampMs(updatedAt) + Math.max(0, total - index)).toISOString();
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
export const detectDiscordLegacyStateMigrations: BundledChannelLegacyStateMigrationDetector = ({
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
createPluginStateKeyedStoreForTests,
|
||||
resetPluginStateStoreForTests,
|
||||
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { setDiscordRuntime, type DiscordRuntime } from "../runtime.js";
|
||||
import {
|
||||
buildDiscordModelPickerPreferenceKey,
|
||||
@@ -163,6 +163,68 @@ 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" };
|
||||
@@ -207,4 +269,34 @@ 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,6 +3,12 @@ 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";
|
||||
@@ -13,11 +19,13 @@ 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 = {
|
||||
@@ -124,6 +132,7 @@ function sanitizeStoredPreferenceEntry(value: unknown): ModelPickerPreferencesEn
|
||||
scopeKey?: unknown;
|
||||
modelRef?: unknown;
|
||||
updatedAt?: unknown;
|
||||
updatedOrder?: unknown;
|
||||
};
|
||||
if (typeof typedValue.scopeKey !== "string" || typeof typedValue.modelRef !== "string") {
|
||||
return undefined;
|
||||
@@ -136,6 +145,10 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,13 +165,58 @@ function timestampMs(value: string): number {
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function legacyUpdatedAtForIndex(updatedAt: string, index: number, total: number): string {
|
||||
return new Date(timestampMs(updatedAt) + Math.max(0, total - index)).toISOString();
|
||||
function timestampOrder(value?: number): number {
|
||||
return value !== undefined && value >= 0 ? value : 0;
|
||||
}
|
||||
|
||||
function nextPreferenceTimestampIso(): string {
|
||||
lastPreferenceTimestampMs = Math.max(Date.now(), lastPreferenceTimestampMs + 1);
|
||||
return new Date(lastPreferenceTimestampMs).toISOString();
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
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 normalizeLegacyPreferenceKey(key: string): string | undefined {
|
||||
@@ -237,10 +295,13 @@ export async function readDiscordModelPickerRecentModels(params: {
|
||||
await importLegacyPreferences(params.env);
|
||||
const store = openPreferenceStore(params.env);
|
||||
const recent = (await store.entries())
|
||||
.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);
|
||||
.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);
|
||||
if (!params.allowedModelRefs || params.allowedModelRefs.size === 0) {
|
||||
return sanitizeRecentModels(recent, limit);
|
||||
}
|
||||
@@ -268,10 +329,14 @@ 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,
|
||||
updatedAt: nextPreferenceTimestampIso(),
|
||||
...timestamp,
|
||||
});
|
||||
const limit = Math.max(1, Math.min(params.limit ?? DEFAULT_RECENT_LIMIT, 10));
|
||||
const scopedEntries = (await store.entries())
|
||||
@@ -280,11 +345,7 @@ export async function recordDiscordModelPickerRecentModel(params: {
|
||||
(entry): entry is { key: string; value: ModelPickerPreferencesEntry } =>
|
||||
entry.value?.scopeKey === key,
|
||||
)
|
||||
.toSorted(
|
||||
(left, right) =>
|
||||
timestampMs(right.value.updatedAt) - timestampMs(left.value.updatedAt) ||
|
||||
left.key.localeCompare(right.key),
|
||||
);
|
||||
.toSorted(comparePreferenceEntries);
|
||||
await Promise.all(scopedEntries.slice(limit).map((entry) => store.delete(entry.key)));
|
||||
} catch {
|
||||
return;
|
||||
|
||||
@@ -121,6 +121,7 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -322,6 +323,18 @@ 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,6 +6,7 @@ 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 {
|
||||
@@ -139,7 +140,10 @@ export async function timeoutMemberDiscord(
|
||||
let until = payload.until;
|
||||
if (!until && payload.durationMinutes) {
|
||||
const ms = payload.durationMinutes * 60 * 1000;
|
||||
until = new Date(Date.now() + ms).toISOString();
|
||||
until = timestampMsToIsoString(Date.now() + ms);
|
||||
if (!until) {
|
||||
throw new Error("Discord timeout duration is outside the supported Date range");
|
||||
}
|
||||
}
|
||||
return await timeoutGuildMember(rest, payload.guildId, payload.userId, {
|
||||
body: { communication_disabled_until: until ?? null },
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
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,
|
||||
@@ -1497,10 +1501,14 @@ 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: Date.now() + DISCORD_REALTIME_WAKE_NAME_FOLLOWUP_TTL_MS,
|
||||
expiresAt,
|
||||
};
|
||||
logger.info(
|
||||
`discord voice: realtime wake-name follow-up armed speaker=${context.speakerLabel} voiceSession=${this.params.entry.voiceSessionKey} agent=${this.params.entry.route.agentId}`,
|
||||
@@ -1510,7 +1518,9 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
|
||||
private consumePendingWakeNameFollowup(): TranscriptUtteranceAttribution | undefined {
|
||||
const pending = this.pendingWakeNameFollowup;
|
||||
this.pendingWakeNameFollowup = undefined;
|
||||
if (!pending || Date.now() > pending.expiresAt) {
|
||||
const now = asDateTimestampMs(Date.now());
|
||||
const expiresAt = pending ? asDateTimestampMs(pending.expiresAt) : undefined;
|
||||
if (!pending || now === undefined || expiresAt === undefined || now > expiresAt) {
|
||||
return undefined;
|
||||
}
|
||||
const currentTurn = this.peekPendingSpeakerTurn();
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
57
extensions/discord/src/voice/speaker-context.test.ts
Normal file
57
extensions/discord/src/voice/speaker-context.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
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,3 +1,7 @@
|
||||
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";
|
||||
@@ -104,7 +108,9 @@ export class DiscordVoiceSpeakerContextResolver {
|
||||
if (!cached) {
|
||||
return undefined;
|
||||
}
|
||||
if (cached.expiresAt <= Date.now()) {
|
||||
const now = asDateTimestampMs(Date.now());
|
||||
const expiresAt = asDateTimestampMs(cached.expiresAt);
|
||||
if (now === undefined || expiresAt === undefined || expiresAt <= now) {
|
||||
this.cache.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
@@ -119,9 +125,12 @@ export class DiscordVoiceSpeakerContextResolver {
|
||||
|
||||
private setCachedContext(guildId: string, userId: string, context: VoiceSpeakerContext): void {
|
||||
const key = this.resolveCacheKey(guildId, userId);
|
||||
this.cache.set(key, {
|
||||
...context,
|
||||
expiresAt: Date.now() + SPEAKER_CONTEXT_CACHE_TTL_MS,
|
||||
});
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(SPEAKER_CONTEXT_CACHE_TTL_MS);
|
||||
if (expiresAt !== undefined) {
|
||||
this.cache.set(key, {
|
||||
...context,
|
||||
expiresAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,37 @@ 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 });
|
||||
|
||||
67
extensions/feishu/src/bot-sender-name.test.ts
Normal file
67
extensions/feishu/src/bot-sender-name.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
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,3 +1,7 @@
|
||||
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";
|
||||
@@ -89,10 +93,14 @@ export async function resolveFeishuSenderName(params: {
|
||||
}
|
||||
|
||||
const cached = senderNameCache.get(normalizedSenderId);
|
||||
const now = Date.now();
|
||||
if (cached && cached.expireAt > now) {
|
||||
const now = asDateTimestampMs(Date.now());
|
||||
const cachedExpireAt = cached ? asDateTimestampMs(cached.expireAt) : undefined;
|
||||
if (cached && now !== undefined && cachedExpireAt !== undefined && cachedExpireAt > now) {
|
||||
return { name: cached.name };
|
||||
}
|
||||
if (cached) {
|
||||
senderNameCache.delete(normalizedSenderId);
|
||||
}
|
||||
|
||||
try {
|
||||
const client = createFeishuClient(account);
|
||||
@@ -105,7 +113,10 @@ export async function resolveFeishuSenderName(params: {
|
||||
const name = user?.name ?? user?.nickname ?? user?.en_name;
|
||||
|
||||
if (name) {
|
||||
senderNameCache.set(normalizedSenderId, { name, expireAt: now + SENDER_NAME_TTL_MS });
|
||||
const expireAt = resolveExpiresAtMsFromDurationMs(SENDER_NAME_TTL_MS);
|
||||
if (expireAt !== undefined) {
|
||||
senderNameCache.set(normalizedSenderId, { name, expireAt });
|
||||
}
|
||||
return { name };
|
||||
}
|
||||
return {};
|
||||
|
||||
@@ -270,6 +270,45 @@ 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",
|
||||
@@ -376,6 +415,39 @@ 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: {
|
||||
@@ -459,6 +531,20 @@ 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,7 +10,11 @@ import {
|
||||
resolveConfiguredBindingRoute,
|
||||
resolveRuntimeConversationBindingRoute,
|
||||
} from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
parseStrictNonNegativeInteger,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
createChannelHistoryWindow,
|
||||
@@ -108,9 +112,14 @@ function isFeishuTopicSessionScope(scope: FeishuGroupSessionScope): boolean {
|
||||
}
|
||||
|
||||
function evictGroupNameCache(): void {
|
||||
const now = Date.now();
|
||||
const now = asDateTimestampMs(Date.now());
|
||||
if (now === undefined) {
|
||||
groupNameCache.clear();
|
||||
return;
|
||||
}
|
||||
for (const [key, val] of groupNameCache) {
|
||||
if (val.expiresAt <= now) {
|
||||
const expiresAt = asDateTimestampMs(val.expiresAt);
|
||||
if (expiresAt === undefined || expiresAt <= now) {
|
||||
groupNameCache.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -128,9 +137,12 @@ function evictGroupNameCache(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function setCacheEntry(key: string, value: { name: string; expiresAt: number }): void {
|
||||
function setCacheEntry(key: string, name: string): void {
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(GROUP_NAME_CACHE_TTL_MS);
|
||||
groupNameCache.delete(key);
|
||||
groupNameCache.set(key, value);
|
||||
if (expiresAt !== undefined) {
|
||||
groupNameCache.set(key, { name, expiresAt });
|
||||
}
|
||||
}
|
||||
|
||||
export function clearGroupNameCache(): void {
|
||||
@@ -150,37 +162,34 @@ export async function resolveGroupName(params: {
|
||||
const cacheKey = `${account.accountId}:${chatId}`;
|
||||
|
||||
const cached = groupNameCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.name || undefined;
|
||||
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);
|
||||
}
|
||||
|
||||
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,
|
||||
expiresAt: Date.now() + GROUP_NAME_CACHE_TTL_MS,
|
||||
});
|
||||
setCacheEntry(cacheKey, name);
|
||||
resolvedName = name;
|
||||
} else {
|
||||
setCacheEntry(cacheKey, {
|
||||
name: "",
|
||||
expiresAt: Date.now() + GROUP_NAME_CACHE_TTL_MS,
|
||||
});
|
||||
setCacheEntry(cacheKey, "");
|
||||
}
|
||||
} catch (err) {
|
||||
log(`feishu[${account.accountId}]: getChatInfo failed for ${chatId}: ${String(err)}`);
|
||||
setCacheEntry(cacheKey, {
|
||||
name: "",
|
||||
expiresAt: Date.now() + GROUP_NAME_CACHE_TTL_MS,
|
||||
});
|
||||
setCacheEntry(cacheKey, "");
|
||||
}
|
||||
|
||||
const result = groupNameCache.get(cacheKey)?.name || undefined;
|
||||
evictGroupNameCache();
|
||||
|
||||
return result;
|
||||
return resolvedName;
|
||||
}
|
||||
|
||||
async function resolveFeishuAudioPreflightTranscript(params: {
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
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";
|
||||
@@ -47,16 +52,26 @@ 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 (entry.expiresAt <= now) {
|
||||
if (!isFutureDateTimestampMs(entry.expiresAt, { nowMs: validNow })) {
|
||||
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;
|
||||
@@ -70,13 +85,17 @@ function beginFeishuCardActionToken(params: {
|
||||
}
|
||||
const key = `${params.accountId}:${normalizedToken}`;
|
||||
const existing = processedCardActionTokens.get(key);
|
||||
if (existing && existing.expiresAt > now) {
|
||||
if (existing && isFutureDateTimestampMs(existing.expiresAt, { nowMs: now })) {
|
||||
return false;
|
||||
}
|
||||
processedCardActionTokens.set(key, {
|
||||
status: "inflight",
|
||||
expiresAt: now + FEISHU_CARD_ACTION_TOKEN_TTL_MS,
|
||||
});
|
||||
processedCardActionTokens.delete(key);
|
||||
const expiresAt = resolveProcessedCardActionTokenExpiresAt(now);
|
||||
if (expiresAt !== undefined) {
|
||||
processedCardActionTokens.set(key, {
|
||||
status: "inflight",
|
||||
expiresAt,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -90,9 +109,15 @@ function completeFeishuCardActionToken(params: {
|
||||
if (!normalizedToken) {
|
||||
return;
|
||||
}
|
||||
processedCardActionTokens.set(`${params.accountId}:${normalizedToken}`, {
|
||||
const key = `${params.accountId}:${normalizedToken}`;
|
||||
const expiresAt = resolveProcessedCardActionTokenExpiresAt(now);
|
||||
if (expiresAt === undefined) {
|
||||
processedCardActionTokens.delete(key);
|
||||
return;
|
||||
}
|
||||
processedCardActionTokens.set(key, {
|
||||
status: "completed",
|
||||
expiresAt: now + FEISHU_CARD_ACTION_TOKEN_TTL_MS,
|
||||
expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -185,8 +210,14 @@ 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()) {
|
||||
if (entry.expiresAt <= now) {
|
||||
const expiresAt = asDateTimestampMs(entry.expiresAt);
|
||||
if (expiresAt === undefined || expiresAt <= validNow) {
|
||||
resolvedChatTypeCache.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -206,6 +237,25 @@ 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>;
|
||||
@@ -226,9 +276,13 @@ async function resolveCardActionChatType(params: {
|
||||
const now = Date.now();
|
||||
pruneChatTypeCache(now);
|
||||
const cached = resolvedChatTypeCache.get(cacheKey);
|
||||
if (cached) {
|
||||
const cachedExpiresAt = cached ? asDateTimestampMs(cached.expiresAt) : undefined;
|
||||
if (cached && cachedExpiresAt !== undefined) {
|
||||
return cached.value;
|
||||
}
|
||||
if (cached) {
|
||||
resolvedChatTypeCache.delete(cacheKey);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = (await createFeishuClient(params.account).im.chat.get({
|
||||
@@ -239,10 +293,7 @@ async function resolveCardActionChatType(params: {
|
||||
normalizeResolvedCardActionChatType(response.data?.chat_mode) ??
|
||||
normalizeResolvedCardActionChatType(response.data?.chat_type);
|
||||
if (resolvedChatType) {
|
||||
resolvedChatTypeCache.set(cacheKey, {
|
||||
value: resolvedChatType,
|
||||
expiresAt: now + CHAT_TYPE_CACHE_TTL_MS,
|
||||
});
|
||||
cacheResolvedCardActionChatType(cacheKey, resolvedChatType, now);
|
||||
return resolvedChatType;
|
||||
}
|
||||
params.log(
|
||||
@@ -349,6 +400,17 @@ 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),
|
||||
@@ -358,7 +420,7 @@ export async function handleFeishuCardAction(params: {
|
||||
command,
|
||||
prompt,
|
||||
sessionKey: envelope.c?.s,
|
||||
expiresAt: Date.now() + FEISHU_APPROVAL_CARD_TTL_MS,
|
||||
expiresAt,
|
||||
chatType: await resolveCardActionChatType({
|
||||
event,
|
||||
account,
|
||||
|
||||
@@ -88,6 +88,25 @@ 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,3 +1,7 @@
|
||||
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";
|
||||
@@ -96,7 +100,17 @@ export async function maybeHandleFeishuQuickActionMenu(params: {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expiresAt = (params.now ?? Date.now()) + FEISHU_QUICK_ACTION_CARD_TTL_MS;
|
||||
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;
|
||||
}
|
||||
try {
|
||||
await sendCardFeishu({
|
||||
cfg: params.cfg,
|
||||
|
||||
@@ -187,6 +187,32 @@ 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,4 +1,8 @@
|
||||
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";
|
||||
@@ -38,7 +42,12 @@ function setCachedProbeResult(
|
||||
result: FeishuProbeResult,
|
||||
ttlMs: number,
|
||||
): FeishuProbeResult {
|
||||
probeCache.set(cacheKey, { result, expiresAt: Date.now() + ttlMs });
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(ttlMs);
|
||||
if (expiresAt === undefined) {
|
||||
probeCache.delete(cacheKey);
|
||||
return result;
|
||||
}
|
||||
probeCache.set(cacheKey, { result, expiresAt });
|
||||
if (probeCache.size > MAX_PROBE_CACHE_SIZE) {
|
||||
const oldest = probeCache.keys().next().value;
|
||||
if (oldest !== undefined) {
|
||||
@@ -74,8 +83,13 @@ 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 && cached.expiresAt > Date.now()) {
|
||||
return cached.result;
|
||||
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);
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -251,6 +251,13 @@ 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;
|
||||
@@ -281,7 +288,7 @@ async function runScanToCreate(
|
||||
domain: FeishuDomain,
|
||||
): Promise<AppRegistrationResult | null> {
|
||||
const { beginAppRegistration, initAppRegistration, pollAppRegistration, printQrCode } =
|
||||
await import("./app-registration.js");
|
||||
await loadAppRegistrationModule();
|
||||
try {
|
||||
await initAppRegistration(domain);
|
||||
} catch {
|
||||
@@ -392,7 +399,7 @@ async function runNewAppFlow(params: {
|
||||
|
||||
// Fetch openId via API for manual flow.
|
||||
if (appId && appSecretProbeValue) {
|
||||
const { getAppOwnerOpenId } = await import("./app-registration.js");
|
||||
const { getAppOwnerOpenId } = await loadAppRegistrationModule();
|
||||
scanOpenId = await getAppOwnerOpenId({
|
||||
appId,
|
||||
appSecret: appSecretProbeValue,
|
||||
|
||||
@@ -50,6 +50,7 @@ describe("FeishuStreamingSession", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -111,6 +112,45 @@ 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);
|
||||
@@ -346,46 +386,12 @@ 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 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;
|
||||
const { authTokens, client } = mockStreamingTokenStart((token) => ({
|
||||
code: 0,
|
||||
msg: "ok",
|
||||
tenant_access_token: token,
|
||||
expire: Number.MAX_SAFE_INTEGER,
|
||||
}));
|
||||
|
||||
await new FeishuStreamingSession(client, {
|
||||
appId: "app_unsafe_token_expiry",
|
||||
@@ -401,6 +407,55 @@ 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,7 +3,11 @@
|
||||
*/
|
||||
|
||||
import type { Client } from "@larksuiteoapi/node-sdk";
|
||||
import { resolveExpiresAtMsFromDurationSeconds } from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveDateTimestampMs,
|
||||
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";
|
||||
@@ -48,13 +52,17 @@ 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): number {
|
||||
function resolveStreamingTokenExpiresAt(value: unknown, nowMs = Date.now()): number {
|
||||
const now = resolveDateTimestampMs(nowMs);
|
||||
if (typeof value === "number" && Number.isFinite(value) && value <= 0) {
|
||||
return Date.now();
|
||||
return now;
|
||||
}
|
||||
return (
|
||||
resolveExpiresAtMsFromDurationSeconds(value) ??
|
||||
Date.now() + FEISHU_STREAMING_TOKEN_DEFAULT_LIFETIME_SECONDS * 1000
|
||||
resolveExpiresAtMsFromDurationSeconds(value, { nowMs: now }) ??
|
||||
resolveExpiresAtMsFromDurationSeconds(FEISHU_STREAMING_TOKEN_DEFAULT_LIFETIME_SECONDS, {
|
||||
nowMs: now,
|
||||
}) ??
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
@@ -85,7 +93,11 @@ function resolveAllowedHostnames(domain?: FeishuDomain): string[] {
|
||||
async function getToken(creds: Credentials): Promise<string> {
|
||||
const key = `${creds.domain ?? "feishu"}|${creds.appId}`;
|
||||
const cached = tokenCache.get(key);
|
||||
if (cached && cached.expiresAt > Date.now() + 60000) {
|
||||
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) {
|
||||
return cached.token;
|
||||
}
|
||||
|
||||
@@ -115,7 +127,7 @@ async function getToken(creds: Credentials): Promise<string> {
|
||||
}
|
||||
tokenCache.set(key, {
|
||||
token: data.tenant_access_token,
|
||||
expiresAt: resolveStreamingTokenExpiresAt(data.expire),
|
||||
expiresAt: resolveStreamingTokenExpiresAt(data.expire, now),
|
||||
});
|
||||
return data.tenant_access_token;
|
||||
}
|
||||
|
||||
@@ -23,9 +23,3 @@ export function registerFeishuSubagentHooks(api: OpenClawPluginApi): void {
|
||||
handleFeishuSubagentEnded(event);
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
handleFeishuSubagentDeliveryTarget,
|
||||
handleFeishuSubagentEnded,
|
||||
handleFeishuSubagentSpawning,
|
||||
} from "./src/subagent-hooks.js";
|
||||
|
||||
@@ -37,6 +37,19 @@ import { handleGoogleMeetNodeHostCommand } from "./src/node-host.js";
|
||||
import { GoogleMeetRuntime } from "./src/runtime.js";
|
||||
import { isGoogleMeetBrowserManualActionError } from "./src/transports/chrome-create.js";
|
||||
|
||||
let googleMeetCreateModulePromise: Promise<typeof import("./src/create.js")> | null = null;
|
||||
let googleMeetCliModulePromise: Promise<typeof import("./src/cli.js")> | null = null;
|
||||
|
||||
const loadGoogleMeetCreateModule = async () => {
|
||||
googleMeetCreateModulePromise ??= import("./src/create.js");
|
||||
return await googleMeetCreateModulePromise;
|
||||
};
|
||||
|
||||
const loadGoogleMeetCliModule = async () => {
|
||||
googleMeetCliModulePromise ??= import("./src/cli.js");
|
||||
return await googleMeetCliModulePromise;
|
||||
};
|
||||
|
||||
const googleMeetConfigSchema = {
|
||||
parse(value: unknown) {
|
||||
return resolveGoogleMeetConfig(value);
|
||||
@@ -493,7 +506,7 @@ async function createMeetFromParams(params: {
|
||||
runtime: OpenClawPluginApi["runtime"];
|
||||
raw: Record<string, unknown>;
|
||||
}) {
|
||||
const create = await import("./src/create.js");
|
||||
const create = await loadGoogleMeetCreateModule();
|
||||
return create.createMeetFromParams(params);
|
||||
}
|
||||
|
||||
@@ -503,7 +516,7 @@ async function createAndJoinMeetFromParams(params: {
|
||||
raw: Record<string, unknown>;
|
||||
ensureRuntime: () => Promise<GoogleMeetRuntime>;
|
||||
}) {
|
||||
const create = await import("./src/create.js");
|
||||
const create = await loadGoogleMeetCreateModule();
|
||||
return create.createAndJoinMeetFromParams(params);
|
||||
}
|
||||
|
||||
@@ -615,7 +628,7 @@ async function exportGoogleMeetBundleFromParams(
|
||||
}),
|
||||
]);
|
||||
const { buildGoogleMeetExportManifest, googleMeetExportFileNames, writeMeetExportBundle } =
|
||||
await import("./src/cli.js");
|
||||
await loadGoogleMeetCliModule();
|
||||
const calendarId = normalizeOptionalString(raw.calendarId);
|
||||
const request = {
|
||||
...(resolved.meeting ? { meeting: resolved.meeting } : {}),
|
||||
@@ -1189,7 +1202,7 @@ export default definePluginEntry({
|
||||
|
||||
api.registerCli(
|
||||
async ({ program }) => {
|
||||
const { registerGoogleMeetCli } = await import("./src/cli.js");
|
||||
const { registerGoogleMeetCli } = await loadGoogleMeetCliModule();
|
||||
registerGoogleMeetCli({
|
||||
program,
|
||||
config,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
|
||||
describe("Google Meet OAuth", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
@@ -117,6 +118,27 @@ describe("Google Meet OAuth", () => {
|
||||
expect(tokens.expiresAt).toBe(Date.now() + 3600 * 1000);
|
||||
});
|
||||
|
||||
it("bounds fallback token lifetimes when the process clock is invalid", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
|
||||
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "new-access-token",
|
||||
expires_in: Number.MAX_SAFE_INTEGER,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const tokens = await refreshGoogleMeetAccessToken({
|
||||
clientId: "client-id",
|
||||
refreshToken: "refresh-token",
|
||||
});
|
||||
|
||||
expect(tokens.expiresAt).toBe(3600 * 1000);
|
||||
});
|
||||
|
||||
it("keeps explicit zero-second token lifetimes immediately stale", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-29T12:00:00.000Z"));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
MAX_DATE_TIMESTAMP_MS,
|
||||
resolveDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationSeconds,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { generateHexPkceVerifierChallenge } from "openclaw/plugin-sdk/provider-auth";
|
||||
@@ -24,13 +25,17 @@ const GOOGLE_MEET_SCOPES = [
|
||||
"https://www.googleapis.com/auth/drive.meet.readonly",
|
||||
] as const;
|
||||
|
||||
function resolveGoogleMeetTokenExpiresAt(value: unknown): number {
|
||||
function resolveGoogleMeetTokenExpiresAt(value: unknown, nowMs = Date.now()): number {
|
||||
const now = resolveDateTimestampMs(nowMs);
|
||||
if (typeof value === "number" && Number.isFinite(value) && value <= 0) {
|
||||
return Date.now();
|
||||
return now;
|
||||
}
|
||||
return (
|
||||
resolveExpiresAtMsFromDurationSeconds(value) ??
|
||||
Date.now() + GOOGLE_MEET_DEFAULT_TOKEN_LIFETIME_SECONDS * 1000
|
||||
resolveExpiresAtMsFromDurationSeconds(value, { nowMs: now }) ??
|
||||
resolveExpiresAtMsFromDurationSeconds(GOOGLE_MEET_DEFAULT_TOKEN_LIFETIME_SECONDS, {
|
||||
nowMs: now,
|
||||
}) ??
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,13 @@ const ENV_VARS = [
|
||||
"GEMINI_CLI_OAUTH_CLIENT_SECRET",
|
||||
] as const;
|
||||
|
||||
let oauthRuntimeModulePromise: Promise<typeof import("./oauth.runtime.js")> | null = null;
|
||||
|
||||
const loadOauthRuntimeModule = async () => {
|
||||
oauthRuntimeModulePromise ??= import("./oauth.runtime.js");
|
||||
return await oauthRuntimeModulePromise;
|
||||
};
|
||||
|
||||
async function fetchGeminiCliUsage(ctx: ProviderFetchUsageSnapshotContext) {
|
||||
return await fetchGeminiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn, PROVIDER_ID);
|
||||
}
|
||||
@@ -58,7 +65,7 @@ export function buildGoogleGeminiCliProvider(): ProviderPlugin {
|
||||
|
||||
const spin = ctx.prompter.progress("Starting Gemini CLI OAuth…");
|
||||
try {
|
||||
const { loginGeminiCliOAuth } = await import("./oauth.runtime.js");
|
||||
const { loginGeminiCliOAuth } = await loadOauthRuntimeModule();
|
||||
const result = await loginGeminiCliOAuth({
|
||||
isRemote: ctx.isRemote,
|
||||
openUrl: ctx.openUrl,
|
||||
@@ -122,7 +129,7 @@ export function buildGoogleGeminiCliProvider(): ProviderPlugin {
|
||||
isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId),
|
||||
formatApiKey: (cred) => formatGoogleOauthApiKey(cred),
|
||||
refreshOAuth: async (cred) => {
|
||||
const { refreshGeminiCliOAuthToken } = await import("./oauth.runtime.js");
|
||||
const { refreshGeminiCliOAuthToken } = await loadOauthRuntimeModule();
|
||||
return await refreshGeminiCliOAuthToken(cred);
|
||||
},
|
||||
resolveUsageAuth: async (ctx) => {
|
||||
|
||||
@@ -951,6 +951,39 @@ describe("loginGeminiCliOAuth", () => {
|
||||
expect(result.expires).toBeLessThanOrEqual(beforeRefresh);
|
||||
});
|
||||
|
||||
it("keeps invalid clocks out of refreshed Gemini CLI credential expiry", async () => {
|
||||
mockSettingsExistsSync.mockReturnValue(true);
|
||||
mockSettingsReadFileSync.mockReturnValue(
|
||||
JSON.stringify({
|
||||
security: {
|
||||
auth: {
|
||||
selectedType: "oauth-personal",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
installGeminiOAuthFetchMock(() => undefined, {
|
||||
tokenResponse: () =>
|
||||
responseJson({
|
||||
access_token: "access-token",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
});
|
||||
const dateNow = vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
|
||||
try {
|
||||
const { refreshTokensForGeminiCli } = await import("./oauth.token.js");
|
||||
const result = await refreshTokensForGeminiCli({
|
||||
refresh: "refresh-token",
|
||||
email: "lobster@openclaw.ai",
|
||||
});
|
||||
|
||||
expect(result.expires).toBe(0);
|
||||
} finally {
|
||||
dateNow.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps unsafe token expiry values out of refreshed Gemini CLI credentials", async () => {
|
||||
mockSettingsExistsSync.mockReturnValue(true);
|
||||
mockSettingsReadFileSync.mockReturnValue(
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { resolveExpiresAtMsFromDurationSeconds } from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationSeconds,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { resolveOAuthClientConfig } from "./oauth.credentials.js";
|
||||
import { fetchWithTimeout } from "./oauth.http.js";
|
||||
import { resolveGoogleOAuthIdentity, resolveGooglePersonalOAuthIdentity } from "./oauth.project.js";
|
||||
@@ -34,10 +37,18 @@ async function requestTokenGrant(body: URLSearchParams): Promise<{
|
||||
};
|
||||
}
|
||||
|
||||
function resolveExpiredTokenTimestampMs(nowMs: number): number {
|
||||
return asDateTimestampMs(nowMs - TOKEN_EXPIRY_BUFFER_MS) ?? nowMs;
|
||||
}
|
||||
|
||||
function resolveTokenExpiresAt(value: unknown): number {
|
||||
const nowMs = asDateTimestampMs(Date.now());
|
||||
if (nowMs === undefined) {
|
||||
return 0;
|
||||
}
|
||||
return (
|
||||
resolveExpiresAtMsFromDurationSeconds(value, { bufferMs: TOKEN_EXPIRY_BUFFER_MS }) ??
|
||||
Date.now() - TOKEN_EXPIRY_BUFFER_MS
|
||||
resolveExpiresAtMsFromDurationSeconds(value, { nowMs, bufferMs: TOKEN_EXPIRY_BUFFER_MS }) ??
|
||||
resolveExpiredTokenTimestampMs(nowMs)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
for (const key of ENV_KEYS) {
|
||||
const value = envSnapshot[key];
|
||||
if (value === undefined) {
|
||||
@@ -452,6 +453,20 @@ describe("buildGoogleRealtimeVoiceProvider", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects browser session expiry outside Date range", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
|
||||
const provider = buildGoogleRealtimeVoiceProvider();
|
||||
|
||||
await expect(
|
||||
provider.createBrowserSession?.({
|
||||
providerConfig: {
|
||||
apiKey: "gemini-key",
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("Google realtime browser session expiry is outside the supported Date range");
|
||||
expect(createTokenMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("can opt out of Google Live session resumption and context compression", async () => {
|
||||
const provider = buildGoogleRealtimeVoiceProvider();
|
||||
const bridge = provider.createBridge({
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
ThinkingConfig,
|
||||
TurnCoverage,
|
||||
} from "@google/genai";
|
||||
import { timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard";
|
||||
import type {
|
||||
RealtimeVoiceAudioFormat,
|
||||
@@ -857,6 +858,11 @@ async function createGoogleRealtimeBrowserSession(
|
||||
const voice = req.voice ?? config.voice ?? GOOGLE_REALTIME_DEFAULT_VOICE;
|
||||
const expiresAtMs = Date.now() + GOOGLE_REALTIME_BROWSER_SESSION_TTL_MS;
|
||||
const newSessionExpiresAtMs = Date.now() + GOOGLE_REALTIME_BROWSER_NEW_SESSION_TTL_MS;
|
||||
const expireTime = timestampMsToIsoString(expiresAtMs);
|
||||
const newSessionExpireTime = timestampMsToIsoString(newSessionExpiresAtMs);
|
||||
if (!expireTime || !newSessionExpireTime) {
|
||||
throw new Error("Google realtime browser session expiry is outside the supported Date range");
|
||||
}
|
||||
const ai = createGoogleGenAI({
|
||||
apiKey,
|
||||
httpOptions: {
|
||||
@@ -866,8 +872,8 @@ async function createGoogleRealtimeBrowserSession(
|
||||
const token = await ai.authTokens.create({
|
||||
config: {
|
||||
uses: 1,
|
||||
expireTime: new Date(expiresAtMs).toISOString(),
|
||||
newSessionExpireTime: new Date(newSessionExpiresAtMs).toISOString(),
|
||||
expireTime,
|
||||
newSessionExpireTime,
|
||||
liveConnectConstraints: {
|
||||
model,
|
||||
config: buildGoogleLiveConnectConfig({
|
||||
|
||||
@@ -292,6 +292,7 @@ describe("google transport stream", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
@@ -767,6 +768,29 @@ describe("google transport stream", () => {
|
||||
expect(tokenFetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not cache google-auth ADC tokens when fallback expiry would exceed Date range", async () => {
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-authlib-expiry-"));
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(8_640_000_000_000_000));
|
||||
vi.stubEnv("GOOGLE_APPLICATION_CREDENTIALS", "");
|
||||
vi.stubEnv("HOME", path.join(tempDir, "home"));
|
||||
vi.stubEnv("APPDATA", "");
|
||||
googleAuthGetAccessTokenMock
|
||||
.mockResolvedValueOnce("ya29.first-token")
|
||||
.mockResolvedValueOnce("ya29.second-token");
|
||||
const tokenFetchMock = vi.fn();
|
||||
|
||||
await expect(resolveGoogleVertexAuthorizedUserHeaders(tokenFetchMock)).resolves.toEqual({
|
||||
Authorization: "Bearer ya29.first-token",
|
||||
});
|
||||
await expect(resolveGoogleVertexAuthorizedUserHeaders(tokenFetchMock)).resolves.toEqual({
|
||||
Authorization: "Bearer ya29.second-token",
|
||||
});
|
||||
|
||||
expect(googleAuthGetAccessTokenMock).toHaveBeenCalledTimes(2);
|
||||
expect(tokenFetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses google-auth-library bearer auth for Google Vertex credential marker requests", async () => {
|
||||
const tempDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-google-vertex-authlib-stream-"));
|
||||
vi.stubEnv("GOOGLE_APPLICATION_CREDENTIALS", "");
|
||||
|
||||
@@ -2,7 +2,11 @@ import { existsSync, readFileSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { resolveExpiresAtMsFromDurationSeconds } from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
resolveExpiresAtMsFromDurationSeconds,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
type GoogleAuthorizedUserCredentials = {
|
||||
@@ -32,6 +36,7 @@ const GOOGLE_VERTEX_OAUTH_SCOPE = "https://www.googleapis.com/auth/cloud-platfor
|
||||
// leaves the gateway.
|
||||
const GOOGLE_VERTEX_TOKEN_EXPIRY_BUFFER_MS = 60_000;
|
||||
const GOOGLE_VERTEX_DEFAULT_TOKEN_LIFETIME_SECONDS = 3600;
|
||||
const GOOGLE_VERTEX_AUTHLIB_TOKEN_CACHE_MS = 5 * 60_000;
|
||||
|
||||
let cachedGoogleVertexAuthorizedUserToken: GoogleVertexAuthorizedUserToken | undefined;
|
||||
let cachedGoogleAuthClient:
|
||||
@@ -43,18 +48,36 @@ let cachedGoogleAuthClient:
|
||||
| undefined;
|
||||
let cachedGoogleVertexAdcToken: GoogleVertexAdcToken | undefined;
|
||||
|
||||
function resolveAuthorizedUserTokenExpiresAtMs(value: unknown, nowMs: number): number {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return (
|
||||
resolveExpiresAtMsFromDurationSeconds(Math.max(1, value), { nowMs }) ??
|
||||
nowMs - GOOGLE_VERTEX_TOKEN_EXPIRY_BUFFER_MS
|
||||
);
|
||||
function isGoogleVertexTokenFresh(expiresAtMsRaw: number, nowRaw = Date.now()): boolean {
|
||||
const expiresAtMs = asDateTimestampMs(expiresAtMsRaw);
|
||||
const nowMs = asDateTimestampMs(nowRaw);
|
||||
if (expiresAtMs === undefined || nowMs === undefined) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
resolveExpiresAtMsFromDurationSeconds(GOOGLE_VERTEX_DEFAULT_TOKEN_LIFETIME_SECONDS, {
|
||||
nowMs,
|
||||
}) ?? nowMs - GOOGLE_VERTEX_TOKEN_EXPIRY_BUFFER_MS
|
||||
const minFreshExpiresAtMs = resolveExpiresAtMsFromDurationMs(
|
||||
GOOGLE_VERTEX_TOKEN_EXPIRY_BUFFER_MS,
|
||||
{ nowMs },
|
||||
);
|
||||
return minFreshExpiresAtMs !== undefined && expiresAtMs > minFreshExpiresAtMs;
|
||||
}
|
||||
|
||||
function resolveAuthorizedUserTokenExpiresAtMs(value: unknown, nowRaw: number): number | undefined {
|
||||
const nowMs = asDateTimestampMs(nowRaw);
|
||||
if (nowMs === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const lifetimeSeconds =
|
||||
typeof value === "number" && Number.isFinite(value)
|
||||
? Math.max(1, value)
|
||||
: GOOGLE_VERTEX_DEFAULT_TOKEN_LIFETIME_SECONDS;
|
||||
return resolveExpiresAtMsFromDurationSeconds(lifetimeSeconds, { nowMs }) ?? nowMs;
|
||||
}
|
||||
|
||||
function resolveGoogleAuthLibraryTokenExpiresAtMs(nowRaw = Date.now()): number | undefined {
|
||||
const nowMs = asDateTimestampMs(nowRaw);
|
||||
return nowMs === undefined
|
||||
? undefined
|
||||
: resolveExpiresAtMsFromDurationMs(GOOGLE_VERTEX_AUTHLIB_TOKEN_CACHE_MS, { nowMs });
|
||||
}
|
||||
|
||||
export function resetGoogleVertexAuthorizedUserTokenCacheForTest(): void {
|
||||
@@ -177,7 +200,7 @@ async function refreshGoogleVertexAuthorizedUserAccessToken(params: {
|
||||
if (
|
||||
cached?.credentialsPath === params.credentialsPath &&
|
||||
cached.refreshToken === refreshToken &&
|
||||
cached.expiresAtMs - Date.now() > GOOGLE_VERTEX_TOKEN_EXPIRY_BUFFER_MS
|
||||
isGoogleVertexTokenFresh(cached.expiresAtMs)
|
||||
) {
|
||||
return cached.token;
|
||||
}
|
||||
@@ -208,12 +231,15 @@ async function refreshGoogleVertexAuthorizedUserAccessToken(params: {
|
||||
throw new Error("Google Vertex ADC token refresh response did not include an access_token.");
|
||||
}
|
||||
const nowMs = Date.now();
|
||||
cachedGoogleVertexAuthorizedUserToken = {
|
||||
token,
|
||||
expiresAtMs: resolveAuthorizedUserTokenExpiresAtMs(payload?.expires_in, nowMs),
|
||||
credentialsPath: params.credentialsPath,
|
||||
refreshToken,
|
||||
};
|
||||
const expiresAtMs = resolveAuthorizedUserTokenExpiresAtMs(payload?.expires_in, nowMs);
|
||||
if (expiresAtMs !== undefined) {
|
||||
cachedGoogleVertexAuthorizedUserToken = {
|
||||
token,
|
||||
expiresAtMs,
|
||||
credentialsPath: params.credentialsPath,
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
@@ -238,7 +264,7 @@ async function resolveGoogleVertexAccessTokenViaGoogleAuth(): Promise<string> {
|
||||
const auth = await cachedGoogleAuthClient.promise;
|
||||
|
||||
const cached = cachedGoogleVertexAdcToken;
|
||||
if (cached && cached.expiresAtMs - Date.now() > GOOGLE_VERTEX_TOKEN_EXPIRY_BUFFER_MS) {
|
||||
if (cached && isGoogleVertexTokenFresh(cached.expiresAtMs)) {
|
||||
return cached.token;
|
||||
}
|
||||
|
||||
@@ -255,10 +281,13 @@ async function resolveGoogleVertexAccessTokenViaGoogleAuth(): Promise<string> {
|
||||
// `getAccessToken()` return type, so we cache for a conservative 5 minutes.
|
||||
// The library itself already refreshes well before its own internal expiry,
|
||||
// so this cache is mainly to avoid hot-loop calls into the auth client.
|
||||
cachedGoogleVertexAdcToken = {
|
||||
token: normalized,
|
||||
expiresAtMs: Date.now() + 5 * 60_000,
|
||||
};
|
||||
const expiresAtMs = resolveGoogleAuthLibraryTokenExpiresAtMs();
|
||||
if (expiresAtMs !== undefined) {
|
||||
cachedGoogleVertexAdcToken = {
|
||||
token: normalized,
|
||||
expiresAtMs,
|
||||
};
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const spawnMock = vi.hoisted(() => vi.fn());
|
||||
const createIMessageRpcClientMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("node:child_process", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("node:child_process")>()),
|
||||
spawn: spawnMock,
|
||||
}));
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
createIMessageRpcClient: createIMessageRpcClientMock,
|
||||
}));
|
||||
|
||||
const { imessageActionsRuntime, findChatGuidForTest, normalizeDirectChatIdentifierForTest } =
|
||||
await import("./actions.runtime.js");
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
createIMessageRpcClientMock.mockReset();
|
||||
spawnMock.mockReset();
|
||||
});
|
||||
|
||||
function mockSpawnJsonResponse(payload: Record<string, unknown> = { success: true }) {
|
||||
spawnMock.mockImplementationOnce(() => {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
@@ -29,6 +40,13 @@ function mockSpawnJsonResponse(payload: Record<string, unknown> = { success: tru
|
||||
});
|
||||
}
|
||||
|
||||
function mockRpcChatList(chats: Array<Record<string, unknown>>) {
|
||||
const request = vi.fn().mockResolvedValue({ chats });
|
||||
const stop = vi.fn().mockResolvedValue(undefined);
|
||||
createIMessageRpcClientMock.mockResolvedValueOnce({ request, stop });
|
||||
return { request, stop };
|
||||
}
|
||||
|
||||
describe("imessage actions runtime", () => {
|
||||
it("passes the configured Messages db path to private API bridge commands", async () => {
|
||||
mockSpawnJsonResponse();
|
||||
@@ -63,6 +81,58 @@ describe("imessage actions runtime", () => {
|
||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
});
|
||||
|
||||
it("drops cached chats.list entries when the current clock is not a valid date timestamp", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValueOnce(1_700_000_000_000).mockReturnValueOnce(Number.NaN);
|
||||
const firstClient = mockRpcChatList([{ id: 1, guid: "iMessage;+;first" }]);
|
||||
const secondClient = mockRpcChatList([{ id: 2, guid: "iMessage;+;second" }]);
|
||||
|
||||
await expect(
|
||||
imessageActionsRuntime.resolveChatGuidForTarget({
|
||||
target: { kind: "chat_id", chatId: 1 },
|
||||
options: { cliPath: "imsg-invalid-clock" },
|
||||
}),
|
||||
).resolves.toBe("iMessage;+;first");
|
||||
await expect(
|
||||
imessageActionsRuntime.resolveChatGuidForTarget({
|
||||
target: { kind: "chat_id", chatId: 2 },
|
||||
options: { cliPath: "imsg-invalid-clock" },
|
||||
}),
|
||||
).resolves.toBe("iMessage;+;second");
|
||||
|
||||
expect(createIMessageRpcClientMock).toHaveBeenCalledTimes(2);
|
||||
expect(firstClient.request).toHaveBeenCalledWith(
|
||||
"chats.list",
|
||||
{ limit: 1000 },
|
||||
{ timeoutMs: undefined },
|
||||
);
|
||||
expect(secondClient.request).toHaveBeenCalledWith(
|
||||
"chats.list",
|
||||
{ limit: 1000 },
|
||||
{ timeoutMs: undefined },
|
||||
);
|
||||
});
|
||||
|
||||
it("does not cache chats.list when the expiry timestamp would exceed the valid date range", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
|
||||
mockRpcChatList([{ id: 1, guid: "iMessage;+;first" }]);
|
||||
mockRpcChatList([{ id: 2, guid: "iMessage;+;second" }]);
|
||||
|
||||
await expect(
|
||||
imessageActionsRuntime.resolveChatGuidForTarget({
|
||||
target: { kind: "chat_id", chatId: 1 },
|
||||
options: { cliPath: "imsg-overflow-clock" },
|
||||
}),
|
||||
).resolves.toBe("iMessage;+;first");
|
||||
await expect(
|
||||
imessageActionsRuntime.resolveChatGuidForTarget({
|
||||
target: { kind: "chat_id", chatId: 2 },
|
||||
options: { cliPath: "imsg-overflow-clock" },
|
||||
}),
|
||||
).resolves.toBe("iMessage;+;second");
|
||||
|
||||
expect(createIMessageRpcClientMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findChatGuid cross-format identifier resolution", () => {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { extname, join } from "node:path";
|
||||
import { parseStrictInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
parseStrictInteger,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { appendIMessageCliStderrTail, appendIMessageCliStdout } from "./cli-output.js";
|
||||
@@ -76,12 +80,14 @@ function chatListCacheGet(
|
||||
cliPath: string,
|
||||
dbPath?: string,
|
||||
): ReadonlyArray<Record<string, unknown>> | null {
|
||||
const entry = chatListCache.get(chatListCacheKey(cliPath, dbPath));
|
||||
const key = chatListCacheKey(cliPath, dbPath);
|
||||
const entry = chatListCache.get(key);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
if (entry.expiresAt < Date.now()) {
|
||||
chatListCache.delete(chatListCacheKey(cliPath, dbPath));
|
||||
const now = asDateTimestampMs(Date.now());
|
||||
if (now === undefined || entry.expiresAt <= now) {
|
||||
chatListCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.list;
|
||||
@@ -92,9 +98,13 @@ function chatListCacheSet(
|
||||
dbPath: string | undefined,
|
||||
list: ReadonlyArray<Record<string, unknown>>,
|
||||
): void {
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(CHAT_LIST_CACHE_TTL_MS);
|
||||
if (expiresAt === undefined) {
|
||||
return;
|
||||
}
|
||||
chatListCache.set(chatListCacheKey(cliPath, dbPath), {
|
||||
list,
|
||||
expiresAt: Date.now() + CHAT_LIST_CACHE_TTL_MS,
|
||||
expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
rememberIMessageReplyCache,
|
||||
type IMessageChatContext,
|
||||
} from "./monitor-reply-cache.js";
|
||||
import { getCachedIMessagePrivateApiStatus } from "./probe.js";
|
||||
import { getCachedIMessagePrivateApiStatus, probeIMessagePrivateApi } from "./probe.js";
|
||||
import { parseIMessageTarget, type IMessageTarget } from "./targets.js";
|
||||
|
||||
const loadIMessageActionsRuntime = createLazyRuntimeNamedExport(
|
||||
@@ -417,7 +417,6 @@ export const imessageMessageActions: ChannelMessageActionAdapter = {
|
||||
// status adapter, which doesn't fire eagerly on first dispatch. Run
|
||||
// an inline probe so the first react/send-rich attempt after `imsg
|
||||
// launch` succeeds without requiring a manual `channels status`.
|
||||
const { probeIMessagePrivateApi } = await import("./probe.js");
|
||||
privateApiStatus = await probeIMessagePrivateApi(
|
||||
cliPathForProbe,
|
||||
account.config.probeTimeoutMs ?? DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS,
|
||||
|
||||
@@ -168,7 +168,7 @@ describe("iMessage monitor last-route updates", () => {
|
||||
expect(recordParams?.updateLastRoute?.sessionKey).toBe(recordParams?.sessionKey);
|
||||
expect(recordParams?.updateLastRoute?.sessionKey).not.toBe("agent:main:main");
|
||||
expect(recordParams?.updateLastRoute?.channel).toBe("imessage");
|
||||
expect(recordParams?.updateLastRoute?.to).toBe("+15550001111");
|
||||
expect(recordParams?.updateLastRoute?.to).toBe("imessage:+15550001111");
|
||||
expect(recordParams?.updateLastRoute?.mainDmOwnerPin).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runIMessageCatchup } from "./catchup-bridge.js";
|
||||
import { resolveCatchupConfig } from "./catchup.js";
|
||||
import { resolveCatchupConfig, saveIMessageCatchupCursor } from "./catchup.js";
|
||||
import type { IMessagePayload } from "./types.js";
|
||||
|
||||
type RpcCall = {
|
||||
@@ -157,6 +157,32 @@ describe("runIMessageCatchup", () => {
|
||||
expect(summary.replayed).toBe(1);
|
||||
});
|
||||
|
||||
it("does not crash on Date-invalid persisted cursor timestamps", async () => {
|
||||
const log = vi.fn();
|
||||
await saveIMessageCatchupCursor("default", {
|
||||
lastSeenMs: 8_700_000_000_000_000,
|
||||
lastSeenRowid: 10,
|
||||
});
|
||||
const { client, calls } = makeFakeClient(() => {
|
||||
throw new Error("unexpected rpc");
|
||||
});
|
||||
|
||||
const summary = await runIMessageCatchup({
|
||||
client: client as never,
|
||||
accountId: "default",
|
||||
config: resolveCatchupConfig({ enabled: true, perRunLimit: 50, maxAgeMinutes: 60 }),
|
||||
includeAttachments: false,
|
||||
dispatchPayload: async () => {},
|
||||
runtime: { log },
|
||||
});
|
||||
|
||||
expect(summary.querySucceeded).toBe(false);
|
||||
expect(calls).toEqual([]);
|
||||
expect(log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("imessage catchup: invalid since timestamp"),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns querySucceeded=false when chats.list throws", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-08T12:00:00Z"));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { warn } from "openclaw/plugin-sdk/runtime-env";
|
||||
import type { IMessageRpcClient } from "../client.js";
|
||||
import {
|
||||
@@ -88,6 +89,11 @@ export async function runIMessageCatchup(
|
||||
const payloadByGuid = new Map<string, IMessagePayload>();
|
||||
|
||||
const fetchFn: CatchupFetchFn = async ({ sinceMs, sinceRowid, limit }) => {
|
||||
const sinceISO = timestampMsToIsoString(sinceMs);
|
||||
if (!sinceISO) {
|
||||
warnLog(`imessage catchup: invalid since timestamp ${sinceMs}`);
|
||||
return { resolved: false, rows: [] };
|
||||
}
|
||||
let chatsResult: { chats?: ChatsListEntry[] } | undefined;
|
||||
try {
|
||||
chatsResult = await client.request<{ chats?: ChatsListEntry[] }>(
|
||||
@@ -100,7 +106,6 @@ export async function runIMessageCatchup(
|
||||
return { resolved: false, rows: [] };
|
||||
}
|
||||
const chats = chatsResult?.chats ?? [];
|
||||
const sinceISO = new Date(sinceMs).toISOString();
|
||||
const collected: IMessageCatchupRow[] = [];
|
||||
const perChatLimit = Math.min(limit, PER_CHAT_HISTORY_LIMIT_CAP);
|
||||
let historyFetchFailed = false;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { asDateTimestampMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
|
||||
export type IMessagePrivateApiStatus = {
|
||||
available: boolean;
|
||||
v2Ready: boolean;
|
||||
@@ -56,7 +58,11 @@ export function getCachedIMessagePrivateApiStatus(
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
if (entry.expiresAt > 0 && entry.expiresAt < Date.now()) {
|
||||
if (entry.expiresAt === 0) {
|
||||
return entry.status;
|
||||
}
|
||||
const now = asDateTimestampMs(Date.now());
|
||||
if (now === undefined || entry.expiresAt <= now) {
|
||||
bridgeStatusCache.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
@@ -68,6 +74,9 @@ export function setCachedIMessagePrivateApiStatus(
|
||||
status: IMessagePrivateApiStatus,
|
||||
expiresAt = 0,
|
||||
): void {
|
||||
if (expiresAt !== 0 && asDateTimestampMs(expiresAt) === undefined) {
|
||||
return;
|
||||
}
|
||||
bridgeStatusCache.set(normalizeCliPath(cliPath), { status, expiresAt });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearCachedIMessagePrivateApiStatus,
|
||||
getCachedIMessagePrivateApiStatus,
|
||||
setCachedIMessagePrivateApiStatus,
|
||||
} from "./private-api-status.js";
|
||||
import { imessageRpcSupportsMethod } from "./probe.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
clearCachedIMessagePrivateApiStatus();
|
||||
});
|
||||
|
||||
describe("imessageRpcSupportsMethod", () => {
|
||||
it("returns false when the bridge is not available", () => {
|
||||
expect(
|
||||
@@ -92,3 +102,35 @@ describe("imessageRpcSupportsMethod", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("iMessage private API status cache", () => {
|
||||
const availableStatus = {
|
||||
available: true,
|
||||
v2Ready: true,
|
||||
selectors: {},
|
||||
rpcMethods: ["chats.list"],
|
||||
};
|
||||
|
||||
it("drops expiring private API status when the current clock is not a valid date timestamp", () => {
|
||||
clearCachedIMessagePrivateApiStatus();
|
||||
setCachedIMessagePrivateApiStatus(
|
||||
"imsg-invalid-private-clock",
|
||||
availableStatus,
|
||||
1_700_000_030_000,
|
||||
);
|
||||
vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
|
||||
|
||||
expect(getCachedIMessagePrivateApiStatus("imsg-invalid-private-clock")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not cache private API status with an invalid expiry timestamp", () => {
|
||||
clearCachedIMessagePrivateApiStatus();
|
||||
setCachedIMessagePrivateApiStatus(
|
||||
"imsg-overflow-private-clock",
|
||||
availableStatus,
|
||||
Number.POSITIVE_INFINITY,
|
||||
);
|
||||
|
||||
expect(getCachedIMessagePrivateApiStatus("imsg-overflow-private-clock")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import path from "node:path";
|
||||
import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { runCommandWithTimeout } from "openclaw/plugin-sdk/process-runtime";
|
||||
import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
@@ -53,6 +57,27 @@ type RpcSupportCacheEntry = { result: RpcSupportResult; expiresAt: number };
|
||||
|
||||
const rpcSupportCache = new Map<string, RpcSupportCacheEntry>();
|
||||
|
||||
function getCachedRpcSupport(cliPath: string): RpcSupportResult | undefined {
|
||||
const cached = rpcSupportCache.get(cliPath);
|
||||
if (!cached) {
|
||||
return undefined;
|
||||
}
|
||||
const now = asDateTimestampMs(Date.now());
|
||||
if (now === undefined || cached.expiresAt <= now) {
|
||||
rpcSupportCache.delete(cliPath);
|
||||
return undefined;
|
||||
}
|
||||
return cached.result;
|
||||
}
|
||||
|
||||
function setCachedRpcSupport(cliPath: string, result: RpcSupportResult): void {
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(RPC_SUPPORT_CACHE_TTL_MS);
|
||||
if (expiresAt === undefined) {
|
||||
return;
|
||||
}
|
||||
rpcSupportCache.set(cliPath, { result, expiresAt });
|
||||
}
|
||||
|
||||
function isDefaultLocalIMessageCliPath(cliPath: string): boolean {
|
||||
const trimmed = cliPath.trim();
|
||||
return trimmed === "imsg" || (!trimmed.includes("/") && path.basename(trimmed) === "imsg");
|
||||
@@ -69,9 +94,9 @@ export function resolveIMessageNonMacHostError(
|
||||
}
|
||||
|
||||
async function probeRpcSupport(cliPath: string, timeoutMs: number): Promise<RpcSupportResult> {
|
||||
const cached = rpcSupportCache.get(cliPath);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.result;
|
||||
const cached = getCachedRpcSupport(cliPath);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
try {
|
||||
const result = await runCommandWithTimeout([cliPath, "rpc", "--help"], { timeoutMs });
|
||||
@@ -83,18 +108,12 @@ async function probeRpcSupport(cliPath: string, timeoutMs: number): Promise<RpcS
|
||||
fatal: true,
|
||||
error: 'imsg CLI does not support the "rpc" subcommand (update imsg)',
|
||||
};
|
||||
rpcSupportCache.set(cliPath, {
|
||||
result: fatal,
|
||||
expiresAt: Date.now() + RPC_SUPPORT_CACHE_TTL_MS,
|
||||
});
|
||||
setCachedRpcSupport(cliPath, fatal);
|
||||
return fatal;
|
||||
}
|
||||
if (result.code === 0) {
|
||||
const supported = { supported: true };
|
||||
rpcSupportCache.set(cliPath, {
|
||||
result: supported,
|
||||
expiresAt: Date.now() + RPC_SUPPORT_CACHE_TTL_MS,
|
||||
});
|
||||
setCachedRpcSupport(cliPath, supported);
|
||||
return supported;
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vites
|
||||
import { resolveIMessageAccount } from "./accounts.js";
|
||||
import * as channelRuntimeModule from "./channel.runtime.js";
|
||||
import * as clientModule from "./client.js";
|
||||
import { probeIMessage } from "./probe.js";
|
||||
import { clearIMessagePrivateApiCache, probeIMessage } from "./probe.js";
|
||||
import { imessageSetupWizard } from "./setup-surface.js";
|
||||
import { probeIMessageStatusAccount } from "./status-core.js";
|
||||
|
||||
@@ -159,6 +159,7 @@ describe("imessage setup status", () => {
|
||||
describe("probeIMessage", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
clearIMessagePrivateApiCache();
|
||||
spawnMock.mockClear();
|
||||
vi.spyOn(setupRuntime, "detectBinary").mockResolvedValue(true);
|
||||
vi.spyOn(processRuntime, "runCommandWithTimeout").mockResolvedValue({
|
||||
@@ -185,6 +186,102 @@ describe("probeIMessage", () => {
|
||||
expect(createIMessageRpcClientMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops cached rpc support when the current clock is not a valid date timestamp", async () => {
|
||||
vi.spyOn(Date, "now")
|
||||
.mockReturnValueOnce(1_700_000_000_000)
|
||||
.mockReturnValueOnce(Number.NaN)
|
||||
.mockReturnValue(1_700_000_000_000);
|
||||
const runCommand = vi
|
||||
.spyOn(processRuntime, "runCommandWithTimeout")
|
||||
.mockResolvedValueOnce({
|
||||
stdout: "",
|
||||
stderr: 'unknown command "rpc" for "imsg"',
|
||||
code: 1,
|
||||
signal: null,
|
||||
killed: false,
|
||||
termination: "exit",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: "rpc help",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
termination: "exit",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
advanced_features: true,
|
||||
v2_ready: true,
|
||||
selectors: {},
|
||||
rpc_methods: ["chats.list"],
|
||||
}),
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
termination: "exit",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: "send-rich --file",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
termination: "exit",
|
||||
});
|
||||
vi.spyOn(clientModule, "createIMessageRpcClient").mockResolvedValue({
|
||||
request: vi.fn().mockResolvedValue({ chats: [] }),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as Awaited<ReturnType<typeof clientModule.createIMessageRpcClient>>);
|
||||
|
||||
await expect(probeIMessage(1000, { cliPath: "imsg-invalid-rpc-clock" })).resolves.toMatchObject(
|
||||
{
|
||||
ok: false,
|
||||
fatal: true,
|
||||
},
|
||||
);
|
||||
await expect(probeIMessage(1000, { cliPath: "imsg-invalid-rpc-clock" })).resolves.toMatchObject(
|
||||
{
|
||||
ok: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(runCommand).toHaveBeenNthCalledWith(1, ["imsg-invalid-rpc-clock", "rpc", "--help"], {
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
expect(runCommand).toHaveBeenNthCalledWith(2, ["imsg-invalid-rpc-clock", "rpc", "--help"], {
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not cache rpc support when the expiry timestamp would exceed the valid date range", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
|
||||
const runCommand = vi.spyOn(processRuntime, "runCommandWithTimeout").mockResolvedValue({
|
||||
stdout: "",
|
||||
stderr: 'unknown command "rpc" for "imsg"',
|
||||
code: 1,
|
||||
signal: null,
|
||||
killed: false,
|
||||
termination: "exit",
|
||||
});
|
||||
|
||||
await expect(
|
||||
probeIMessage(1000, { cliPath: "imsg-overflow-rpc-clock" }),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
fatal: true,
|
||||
});
|
||||
await expect(
|
||||
probeIMessage(1000, { cliPath: "imsg-overflow-rpc-clock" }),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
fatal: true,
|
||||
});
|
||||
|
||||
expect(runCommand).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("fails fast for default local imsg probes on non-mac hosts", async () => {
|
||||
const createIMessageRpcClientMock = vi
|
||||
.spyOn(clientModule, "createIMessageRpcClient")
|
||||
|
||||
@@ -28,7 +28,7 @@ describe("imessage targets", () => {
|
||||
|
||||
it("parses sms handles with service", () => {
|
||||
const target = parseIMessageTarget("sms:+1555");
|
||||
expect(target).toEqual({ kind: "handle", to: "+1555", service: "sms" });
|
||||
expect(target).toEqual({ kind: "handle", to: "+1555", service: "sms", serviceExplicit: true });
|
||||
});
|
||||
|
||||
it("normalizes handles", () => {
|
||||
|
||||
@@ -85,6 +85,12 @@ const loadMatrixChannelRuntime = createLazyRuntimeNamedExport(
|
||||
() => import("./channel.runtime.js"),
|
||||
"matrixChannelRuntime",
|
||||
);
|
||||
let matrixDoctorModulePromise: Promise<typeof import("./doctor.js")> | null = null;
|
||||
|
||||
const loadMatrixDoctorModule = async () => {
|
||||
matrixDoctorModulePromise ??= import("./doctor.js");
|
||||
return await matrixDoctorModulePromise;
|
||||
};
|
||||
|
||||
const meta = {
|
||||
id: "matrix",
|
||||
@@ -118,9 +124,9 @@ const matrixDoctor: ChannelDoctorAdapter = {
|
||||
legacyConfigRules: MATRIX_LEGACY_CONFIG_RULES,
|
||||
normalizeCompatibilityConfig: normalizeMatrixCompatibilityConfig,
|
||||
runConfigSequence: async ({ cfg, env, shouldRepair }) =>
|
||||
await (await import("./doctor.js")).runMatrixDoctorSequence({ cfg, env, shouldRepair }),
|
||||
await (await loadMatrixDoctorModule()).runMatrixDoctorSequence({ cfg, env, shouldRepair }),
|
||||
cleanStaleConfig: async ({ cfg }) =>
|
||||
await (await import("./doctor.js")).cleanStaleMatrixPluginConfig(cfg),
|
||||
await (await loadMatrixDoctorModule()).cleanStaleMatrixPluginConfig(cfg),
|
||||
};
|
||||
|
||||
const listMatrixDirectoryPeersFromConfig =
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ensureMatrixStartupVerification } from "./startup-verification.js";
|
||||
|
||||
function createTempStateDir(): string {
|
||||
@@ -80,6 +80,10 @@ function createHarness(params?: {
|
||||
}
|
||||
|
||||
describe("ensureMatrixStartupVerification", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("skips automatic requests when the device is already verified", async () => {
|
||||
const tempHome = createTempStateDir();
|
||||
const harness = createHarness({ verified: true });
|
||||
@@ -203,6 +207,27 @@ describe("ensureMatrixStartupVerification", () => {
|
||||
expect(fs.existsSync(createStateFilePath(tempHome))).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back when startup verification nowMs is outside Date range", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-05-30T12:00:00.000Z"));
|
||||
const tempHome = createTempStateDir();
|
||||
const stateFilePath = createStateFilePath(tempHome);
|
||||
const harness = createHarness();
|
||||
|
||||
const result = await ensureMatrixStartupVerification({
|
||||
client: harness.client as never,
|
||||
auth: createAuth(),
|
||||
accountConfig: {},
|
||||
stateFilePath,
|
||||
nowMs: 8_640_000_000_000_001,
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("requested");
|
||||
const state = JSON.parse(fs.readFileSync(stateFilePath, "utf-8")) as {
|
||||
attemptedAt?: string;
|
||||
};
|
||||
expect(state.attemptedAt).toBe("2026-05-30T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("keeps startup verification failures non-fatal", async () => {
|
||||
const tempHome = createTempStateDir();
|
||||
const harness = createHarness({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store";
|
||||
import { timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
|
||||
import type { MatrixConfig } from "../../types.js";
|
||||
import { resolveMatrixStoragePaths } from "../client/storage.js";
|
||||
import type { MatrixAuth } from "../client/types.js";
|
||||
@@ -95,6 +96,14 @@ function resolveRetryAfterMs(params: {
|
||||
return remaining > 0 ? remaining : undefined;
|
||||
}
|
||||
|
||||
function resolveStartupVerificationTimestamp(nowMs: unknown): string {
|
||||
return (
|
||||
timestampMsToIsoString(nowMs) ??
|
||||
timestampMsToIsoString(Date.now()) ??
|
||||
"1970-01-01T00:00:00.000Z"
|
||||
);
|
||||
}
|
||||
|
||||
function shouldHonorCooldown(params: {
|
||||
state: MatrixStartupVerificationState | null;
|
||||
verification: MatrixOwnDeviceVerificationStatus;
|
||||
@@ -189,6 +198,7 @@ export async function ensureMatrixStartupVerification(params: {
|
||||
);
|
||||
const cooldownMs = cooldownHours * 60 * 60 * 1000;
|
||||
const nowMs = params.nowMs ?? Date.now();
|
||||
const attemptedAt = resolveStartupVerificationTimestamp(nowMs);
|
||||
const state = await readStartupVerificationState(statePath);
|
||||
const stateCooldownMs = resolveStateCooldownMs(state, cooldownMs);
|
||||
if (shouldHonorCooldown({ state, verification, stateCooldownMs, nowMs })) {
|
||||
@@ -208,7 +218,7 @@ export async function ensureMatrixStartupVerification(params: {
|
||||
await writeJsonFileAtomically(statePath, {
|
||||
userId: verification.userId,
|
||||
deviceId: verification.deviceId,
|
||||
attemptedAt: new Date(nowMs).toISOString(),
|
||||
attemptedAt,
|
||||
outcome: "requested",
|
||||
requestId: request.id,
|
||||
transactionId: request.transactionId,
|
||||
@@ -224,7 +234,7 @@ export async function ensureMatrixStartupVerification(params: {
|
||||
await writeJsonFileAtomically(statePath, {
|
||||
userId: verification.userId,
|
||||
deviceId: verification.deviceId,
|
||||
attemptedAt: new Date(nowMs).toISOString(),
|
||||
attemptedAt,
|
||||
outcome: "failed",
|
||||
error,
|
||||
} satisfies MatrixStartupVerificationState).catch(() => {});
|
||||
|
||||
@@ -166,6 +166,26 @@ describe("MatrixVerificationManager", () => {
|
||||
expect(summary.phaseName).toBe("requested");
|
||||
});
|
||||
|
||||
it("tracks verification requests when the process clock is outside the Date range", () => {
|
||||
const manager = new MatrixVerificationManager();
|
||||
const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
|
||||
|
||||
try {
|
||||
const summary = manager.trackVerificationRequest(
|
||||
new MockVerificationRequest({
|
||||
transactionId: "txn-invalid-clock",
|
||||
phase: VerificationPhase.Requested,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(summary.createdAt).toBe("1970-01-01T00:00:00.000Z");
|
||||
expect(summary.updatedAt).toBe("1970-01-01T00:00:00.000Z");
|
||||
expect(manager.listVerifications()).toHaveLength(1);
|
||||
} finally {
|
||||
dateNowSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses the same tracked id for repeated transaction IDs", () => {
|
||||
const manager = new MatrixVerificationManager();
|
||||
const first = new MockVerificationRequest({
|
||||
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
VerifierEvent,
|
||||
} from "matrix-js-sdk/lib/crypto-api/verification.js";
|
||||
import { VerificationMethod } from "matrix-js-sdk/lib/types.js";
|
||||
import {
|
||||
resolveDateTimestampMs,
|
||||
resolveTimestampMsToIsoString,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { formatMatrixErrorMessage } from "../errors.js";
|
||||
|
||||
export type MatrixVerificationMethod = "sas" | "show-qr" | "scan-qr";
|
||||
@@ -266,7 +270,7 @@ export class MatrixVerificationManager {
|
||||
}
|
||||
|
||||
private touchVerificationSession(session: MatrixVerificationSession): void {
|
||||
session.updatedAtMs = Date.now();
|
||||
session.updatedAtMs = resolveDateTimestampMs(Date.now());
|
||||
this.emitVerificationSummary(session);
|
||||
}
|
||||
|
||||
@@ -317,8 +321,8 @@ export class MatrixVerificationManager {
|
||||
hasReciprocateQr: Boolean(session.reciprocateQrCallbacks),
|
||||
completed: phase === VerificationPhase.Done,
|
||||
error: session.error,
|
||||
createdAt: new Date(session.createdAtMs).toISOString(),
|
||||
updatedAt: new Date(session.updatedAtMs).toISOString(),
|
||||
createdAt: resolveTimestampMsToIsoString(session.createdAtMs),
|
||||
updatedAt: resolveTimestampMsToIsoString(session.updatedAtMs),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -594,7 +598,7 @@ export class MatrixVerificationManager {
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const now = resolveDateTimestampMs(Date.now());
|
||||
const id = `verification-${++this.verificationSessionCounter}`;
|
||||
const session: MatrixVerificationSession = {
|
||||
id,
|
||||
|
||||
@@ -23,9 +23,3 @@ export function registerMatrixSubagentHooks(api: OpenClawPluginApi): void {
|
||||
return handleMatrixSubagentDeliveryTarget(event);
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
handleMatrixSubagentDeliveryTarget,
|
||||
handleMatrixSubagentEnded,
|
||||
handleMatrixSubagentSpawning,
|
||||
} from "./src/matrix/subagent-hooks.js";
|
||||
|
||||
@@ -342,7 +342,7 @@ export function renderMattermostModelsPickerView(params: {
|
||||
|
||||
const page = paginateItems(models, params.page);
|
||||
const rows: MattermostInteractiveButtonInput[][] = page.items.map((model) => {
|
||||
const isCurrent = current?.provider === provider && current.model === model;
|
||||
const isCurrent = current?.provider === provider && current?.model === model;
|
||||
return [
|
||||
buildButton({
|
||||
action: "select",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const fetchMattermostChannel = vi.hoisted(() => vi.fn());
|
||||
const fetchMattermostUser = vi.hoisted(() => vi.fn());
|
||||
@@ -32,6 +32,10 @@ describe("mattermost monitor resources", () => {
|
||||
buildButtonProps.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("downloads media, preserves auth headers, and infers media kind", async () => {
|
||||
const saveRemoteMedia = vi.fn(async () => ({
|
||||
path: "/tmp/file.png",
|
||||
@@ -120,6 +124,70 @@ describe("mattermost monitor resources", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not reuse cached lookups while the process clock is invalid", async () => {
|
||||
fetchMattermostChannel
|
||||
.mockResolvedValueOnce({ id: "chan-1", name: "old" })
|
||||
.mockResolvedValueOnce({ id: "chan-1", name: "fresh" })
|
||||
.mockResolvedValueOnce({ id: "chan-1", name: "recovered" });
|
||||
|
||||
const resources = createMattermostMonitorResources({
|
||||
accountId: "default",
|
||||
callbackUrl: "https://openclaw.test/callback",
|
||||
client: {} as never,
|
||||
logger: {},
|
||||
mediaMaxBytes: 1024,
|
||||
saveRemoteMedia: vi.fn(),
|
||||
mediaKindFromMime: () => "document",
|
||||
});
|
||||
|
||||
await expect(resources.resolveChannelInfo("chan-1")).resolves.toEqual({
|
||||
id: "chan-1",
|
||||
name: "old",
|
||||
});
|
||||
|
||||
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
|
||||
await expect(resources.resolveChannelInfo("chan-1")).resolves.toEqual({
|
||||
id: "chan-1",
|
||||
name: "fresh",
|
||||
});
|
||||
|
||||
vi.mocked(Date.now).mockReturnValue(1_000);
|
||||
await expect(resources.resolveChannelInfo("chan-1")).resolves.toEqual({
|
||||
id: "chan-1",
|
||||
name: "recovered",
|
||||
});
|
||||
|
||||
expect(fetchMattermostChannel).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("does not cache lookups when cache expiry would exceed the Date range", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
|
||||
fetchMattermostUser
|
||||
.mockResolvedValueOnce({ id: "user-1", username: "first" })
|
||||
.mockResolvedValueOnce({ id: "user-1", username: "second" });
|
||||
|
||||
const resources = createMattermostMonitorResources({
|
||||
accountId: "default",
|
||||
callbackUrl: "https://openclaw.test/callback",
|
||||
client: {} as never,
|
||||
logger: {},
|
||||
mediaMaxBytes: 1024,
|
||||
saveRemoteMedia: vi.fn(),
|
||||
mediaKindFromMime: () => "document",
|
||||
});
|
||||
|
||||
await expect(resources.resolveUserInfo("user-1")).resolves.toEqual({
|
||||
id: "user-1",
|
||||
username: "first",
|
||||
});
|
||||
await expect(resources.resolveUserInfo("user-1")).resolves.toEqual({
|
||||
id: "user-1",
|
||||
username: "second",
|
||||
});
|
||||
|
||||
expect(fetchMattermostUser).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("proxies typing indicators to the mattermost client helper", async () => {
|
||||
const client = {} as never;
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
fetchMattermostChannel,
|
||||
@@ -50,6 +54,35 @@ export function createMattermostMonitorResources(params: {
|
||||
const channelCache = new Map<string, { value: MattermostChannel | null; expiresAt: number }>();
|
||||
const userCache = new Map<string, { value: MattermostUser | null; expiresAt: number }>();
|
||||
|
||||
const getCachedValue = <T>(
|
||||
cache: Map<string, { value: T | null; expiresAt: number }>,
|
||||
key: string,
|
||||
nowMs: number | undefined,
|
||||
): T | null | undefined => {
|
||||
const cached = cache.get(key);
|
||||
if (!cached) {
|
||||
return undefined;
|
||||
}
|
||||
if (nowMs !== undefined && cached.expiresAt > nowMs) {
|
||||
return cached.value;
|
||||
}
|
||||
cache.delete(key);
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const setCachedValue = <T>(
|
||||
cache: Map<string, { value: T | null; expiresAt: number }>,
|
||||
key: string,
|
||||
value: T | null,
|
||||
ttlMs: number,
|
||||
rawNowMs: number,
|
||||
): void => {
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(ttlMs, { nowMs: rawNowMs });
|
||||
if (expiresAt !== undefined) {
|
||||
cache.set(key, { value, expiresAt });
|
||||
}
|
||||
};
|
||||
|
||||
const resolveMattermostMedia = async (
|
||||
fileIds?: string[] | null,
|
||||
): Promise<MattermostMediaInfo[]> => {
|
||||
@@ -89,45 +122,35 @@ export function createMattermostMonitorResources(params: {
|
||||
};
|
||||
|
||||
const resolveChannelInfo = async (channelId: string): Promise<MattermostChannel | null> => {
|
||||
const cached = channelCache.get(channelId);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.value;
|
||||
const rawNow = Date.now();
|
||||
const cached = getCachedValue(channelCache, channelId, asDateTimestampMs(rawNow));
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
try {
|
||||
const info = await fetchMattermostChannel(client, channelId);
|
||||
channelCache.set(channelId, {
|
||||
value: info,
|
||||
expiresAt: Date.now() + CHANNEL_CACHE_TTL_MS,
|
||||
});
|
||||
setCachedValue(channelCache, channelId, info, CHANNEL_CACHE_TTL_MS, rawNow);
|
||||
return info;
|
||||
} catch (err) {
|
||||
logger.debug?.(`mattermost: channel lookup failed: ${String(err)}`);
|
||||
channelCache.set(channelId, {
|
||||
value: null,
|
||||
expiresAt: Date.now() + CHANNEL_CACHE_TTL_MS,
|
||||
});
|
||||
setCachedValue(channelCache, channelId, null, CHANNEL_CACHE_TTL_MS, rawNow);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveUserInfo = async (userId: string): Promise<MattermostUser | null> => {
|
||||
const cached = userCache.get(userId);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.value;
|
||||
const rawNow = Date.now();
|
||||
const cached = getCachedValue(userCache, userId, asDateTimestampMs(rawNow));
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
try {
|
||||
const info = await fetchMattermostUser(client, userId);
|
||||
userCache.set(userId, {
|
||||
value: info,
|
||||
expiresAt: Date.now() + USER_CACHE_TTL_MS,
|
||||
});
|
||||
setCachedValue(userCache, userId, info, USER_CACHE_TTL_MS, rawNow);
|
||||
return info;
|
||||
} catch (err) {
|
||||
logger.debug?.(`mattermost: user lookup failed: ${String(err)}`);
|
||||
userCache.set(userId, {
|
||||
value: null,
|
||||
expiresAt: Date.now() + USER_CACHE_TTL_MS,
|
||||
});
|
||||
setCachedValue(userCache, userId, null, USER_CACHE_TTL_MS, rawNow);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
addMattermostReaction,
|
||||
removeMattermostReaction,
|
||||
@@ -15,6 +15,10 @@ describe("mattermost reactions", () => {
|
||||
resetMattermostReactionBotUserCacheForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function addReactionWithFetch(fetchMock: typeof fetch) {
|
||||
return addMattermostReaction({
|
||||
cfg: createMattermostTestConfig(),
|
||||
@@ -104,4 +108,94 @@ describe("mattermost reactions", () => {
|
||||
expect(removeResult).toEqual({ ok: true });
|
||||
expect(usersMeCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not reuse cached bot user ids while the process clock is invalid", async () => {
|
||||
const cfg = createMattermostTestConfig();
|
||||
const firstFetch = createMattermostReactionFetchMock({
|
||||
mode: "add",
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
userId: "BOT_OLD",
|
||||
});
|
||||
const secondFetch = createMattermostReactionFetchMock({
|
||||
mode: "add",
|
||||
postId: "POST2",
|
||||
emojiName: "thumbsup",
|
||||
userId: "BOT_FRESH",
|
||||
});
|
||||
const thirdFetch = createMattermostReactionFetchMock({
|
||||
mode: "add",
|
||||
postId: "POST3",
|
||||
emojiName: "thumbsup",
|
||||
userId: "BOT_RECOVERED",
|
||||
});
|
||||
|
||||
await expect(
|
||||
addMattermostReaction({
|
||||
cfg,
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
fetchImpl: firstFetch,
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
|
||||
await expect(
|
||||
addMattermostReaction({
|
||||
cfg,
|
||||
postId: "POST2",
|
||||
emojiName: "thumbsup",
|
||||
fetchImpl: secondFetch,
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
vi.mocked(Date.now).mockReturnValue(1_000);
|
||||
await expect(
|
||||
addMattermostReaction({
|
||||
cfg,
|
||||
postId: "POST3",
|
||||
emojiName: "thumbsup",
|
||||
fetchImpl: thirdFetch,
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
const usersMeCalls = [
|
||||
...firstFetch.mock.calls,
|
||||
...secondFetch.mock.calls,
|
||||
...thirdFetch.mock.calls,
|
||||
].filter((call) => requestUrl(call[0]).endsWith("/api/v4/users/me"));
|
||||
expect(usersMeCalls).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("does not cache bot user ids when cache expiry would exceed the Date range", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
|
||||
const cfg = createMattermostTestConfig();
|
||||
const fetchMock = createMattermostReactionFetchMock({
|
||||
mode: "both",
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
});
|
||||
|
||||
await expect(
|
||||
addMattermostReaction({
|
||||
cfg,
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
fetchImpl: fetchMock,
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
await expect(
|
||||
removeMattermostReaction({
|
||||
cfg,
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
fetchImpl: fetchMock,
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
const usersMeCalls = fetchMock.mock.calls.filter((call) =>
|
||||
requestUrl(call[0]).endsWith("/api/v4/users/me"),
|
||||
);
|
||||
expect(usersMeCalls).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { resolveMattermostAccount } from "./accounts.js";
|
||||
import {
|
||||
@@ -26,16 +30,24 @@ async function resolveBotUserId(
|
||||
client: MattermostClient,
|
||||
cacheKey: string,
|
||||
): Promise<string | null> {
|
||||
const rawNow = Date.now();
|
||||
const now = asDateTimestampMs(rawNow);
|
||||
const cached = botUserIdCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.userId;
|
||||
if (cached) {
|
||||
if (now !== undefined && cached.expiresAt > now) {
|
||||
return cached.userId;
|
||||
}
|
||||
botUserIdCache.delete(cacheKey);
|
||||
}
|
||||
const me = await fetchMattermostMe(client);
|
||||
const userId = me?.id?.trim();
|
||||
if (!userId) {
|
||||
return null;
|
||||
}
|
||||
botUserIdCache.set(cacheKey, { userId, expiresAt: Date.now() + BOT_USER_CACHE_TTL_MS });
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(BOT_USER_CACHE_TTL_MS, { nowMs: rawNow });
|
||||
if (expiresAt !== undefined) {
|
||||
botUserIdCache.set(cacheKey, { userId, expiresAt });
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
|
||||
@@ -458,6 +458,119 @@ describe("slash-http", () => {
|
||||
expect(client.requests).toEqual(["/commands/cmd-1"]);
|
||||
});
|
||||
|
||||
it("does not cache failed command validation when the expiry would exceed a valid Date", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(8_640_000_000_000_000));
|
||||
try {
|
||||
const registeredCommand = createRegisteredCommand({ token: "old-token" });
|
||||
const client = createCommandLookupClient({
|
||||
command: {
|
||||
id: "cmd-1",
|
||||
token: "new-token",
|
||||
team_id: "t1",
|
||||
trigger: "oc_status",
|
||||
method: MATTERMOST_SLASH_POST_METHOD,
|
||||
url: "https://gateway.example.com/slash",
|
||||
auto_complete: true,
|
||||
delete_at: 0,
|
||||
},
|
||||
});
|
||||
const payload = {
|
||||
token: "old-token",
|
||||
team_id: "t1",
|
||||
channel_id: "c1",
|
||||
user_id: "u1",
|
||||
command: "/oc_status",
|
||||
text: "",
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateMattermostSlashCommandToken({
|
||||
accountId: "default",
|
||||
client,
|
||||
registeredCommand,
|
||||
payload,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
await expect(
|
||||
validateMattermostSlashCommandToken({
|
||||
accountId: "default",
|
||||
client,
|
||||
registeredCommand,
|
||||
payload,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(client.requests).toEqual(["/commands/cmd-1", "/commands/cmd-1"]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("drops exhausted validation lookup buckets when the current clock is invalid", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-04-27T00:00:00Z"));
|
||||
try {
|
||||
const registeredCommand = createRegisteredCommand({ token: "valid-token" });
|
||||
const command = {
|
||||
id: "cmd-1",
|
||||
token: "valid-token",
|
||||
team_id: "t1",
|
||||
trigger: "oc_status",
|
||||
method: MATTERMOST_SLASH_POST_METHOD,
|
||||
url: "https://gateway.example.com/slash",
|
||||
auto_complete: true,
|
||||
delete_at: 0,
|
||||
};
|
||||
const client = createCommandLookupClient({ command });
|
||||
const payload = {
|
||||
token: "valid-token",
|
||||
team_id: "t1",
|
||||
channel_id: "c1",
|
||||
user_id: "u1",
|
||||
command: "/oc_status",
|
||||
text: "",
|
||||
};
|
||||
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
await expect(
|
||||
validateMattermostSlashCommandToken({
|
||||
accountId: "default",
|
||||
client,
|
||||
registeredCommand,
|
||||
payload,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
}
|
||||
await expect(
|
||||
validateMattermostSlashCommandToken({
|
||||
accountId: "default",
|
||||
client,
|
||||
registeredCommand,
|
||||
payload,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
|
||||
const dateNow = vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
|
||||
try {
|
||||
await expect(
|
||||
validateMattermostSlashCommandToken({
|
||||
accountId: "default",
|
||||
client,
|
||||
registeredCommand,
|
||||
payload,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
} finally {
|
||||
dateNow.mockRestore();
|
||||
}
|
||||
|
||||
expect(client.requests).toHaveLength(21);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("scopes validation cache entries by account", async () => {
|
||||
const registeredCommand = createRegisteredCommand();
|
||||
const clientA = createCommandLookupClient({
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
*/
|
||||
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import type { ResolvedMattermostAccount } from "../mattermost/accounts.js";
|
||||
@@ -209,8 +213,14 @@ export function clearMattermostSlashCommandValidationCacheForAccount(accountId:
|
||||
}
|
||||
|
||||
function sweepCommandValidationFailureCache(now = Date.now()): void {
|
||||
const validNow = asDateTimestampMs(now);
|
||||
if (validNow === undefined) {
|
||||
commandValidationFailureCache.clear();
|
||||
return;
|
||||
}
|
||||
for (const [key, entry] of commandValidationFailureCache) {
|
||||
if (entry.expiresAt <= now) {
|
||||
const expiresAt = asDateTimestampMs(entry.expiresAt);
|
||||
if (expiresAt === undefined || expiresAt <= validNow) {
|
||||
commandValidationFailureCache.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -225,11 +235,16 @@ function sweepCommandValidationFailureCache(now = Date.now()): void {
|
||||
|
||||
function hasCachedCommandValidationFailure(key: string, now = Date.now()): boolean {
|
||||
sweepCommandValidationFailureCache(now);
|
||||
const validNow = asDateTimestampMs(now);
|
||||
if (validNow === undefined) {
|
||||
return false;
|
||||
}
|
||||
const cached = commandValidationFailureCache.get(key);
|
||||
if (!cached) {
|
||||
return false;
|
||||
}
|
||||
if (cached.expiresAt > now) {
|
||||
const expiresAt = asDateTimestampMs(cached.expiresAt);
|
||||
if (expiresAt !== undefined && expiresAt > validNow) {
|
||||
return true;
|
||||
}
|
||||
commandValidationFailureCache.delete(key);
|
||||
@@ -237,17 +252,31 @@ function hasCachedCommandValidationFailure(key: string, now = Date.now()): boole
|
||||
}
|
||||
|
||||
function cacheCommandValidationFailure(key: string, accountId: string): void {
|
||||
sweepCommandValidationFailureCache();
|
||||
const now = Date.now();
|
||||
sweepCommandValidationFailureCache(now);
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(COMMAND_VALIDATION_FAILURE_CACHE_MS, {
|
||||
nowMs: now,
|
||||
});
|
||||
if (expiresAt === undefined) {
|
||||
commandValidationFailureCache.delete(key);
|
||||
return;
|
||||
}
|
||||
commandValidationFailureCache.set(key, {
|
||||
accountId,
|
||||
expiresAt: Date.now() + COMMAND_VALIDATION_FAILURE_CACHE_MS,
|
||||
expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
function sweepCommandValidationLookupRateLimit(now = Date.now()): void {
|
||||
const validNow = asDateTimestampMs(now);
|
||||
if (validNow === undefined) {
|
||||
commandValidationLookupRateLimit.clear();
|
||||
return;
|
||||
}
|
||||
const staleAfterMs = COMMAND_VALIDATION_LOOKUP_REFILL_MS * COMMAND_VALIDATION_LOOKUP_BURST * 2;
|
||||
for (const [key, entry] of commandValidationLookupRateLimit) {
|
||||
if (now - entry.updatedAt > staleAfterMs) {
|
||||
const updatedAt = asDateTimestampMs(entry.updatedAt);
|
||||
if (updatedAt === undefined || validNow - updatedAt > staleAfterMs) {
|
||||
commandValidationLookupRateLimit.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -265,7 +294,12 @@ function reserveCommandValidationLookup(params: {
|
||||
accountId: string;
|
||||
now?: number;
|
||||
}): { allowed: true } | { allowed: false; shouldLog: boolean } {
|
||||
const now = params.now ?? Date.now();
|
||||
const rawNow = params.now ?? Date.now();
|
||||
const now = asDateTimestampMs(rawNow);
|
||||
if (now === undefined) {
|
||||
commandValidationLookupRateLimit.clear();
|
||||
return { allowed: true };
|
||||
}
|
||||
sweepCommandValidationLookupRateLimit(now);
|
||||
const existing = commandValidationLookupRateLimit.get(params.key);
|
||||
if (!existing) {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { writeDailyDreamingPhaseBlock, writeDeepDreamingReport } from "./dreaming-markdown.js";
|
||||
import { createMemoryCoreTestHarness } from "./test-helpers.js";
|
||||
|
||||
const { createTempWorkspace } = createMemoryCoreTestHarness();
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function expectPathMissing(targetPath: string): Promise<void> {
|
||||
const error = await fs.access(targetPath).then(
|
||||
() => undefined,
|
||||
@@ -55,6 +59,25 @@ describe("dreaming markdown storage", () => {
|
||||
expect(content).toContain("- Candidate: remember the API key is fake");
|
||||
});
|
||||
|
||||
it("falls back when the injected timestamp is outside Date range", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(Date.UTC(2026, 4, 30, 12, 0, 0));
|
||||
const workspaceDir = await createTempWorkspace("openclaw-dreaming-markdown-");
|
||||
|
||||
const result = await writeDailyDreamingPhaseBlock({
|
||||
workspaceDir,
|
||||
phase: "light",
|
||||
bodyLines: ["- Candidate: bounded fallback"],
|
||||
nowMs: 8_640_000_000_000_001,
|
||||
timezone,
|
||||
storage: {
|
||||
mode: "inline",
|
||||
separateReports: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(requireInlinePath(result)).toBe(path.join(workspaceDir, "memory", "2026-05-30.md"));
|
||||
});
|
||||
|
||||
it("keeps multiple inline phases in the shared daily memory file", async () => {
|
||||
const workspaceDir = await createTempWorkspace("openclaw-dreaming-markdown-");
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
replaceManagedMarkdownBlock,
|
||||
withTrailingNewline,
|
||||
} from "openclaw/plugin-sdk/memory-host-markdown";
|
||||
import { resolveMemoryCoreNowMs, resolveMemoryCoreTimestamp } from "./time.js";
|
||||
|
||||
const DAILY_PHASE_HEADINGS: Record<Exclude<MemoryDreamingPhaseName, "deep">, string> = {
|
||||
light: "## Light Sleep",
|
||||
@@ -63,7 +64,7 @@ export async function writeDailyDreamingPhaseBlock(params: {
|
||||
timezone?: string;
|
||||
storage: MemoryDreamingStorageConfig;
|
||||
}): Promise<{ inlinePath?: string; reportPath?: string }> {
|
||||
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
|
||||
const nowMs = resolveMemoryCoreNowMs(params.nowMs);
|
||||
const body = params.bodyLines.length > 0 ? params.bodyLines.join("\n") : "- No notable updates.";
|
||||
let inlinePath: string | undefined;
|
||||
let reportPath: string | undefined;
|
||||
@@ -107,7 +108,7 @@ export async function writeDailyDreamingPhaseBlock(params: {
|
||||
|
||||
await appendMemoryHostEvent(params.workspaceDir, {
|
||||
type: "memory.dream.completed",
|
||||
timestamp: new Date(nowMs).toISOString(),
|
||||
timestamp: resolveMemoryCoreTimestamp(nowMs),
|
||||
phase: params.phase,
|
||||
...(inlinePath ? { inlinePath } : {}),
|
||||
...(reportPath ? { reportPath } : {}),
|
||||
@@ -131,14 +132,14 @@ export async function writeDeepDreamingReport(params: {
|
||||
if (!shouldWriteSeparate(params.storage)) {
|
||||
return undefined;
|
||||
}
|
||||
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
|
||||
const nowMs = resolveMemoryCoreNowMs(params.nowMs);
|
||||
const reportPath = resolveSeparateReportPath(params.workspaceDir, "deep", nowMs, params.timezone);
|
||||
await fs.mkdir(path.dirname(reportPath), { recursive: true });
|
||||
const body = params.bodyLines.length > 0 ? params.bodyLines.join("\n") : "- No durable changes.";
|
||||
await fs.writeFile(reportPath, `# Deep Sleep\n\n${body}\n`, "utf-8");
|
||||
await appendMemoryHostEvent(params.workspaceDir, {
|
||||
type: "memory.dream.completed",
|
||||
timestamp: new Date(nowMs).toISOString(),
|
||||
timestamp: resolveMemoryCoreTimestamp(nowMs),
|
||||
phase: "deep",
|
||||
reportPath,
|
||||
lineCount: params.bodyLines.length,
|
||||
|
||||
18
extensions/memory-core/src/flush-plan.test.ts
Normal file
18
extensions/memory-core/src/flush-plan.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildMemoryFlushPlan } from "./flush-plan.js";
|
||||
|
||||
describe("buildMemoryFlushPlan", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("falls back when the injected timestamp is outside Date range", () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(Date.UTC(2026, 4, 30, 12, 0, 0));
|
||||
|
||||
const plan = buildMemoryFlushPlan({
|
||||
nowMs: 8_640_000_000_000_001,
|
||||
});
|
||||
|
||||
expect(plan?.relativePath).toBe("memory/2026-05-30.md");
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type MemoryFlushPlan,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-runtime-core";
|
||||
import { resolveMemoryCoreNowMs } from "./time.js";
|
||||
|
||||
export const DEFAULT_MEMORY_FLUSH_SOFT_TOKENS = 4000;
|
||||
export const DEFAULT_MEMORY_FLUSH_FORCE_TRANSCRIPT_BYTES = 2 * 1024 * 1024;
|
||||
@@ -53,7 +54,7 @@ function formatDateStampInTimezone(nowMs: number, timezone: string): string {
|
||||
if (year && month && day) {
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
return new Date(nowMs).toISOString().slice(0, 10);
|
||||
return new Date(resolveMemoryCoreNowMs(nowMs)).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function normalizeNonNegativeInt(value: unknown): number | null {
|
||||
@@ -99,7 +100,7 @@ export function buildMemoryFlushPlan(
|
||||
} = {},
|
||||
): MemoryFlushPlan | null {
|
||||
const resolved = params;
|
||||
const nowMs = Number.isFinite(resolved.nowMs) ? (resolved.nowMs as number) : Date.now();
|
||||
const nowMs = resolveMemoryCoreNowMs(resolved.nowMs);
|
||||
const cfg = resolved.cfg;
|
||||
const defaults = cfg?.agents?.defaults?.compaction?.memoryFlush;
|
||||
if (defaults?.enabled === false) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/memory-host-events", () => ({
|
||||
appendMemoryHostEvent: vi.fn(async () => {}),
|
||||
@@ -40,6 +40,10 @@ describe("short-term promotion", () => {
|
||||
await fs.rm(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function withTempWorkspace(run: (workspaceDir: string) => Promise<void>) {
|
||||
const workspaceDir = path.join(fixtureRoot, `case-${caseId++}`);
|
||||
await fs.mkdir(path.join(workspaceDir, "memory", ".dreams"), { recursive: true });
|
||||
@@ -89,19 +93,31 @@ describe("short-term promotion", () => {
|
||||
return candidate.promotedAt;
|
||||
}
|
||||
|
||||
async function readRecallStoreEntries(
|
||||
workspaceDir: string,
|
||||
): Promise<
|
||||
async function readRecallStoreEntries(workspaceDir: string): Promise<
|
||||
Record<
|
||||
string,
|
||||
{ claimHash?: unknown; recallCount?: unknown; snippet?: unknown; totalScore?: unknown }
|
||||
{
|
||||
claimHash?: unknown;
|
||||
firstRecalledAt?: unknown;
|
||||
lastRecalledAt?: unknown;
|
||||
recallCount?: unknown;
|
||||
snippet?: unknown;
|
||||
totalScore?: unknown;
|
||||
}
|
||||
>
|
||||
> {
|
||||
const raw = await fs.readFile(resolveShortTermRecallStorePath(workspaceDir), "utf-8");
|
||||
const store = JSON.parse(raw) as {
|
||||
entries?: Record<
|
||||
string,
|
||||
{ claimHash?: unknown; recallCount?: unknown; snippet?: unknown; totalScore?: unknown }
|
||||
{
|
||||
claimHash?: unknown;
|
||||
firstRecalledAt?: unknown;
|
||||
lastRecalledAt?: unknown;
|
||||
recallCount?: unknown;
|
||||
snippet?: unknown;
|
||||
totalScore?: unknown;
|
||||
}
|
||||
>;
|
||||
};
|
||||
return store.entries ?? {};
|
||||
@@ -160,6 +176,35 @@ describe("short-term promotion", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back when the injected recall timestamp is outside Date range", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(Date.UTC(2026, 4, 30, 12, 0, 0));
|
||||
await withTempWorkspace(async (workspaceDir) => {
|
||||
const notePath = await writeDailyMemoryNote(workspaceDir, "2026-05-30", [
|
||||
"Bounded recall timestamp note.",
|
||||
]);
|
||||
|
||||
await recordShortTermRecalls({
|
||||
workspaceDir,
|
||||
query: "bounded recall",
|
||||
nowMs: 8_640_000_000_000_001,
|
||||
results: [
|
||||
{
|
||||
path: path.relative(workspaceDir, notePath).replaceAll("\\", "/"),
|
||||
source: "memory",
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
score: 0.9,
|
||||
snippet: "Bounded recall timestamp note.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const [entry] = Object.values(await readRecallStoreEntries(workspaceDir));
|
||||
expect(entry?.firstRecalledAt).toBe("2026-05-30T12:00:00.000Z");
|
||||
expect(entry?.lastRecalledAt).toBe("2026-05-30T12:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
it("records short-term recall for notes stored in spaced and Unicode memory subdirectories", async () => {
|
||||
await withTempWorkspace(async (workspaceDir) => {
|
||||
const spacedPath = await writeDailyMemoryNoteInSubdir(
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "./concept-vocabulary.js";
|
||||
import { asRecord } from "./dreaming-shared.js";
|
||||
import { compactMemoryForBudget, DEFAULT_MEMORY_FILE_MAX_CHARS } from "./memory-budget.js";
|
||||
import { resolveMemoryCoreNowMs, resolveMemoryCoreTimestamp } from "./time.js";
|
||||
|
||||
const SHORT_TERM_PATH_RE = /(?:^|\/)memory\/(?:[^/]+\/)*(\d{4})-(\d{2})-(\d{2})(?:-[^/]+)?\.md$/;
|
||||
const DREAMING_MEMORY_PATH_RE = /(?:^|\/)memory\/dreaming\//;
|
||||
@@ -1052,8 +1053,8 @@ export async function recordShortTermRecalls(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
|
||||
const nowIso = new Date(nowMs).toISOString();
|
||||
const nowMs = resolveMemoryCoreNowMs(params.nowMs);
|
||||
const nowIso = resolveMemoryCoreTimestamp(nowMs);
|
||||
const signalType = params.signalType ?? "recall";
|
||||
const queryHash = hashQuery(query);
|
||||
const todayBucket =
|
||||
@@ -1199,8 +1200,8 @@ export async function recordGroundedShortTermCandidates(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
|
||||
const nowIso = new Date(nowMs).toISOString();
|
||||
const nowMs = resolveMemoryCoreNowMs(params.nowMs);
|
||||
const nowIso = resolveMemoryCoreTimestamp(nowMs);
|
||||
const fallbackDayBucket = formatMemoryDreamingDay(nowMs, params.timezone);
|
||||
await withShortTermLock(workspaceDir, async () => {
|
||||
const store = await readStore(workspaceDir, nowIso);
|
||||
@@ -1281,8 +1282,8 @@ export async function recordDreamingPhaseSignals(params: {
|
||||
if (keys.length === 0) {
|
||||
return;
|
||||
}
|
||||
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
|
||||
const nowIso = new Date(nowMs).toISOString();
|
||||
const nowMs = resolveMemoryCoreNowMs(params.nowMs);
|
||||
const nowIso = resolveMemoryCoreTimestamp(nowMs);
|
||||
|
||||
await withShortTermLock(workspaceDir, async () => {
|
||||
const [store, phaseSignals] = await Promise.all([
|
||||
@@ -1334,8 +1335,8 @@ export async function recordRemConsideredPhaseSignals(params: {
|
||||
if (keys.length === 0) {
|
||||
return;
|
||||
}
|
||||
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
|
||||
const nowIso = new Date(nowMs).toISOString();
|
||||
const nowMs = resolveMemoryCoreNowMs(params.nowMs);
|
||||
const nowIso = resolveMemoryCoreTimestamp(nowMs);
|
||||
|
||||
await withShortTermLock(workspaceDir, async () => {
|
||||
const [store, phaseSignals] = await Promise.all([
|
||||
@@ -1376,8 +1377,8 @@ export async function readLightStagedKeys(params: {
|
||||
if (!workspaceDir) {
|
||||
return new Set();
|
||||
}
|
||||
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
|
||||
const nowIso = new Date(nowMs).toISOString();
|
||||
const nowMs = resolveMemoryCoreNowMs(params.nowMs);
|
||||
const nowIso = resolveMemoryCoreTimestamp(nowMs);
|
||||
const store = await readPhaseSignalStore(workspaceDir, nowIso);
|
||||
const keys = new Set<string>();
|
||||
for (const [key, entry] of Object.entries(store.entries)) {
|
||||
@@ -1409,8 +1410,8 @@ export async function rankShortTermPromotionCandidates(
|
||||
return [];
|
||||
}
|
||||
|
||||
const nowMs = Number.isFinite(options.nowMs) ? (options.nowMs as number) : Date.now();
|
||||
const nowIso = new Date(nowMs).toISOString();
|
||||
const nowMs = resolveMemoryCoreNowMs(options.nowMs);
|
||||
const nowIso = resolveMemoryCoreTimestamp(nowMs);
|
||||
const minScore = toFiniteScore(options.minScore, DEFAULT_PROMOTION_MIN_SCORE);
|
||||
const minRecallCount = toFiniteNonNegativeInt(
|
||||
options.minRecallCount,
|
||||
@@ -1550,8 +1551,8 @@ export async function readShortTermRecallEntries(params: {
|
||||
if (!workspaceDir) {
|
||||
return [];
|
||||
}
|
||||
const nowMs = Number.isFinite(params.nowMs) ? (params.nowMs as number) : Date.now();
|
||||
const nowIso = new Date(nowMs).toISOString();
|
||||
const nowMs = resolveMemoryCoreNowMs(params.nowMs);
|
||||
const nowIso = resolveMemoryCoreTimestamp(nowMs);
|
||||
const store = await readStore(workspaceDir, nowIso);
|
||||
return Object.values(store.entries).filter(
|
||||
(entry): entry is ShortTermRecallEntry =>
|
||||
@@ -1838,8 +1839,8 @@ export async function applyShortTermPromotions(
|
||||
options: ApplyShortTermPromotionsOptions,
|
||||
): Promise<ApplyShortTermPromotionsResult> {
|
||||
const workspaceDir = options.workspaceDir.trim();
|
||||
const nowMs = Number.isFinite(options.nowMs) ? (options.nowMs as number) : Date.now();
|
||||
const nowIso = new Date(nowMs).toISOString();
|
||||
const nowMs = resolveMemoryCoreNowMs(options.nowMs);
|
||||
const nowIso = resolveMemoryCoreTimestamp(nowMs);
|
||||
const limit = Number.isFinite(options.limit)
|
||||
? Math.max(0, Math.floor(options.limit as number))
|
||||
: options.candidates.length;
|
||||
|
||||
10
extensions/memory-core/src/time.ts
Normal file
10
extensions/memory-core/src/time.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
|
||||
|
||||
export function resolveMemoryCoreNowMs(nowMs: unknown): number {
|
||||
return timestampMsToIsoString(nowMs) === undefined ? Date.now() : (nowMs as number);
|
||||
}
|
||||
|
||||
export function resolveMemoryCoreTimestamp(nowMs: unknown): string {
|
||||
const timestampMs = resolveMemoryCoreNowMs(nowMs);
|
||||
return timestampMsToIsoString(timestampMs) ?? new Date().toISOString();
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
WIKI_RELATED_END_MARKER,
|
||||
WIKI_RELATED_START_MARKER,
|
||||
} from "./markdown.js";
|
||||
import { resolveMemoryWikiTimestamp } from "./time.js";
|
||||
import { initializeMemoryWikiVault } from "./vault.js";
|
||||
|
||||
const CHATGPT_PREFERENCE_SIGNAL_RE =
|
||||
@@ -746,7 +747,7 @@ export async function importChatGptConversations(params: {
|
||||
let updatedCount = 0;
|
||||
let skippedCount = 0;
|
||||
let runId: string | undefined;
|
||||
const nowIso = new Date(params.nowMs ?? Date.now()).toISOString();
|
||||
const nowIso = resolveMemoryWikiTimestamp(params.nowMs);
|
||||
|
||||
let importRunRecord: ChatGptImportRunRecord | undefined;
|
||||
let importRunDir = "";
|
||||
|
||||
@@ -5,6 +5,7 @@ import { compileMemoryWikiVault } from "./compile.js";
|
||||
import type { ResolvedMemoryWikiConfig } from "./config.js";
|
||||
import { appendMemoryWikiLog } from "./log.js";
|
||||
import { renderMarkdownFence, renderWikiMarkdown, slugifyWikiSegment } from "./markdown.js";
|
||||
import { resolveMemoryWikiTimestamp } from "./time.js";
|
||||
import { initializeMemoryWikiVault } from "./vault.js";
|
||||
|
||||
type IngestMemoryWikiSourceResult = {
|
||||
@@ -48,7 +49,7 @@ export async function ingestMemoryWikiSource(params: {
|
||||
const pageRelativePath = path.join("sources", `${slug}.md`);
|
||||
const pagePath = path.join(params.config.vault.path, pageRelativePath);
|
||||
const created = !(await pathExists(pagePath));
|
||||
const timestamp = new Date(params.nowMs ?? Date.now()).toISOString();
|
||||
const timestamp = resolveMemoryWikiTimestamp(params.nowMs);
|
||||
|
||||
const markdown = renderWikiMarkdown({
|
||||
frontmatter: {
|
||||
|
||||
48
extensions/memory-wiki/src/source-page-shared.test.ts
Normal file
48
extensions/memory-wiki/src/source-page-shared.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { writeImportedSourcePage } from "./source-page-shared.js";
|
||||
|
||||
describe("writeImportedSourcePage", () => {
|
||||
let suiteRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
suiteRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-source-page-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await fs.rm(suiteRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("falls back when the source mtime is outside the Date range", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-01T12:00:00.000Z"));
|
||||
const sourcePath = path.join(suiteRoot, "source.txt");
|
||||
await fs.writeFile(sourcePath, "source body", "utf8");
|
||||
const state: Parameters<typeof writeImportedSourcePage>[0]["state"] = {
|
||||
entries: {},
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const result = await writeImportedSourcePage({
|
||||
vaultRoot: suiteRoot,
|
||||
syncKey: "unsafe:source",
|
||||
sourcePath,
|
||||
sourceUpdatedAtMs: 8_700_000_000_000_000,
|
||||
sourceSize: 11,
|
||||
renderFingerprint: "fingerprint",
|
||||
pagePath: "pages/source.md",
|
||||
group: "unsafe-local",
|
||||
state,
|
||||
buildRendered: (raw, updatedAt) => `updatedAt: ${updatedAt}\n${raw}`,
|
||||
});
|
||||
|
||||
await expect(fs.readFile(path.join(suiteRoot, "pages/source.md"), "utf8")).resolves.toBe(
|
||||
"updatedAt: 2026-05-01T12:00:00.000Z\nsource body",
|
||||
);
|
||||
expect(result).toEqual({ pagePath: "pages/source.md", changed: true, created: true });
|
||||
expect(state.entries["unsafe:source"]?.sourceUpdatedAtMs).toBe(8_700_000_000_000_000);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user