mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-07 06:21:32 +08:00
Compare commits
107 Commits
feat/code-
...
codex/tele
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
367d1e7263 | ||
|
|
e4a6bd7dc3 | ||
|
|
fb3463b337 | ||
|
|
ad826c80a5 | ||
|
|
522da25932 | ||
|
|
d44621b544 | ||
|
|
6fe0539992 | ||
|
|
283238fd77 | ||
|
|
5568ecc7aa | ||
|
|
743d5378d2 | ||
|
|
63a3676d3c | ||
|
|
2a39c217c8 | ||
|
|
a2fc4ca7ad | ||
|
|
8eeaa45729 | ||
|
|
4d13055ca5 | ||
|
|
bfceffa2f7 | ||
|
|
031583e8f5 | ||
|
|
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 |
10
.github/workflows/ci.yml
vendored
10
.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-
|
||||
|
||||
@@ -1425,11 +1425,17 @@ jobs:
|
||||
-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 \
|
||||
|
||||
@@ -36,6 +36,8 @@ Docs: https://docs.openclaw.ai
|
||||
- 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.
|
||||
@@ -44,6 +46,8 @@ Docs: https://docs.openclaw.ai
|
||||
- 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.
|
||||
|
||||
@@ -318,10 +318,11 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
- `channels.telegram.streaming` is `off | partial | block | progress` (default: `partial`)
|
||||
- `progress` keeps one editable status draft for tool progress, clears it at completion, and sends the final answer as a normal message
|
||||
- `streaming.preview.toolProgress` controls whether tool/progress updates reuse the same edited preview message (default: `true` when preview streaming is active)
|
||||
- `streaming.progress.commentary` (default `false`) opts into Codex preamble/commentary text in the temporary progress draft. Commentary is cleaned before display, stays transient, and does not change final answer delivery.
|
||||
- `streaming.preview.commandText` controls command/exec detail inside those tool-progress lines: `raw` (default, preserves released behavior) or `status` (tool label only)
|
||||
- legacy `channels.telegram.streamMode` and boolean `streaming` values are detected; run `openclaw doctor --fix` to migrate them to `channels.telegram.streaming.mode`
|
||||
|
||||
Tool-progress preview updates are the short status lines shown while tools run, for example command execution, file reads, planning updates, patch summaries, or Codex preamble/commentary text in Codex app-server mode. Telegram keeps these enabled by default to match released OpenClaw behavior from `v2026.4.22` and later.
|
||||
Tool-progress preview updates are the short status lines shown while tools run, for example command execution, file reads, planning updates, and patch summaries. Telegram keeps these enabled by default to match released OpenClaw behavior from `v2026.4.22` and later.
|
||||
|
||||
Direct chats can use native Telegram drafts for these tool-progress lines without persisting tool chatter into chat history. Native drafts stop before answer text starts; final answers stay on the normal persistent delivery path. This lane is off by default and should be gated to trusted DM IDs first:
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { EmbeddedBlockChunker } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import {
|
||||
buildChannelCommentaryProgressDraftLine,
|
||||
createChannelProgressDraftGate,
|
||||
type ChannelProgressDraftLine,
|
||||
formatChannelProgressDraftText,
|
||||
isChannelProgressDraftWorkToolName,
|
||||
mergeChannelProgressDraftLine,
|
||||
normalizeChannelProgressDraftLineIdentity,
|
||||
removeChannelProgressDraftLine,
|
||||
resolveChannelCommentaryProgressLineId,
|
||||
resolveChannelProgressDraftMaxLines,
|
||||
resolveChannelStreamingBlockEnabled,
|
||||
resolveChannelStreamingProgressCommentary,
|
||||
@@ -123,10 +126,8 @@ export function createDiscordDraftPreviewController(params: {
|
||||
});
|
||||
|
||||
const clearProgressDraftLine = async (lineId: string) => {
|
||||
const nextLines = previewToolProgressLines.filter(
|
||||
(line) => typeof line !== "object" || line.id?.trim() !== lineId,
|
||||
);
|
||||
if (nextLines.length === previewToolProgressLines.length) {
|
||||
const nextLines = removeChannelProgressDraftLine(previewToolProgressLines, lineId);
|
||||
if (nextLines === previewToolProgressLines) {
|
||||
return;
|
||||
}
|
||||
previewToolProgressLines = nextLines;
|
||||
@@ -307,25 +308,20 @@ export function createDiscordDraftPreviewController(params: {
|
||||
if (finalReplyStarted || finalReplyDelivered) {
|
||||
return;
|
||||
}
|
||||
const itemId = options?.itemId?.trim();
|
||||
if (!text && !itemId) {
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeCommentaryProgressText(text ?? "");
|
||||
const lineId = itemId ? `commentary:${itemId}` : normalized ? `commentary:${normalized}` : "";
|
||||
if (!normalized) {
|
||||
const line = buildChannelCommentaryProgressDraftLine({
|
||||
text,
|
||||
itemId: options?.itemId,
|
||||
});
|
||||
if (!line) {
|
||||
const lineId = resolveChannelCommentaryProgressLineId({
|
||||
text,
|
||||
itemId: options?.itemId,
|
||||
});
|
||||
if (lineId) {
|
||||
await clearProgressDraftLine(lineId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const line: ChannelProgressDraftLine = {
|
||||
id: lineId,
|
||||
kind: "item",
|
||||
text: normalized,
|
||||
label: "Commentary",
|
||||
prefix: false,
|
||||
};
|
||||
previewToolProgressLines = mergeChannelProgressDraftLine(previewToolProgressLines, line, {
|
||||
maxLines: resolveChannelProgressDraftMaxLines(params.discordConfig),
|
||||
});
|
||||
@@ -469,24 +465,6 @@ function normalizeReasoningProgressLine(text: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeCommentaryProgressText(text: string): string {
|
||||
const cleaned = stripInlineDirectiveTagsForDelivery(text).text.trim();
|
||||
if (!cleaned || isSilentCommentaryProgressText(cleaned)) {
|
||||
return "";
|
||||
}
|
||||
return cleaned
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.replace(/\s+/g, " ").trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => `_${line}_`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function isSilentCommentaryProgressText(text: string): boolean {
|
||||
const normalized = text.replace(/^[\s*_`~]+|[\s*_`~]+$/gu, "").trim();
|
||||
return /^NO_REPLY$/iu.test(normalized);
|
||||
}
|
||||
|
||||
function mergeReasoningProgressText(
|
||||
current: string,
|
||||
incoming: string,
|
||||
|
||||
@@ -2252,4 +2252,40 @@ describe("shouldIgnoreBoundThreadWebhookMessage", () => {
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not suppress unbound thread webhook echoes when echo expiry overflows", async () => {
|
||||
const manager = createThreadBindingManager({
|
||||
cfg: DEFAULT_PREFLIGHT_CFG,
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
});
|
||||
const binding = await manager.bindTarget({
|
||||
threadId: "thread-overflow",
|
||||
channelId: "parent-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:child-1",
|
||||
agentId: "main",
|
||||
webhookId: "wh-overflow",
|
||||
webhookToken: "tok-1",
|
||||
});
|
||||
expect(binding).not.toBeNull();
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
|
||||
try {
|
||||
manager.unbindThread({
|
||||
threadId: "thread-overflow",
|
||||
sendFarewell: false,
|
||||
});
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(
|
||||
shouldIgnoreBoundThreadWebhookMessage({
|
||||
accountId: "default",
|
||||
threadId: "thread-overflow",
|
||||
webhookId: "wh-overflow",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { loadJsonFile, saveJsonFile } from "openclaw/plugin-sdk/json-store";
|
||||
import {
|
||||
isFutureDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { normalizeAccountId, resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing";
|
||||
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
||||
import {
|
||||
@@ -345,9 +349,14 @@ export function rememberRecentUnboundWebhookEcho(record: ThreadBindingRecord) {
|
||||
if (!bindingKey) {
|
||||
return;
|
||||
}
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(RECENT_UNBOUND_WEBHOOK_ECHO_WINDOW_MS);
|
||||
if (expiresAt === undefined) {
|
||||
RECENT_UNBOUND_WEBHOOK_ECHOES_BY_BINDING_KEY.delete(bindingKey);
|
||||
return;
|
||||
}
|
||||
RECENT_UNBOUND_WEBHOOK_ECHOES_BY_BINDING_KEY.set(bindingKey, {
|
||||
webhookId,
|
||||
expiresAt: Date.now() + RECENT_UNBOUND_WEBHOOK_ECHO_WINDOW_MS,
|
||||
expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -408,7 +417,7 @@ export function isRecentlyUnboundThreadWebhookMessage(params: {
|
||||
if (!suppressed) {
|
||||
return false;
|
||||
}
|
||||
if (suppressed.expiresAt <= Date.now()) {
|
||||
if (!isFutureDateTimestampMs(suppressed.expiresAt)) {
|
||||
RECENT_UNBOUND_WEBHOOK_ECHOES_BY_BINDING_KEY.delete(bindingKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { MAX_DATE_TIMESTAMP_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
testing as sessionBindingTesting,
|
||||
registerSessionBindingAdapter,
|
||||
@@ -321,6 +322,58 @@ describe("matrix monitor handler pairing account scope", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not reuse account-scoped allowFrom cache while the process clock is invalid", async () => {
|
||||
const readAllowFromStore = vi.fn(async () => [] as string[]);
|
||||
const nowSpy = vi.spyOn(Date, "now");
|
||||
const { handler } = createMatrixHandlerTestHarness({
|
||||
readAllowFromStore,
|
||||
dmPolicy: "pairing",
|
||||
buildPairingReply: () => "pairing",
|
||||
});
|
||||
const makeEvent = (id: string): MatrixRawEvent =>
|
||||
createMatrixTextMessageEvent({
|
||||
eventId: id,
|
||||
body: "@room hello",
|
||||
mentions: { room: true },
|
||||
});
|
||||
|
||||
try {
|
||||
nowSpy.mockReturnValue(Number.NaN);
|
||||
await handler("!room:example.org", makeEvent("$event1"));
|
||||
await handler("!room:example.org", makeEvent("$event2"));
|
||||
|
||||
expect(readAllowFromStore).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not cache account-scoped allowFrom reads when cache expiry overflows", async () => {
|
||||
const readAllowFromStore = vi.fn(async () => [] as string[]);
|
||||
const nowSpy = vi.spyOn(Date, "now");
|
||||
const { handler } = createMatrixHandlerTestHarness({
|
||||
readAllowFromStore,
|
||||
dmPolicy: "pairing",
|
||||
buildPairingReply: () => "pairing",
|
||||
});
|
||||
const makeEvent = (id: string): MatrixRawEvent =>
|
||||
createMatrixTextMessageEvent({
|
||||
eventId: id,
|
||||
body: "@room hello",
|
||||
mentions: { room: true },
|
||||
});
|
||||
|
||||
try {
|
||||
nowSpy.mockReturnValue(MAX_DATE_TIMESTAMP_MS);
|
||||
await handler("!room:example.org", makeEvent("$event1"));
|
||||
await handler("!room:example.org", makeEvent("$event2"));
|
||||
|
||||
expect(readAllowFromStore).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("pins direct-message main route updates to the configured owner", async () => {
|
||||
const { handler, recordInboundSession } = createMatrixHandlerTestHarness({
|
||||
cfg: {
|
||||
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
resolveChannelContextVisibilityMode,
|
||||
} from "openclaw/plugin-sdk/context-visibility-runtime";
|
||||
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
|
||||
import {
|
||||
isFutureDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { mergePairLoopGuardConfig } from "openclaw/plugin-sdk/pair-loop-guard-runtime";
|
||||
import { buildInboundHistoryFromEntries } from "openclaw/plugin-sdk/reply-history";
|
||||
import {
|
||||
@@ -510,9 +514,13 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
|
||||
const readStoreAllowFrom = async (): Promise<string[]> => {
|
||||
const now = Date.now();
|
||||
if (cachedStoreAllowFrom && now < cachedStoreAllowFrom.expiresAtMs) {
|
||||
if (
|
||||
cachedStoreAllowFrom &&
|
||||
isFutureDateTimestampMs(cachedStoreAllowFrom.expiresAtMs, { nowMs: now })
|
||||
) {
|
||||
return cachedStoreAllowFrom.value;
|
||||
}
|
||||
cachedStoreAllowFrom = null;
|
||||
const value = await core.channel.pairing
|
||||
.readAllowFromStore({
|
||||
channel: "matrix",
|
||||
@@ -520,10 +528,10 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
accountId,
|
||||
})
|
||||
.catch(() => []);
|
||||
cachedStoreAllowFrom = {
|
||||
value,
|
||||
expiresAtMs: now + ALLOW_FROM_STORE_CACHE_TTL_MS,
|
||||
};
|
||||
const expiresAtMs = resolveExpiresAtMsFromDurationMs(ALLOW_FROM_STORE_CACHE_TTL_MS, {
|
||||
nowMs: now,
|
||||
});
|
||||
cachedStoreAllowFrom = expiresAtMs === undefined ? null : { value, expiresAtMs };
|
||||
return value;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getAccessTokenResultAsync } from "./cli.js";
|
||||
import plugin from "./index.js";
|
||||
import { buildFoundryConnectionTest, isValidTenantIdentifier } from "./onboard.js";
|
||||
@@ -73,7 +73,9 @@ function requirePrepareRuntimeAuth(
|
||||
return prepareRuntimeAuth;
|
||||
}
|
||||
|
||||
function requireRuntimeAuthResult(result: { apiKey?: string; baseUrl?: string } | undefined) {
|
||||
function requireRuntimeAuthResult(
|
||||
result: { apiKey?: string; baseUrl?: string; expiresAt?: number } | undefined,
|
||||
) {
|
||||
if (!result) {
|
||||
throw new Error("expected Microsoft Foundry runtime auth result");
|
||||
}
|
||||
@@ -277,6 +279,10 @@ describe("microsoft-foundry plugin", () => {
|
||||
ensureAuthProfileStoreMock.mockReturnValue({ profiles: {} });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("keeps the API key profile bound when multiple auth profiles exist without explicit order", async () => {
|
||||
const provider = registerProvider();
|
||||
const config = buildFoundryConfig({
|
||||
@@ -489,6 +495,42 @@ describe("microsoft-foundry plugin", () => {
|
||||
expect(execFileMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("bounds Entra token fallback expiry when the process clock is invalid", async () => {
|
||||
const provider = registerProvider();
|
||||
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
|
||||
mockAzureCliTokenRaw(JSON.stringify({ accessToken: "fallback-token" }));
|
||||
ensureAuthProfileStoreMock.mockReturnValue(buildEntraProfileStore());
|
||||
|
||||
const prepared = requireRuntimeAuthResult(
|
||||
await provider.prepareRuntimeAuth?.(buildFoundryRuntimeAuthContext()),
|
||||
);
|
||||
|
||||
expect(prepared.apiKey).toBe("fallback-token");
|
||||
expect(prepared.expiresAt).toBe(55 * 60 * 1000);
|
||||
});
|
||||
|
||||
it("treats an invalid process clock as an Entra token cache miss", async () => {
|
||||
const provider = registerProvider();
|
||||
mockAzureCliToken({ accessToken: "cached-token", expiresInMs: 10 * 60_000 });
|
||||
ensureAuthProfileStoreMock.mockReturnValue(buildEntraProfileStore());
|
||||
const runtimeContext = buildFoundryRuntimeAuthContext();
|
||||
|
||||
const first = requireRuntimeAuthResult(await provider.prepareRuntimeAuth?.(runtimeContext));
|
||||
expect(first.apiKey).toBe("cached-token");
|
||||
|
||||
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
|
||||
mockAzureCliTokenRaw(
|
||||
JSON.stringify({
|
||||
accessToken: "refreshed-token",
|
||||
expiresOn: "2026-05-29T12:10:00.000Z",
|
||||
}),
|
||||
);
|
||||
const second = requireRuntimeAuthResult(await provider.prepareRuntimeAuth?.(runtimeContext));
|
||||
|
||||
expect(second.apiKey).toBe("refreshed-token");
|
||||
expect(execFileMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps other configured Foundry models when switching the selected model", async () => {
|
||||
const provider = registerProvider();
|
||||
const config: OpenClawConfig = {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { ProviderPrepareRuntimeAuthContext } from "openclaw/plugin-sdk/core";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { ensureAuthProfileStore } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { getAccessTokenResultAsync } from "./cli.js";
|
||||
@@ -15,6 +20,7 @@ import {
|
||||
|
||||
const cachedTokens = new Map<string, CachedTokenEntry>();
|
||||
const refreshPromises = new Map<string, Promise<{ apiKey: string; expiresAt: number }>>();
|
||||
const FOUNDRY_TOKEN_FALLBACK_LIFETIME_MS = 55 * 60 * 1000;
|
||||
|
||||
export function resetFoundryRuntimeAuthCaches(): void {
|
||||
cachedTokens.clear();
|
||||
@@ -27,7 +33,11 @@ async function refreshEntraToken(params?: {
|
||||
}): Promise<{ apiKey: string; expiresAt: number }> {
|
||||
const result = await getAccessTokenResultAsync(params);
|
||||
const rawExpiry = result.expiresOn ? new Date(result.expiresOn).getTime() : Number.NaN;
|
||||
const expiresAt = Number.isFinite(rawExpiry) ? rawExpiry : Date.now() + 55 * 60 * 1000;
|
||||
const now = resolveDateTimestampMs(Date.now());
|
||||
const expiresAt =
|
||||
asDateTimestampMs(rawExpiry) ??
|
||||
resolveExpiresAtMsFromDurationMs(FOUNDRY_TOKEN_FALLBACK_LIFETIME_MS, { nowMs: now }) ??
|
||||
now;
|
||||
cachedTokens.set(getFoundryTokenCacheKey(params), {
|
||||
token: result.accessToken,
|
||||
expiresAt,
|
||||
@@ -71,7 +81,12 @@ export async function prepareFoundryRuntimeAuth(ctx: ProviderPrepareRuntimeAuthC
|
||||
tenantId: metadata?.tenantId,
|
||||
});
|
||||
const cachedToken = cachedTokens.get(cacheKey);
|
||||
if (cachedToken && cachedToken.expiresAt > Date.now() + TOKEN_REFRESH_MARGIN_MS) {
|
||||
const rawNow = Date.now();
|
||||
const hasValidClock = asDateTimestampMs(rawNow) !== undefined;
|
||||
const now = resolveDateTimestampMs(rawNow);
|
||||
const refreshAfterMs =
|
||||
resolveExpiresAtMsFromDurationMs(TOKEN_REFRESH_MARGIN_MS, { nowMs: now }) ?? now;
|
||||
if (cachedToken && hasValidClock && cachedToken.expiresAt > refreshAfterMs) {
|
||||
return {
|
||||
apiKey: cachedToken.token,
|
||||
expiresAt: cachedToken.expiresAt,
|
||||
|
||||
@@ -77,6 +77,35 @@ describe("resolveTeamGroupId", () => {
|
||||
expect(fetchGraphJson).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not cache team ids when the expiry would exceed a valid Date", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(8_640_000_000_000_000));
|
||||
try {
|
||||
vi.mocked(fetchGraphJson).mockResolvedValue({ id: "group-guid-boundary" } as never);
|
||||
|
||||
await resolveTeamGroupId("tok", "team-boundary");
|
||||
await resolveTeamGroupId("tok", "team-boundary");
|
||||
|
||||
expect(fetchGraphJson).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("evicts cached team ids when the current clock is invalid", async () => {
|
||||
vi.mocked(fetchGraphJson).mockResolvedValue({ id: "group-guid-invalid-clock" } as never);
|
||||
|
||||
await resolveTeamGroupId("tok", "team-invalid-clock");
|
||||
const dateNow = vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
|
||||
try {
|
||||
await resolveTeamGroupId("tok", "team-invalid-clock");
|
||||
} finally {
|
||||
dateNow.mockRestore();
|
||||
}
|
||||
|
||||
expect(fetchGraphJson).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("falls back to conversationTeamId when Graph returns no id", async () => {
|
||||
vi.mocked(fetchGraphJson).mockResolvedValueOnce({} as never);
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { fetchGraphJson, type GraphResponse } from "./graph.js";
|
||||
|
||||
export type GraphThreadMessage = {
|
||||
@@ -14,6 +18,13 @@ export type GraphThreadMessage = {
|
||||
const teamGroupIdCache = new Map<string, { groupId: string; expiresAt: number }>();
|
||||
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
function resolveTeamGroupIdCacheExpiresAt(nowRaw = Date.now()): number | undefined {
|
||||
const now = asDateTimestampMs(nowRaw);
|
||||
return now === undefined
|
||||
? undefined
|
||||
: resolveExpiresAtMsFromDurationMs(CACHE_TTL_MS, { nowMs: now });
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip HTML tags from Teams message content, preserving @mention display names.
|
||||
* Teams wraps mentions in <at>Name</at> tags.
|
||||
@@ -44,8 +55,13 @@ export async function resolveTeamGroupId(
|
||||
conversationTeamId: string,
|
||||
): Promise<string> {
|
||||
const cached = teamGroupIdCache.get(conversationTeamId);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.groupId;
|
||||
if (cached) {
|
||||
const now = asDateTimestampMs(Date.now());
|
||||
const expiresAt = asDateTimestampMs(cached.expiresAt);
|
||||
if (now !== undefined && expiresAt !== undefined && expiresAt > now) {
|
||||
return cached.groupId;
|
||||
}
|
||||
teamGroupIdCache.delete(conversationTeamId);
|
||||
}
|
||||
|
||||
// The team ID in channelData is typically the group ID itself for standard teams.
|
||||
@@ -59,10 +75,13 @@ export async function resolveTeamGroupId(
|
||||
// Only cache when the Graph lookup succeeds — caching a fallback raw ID
|
||||
// can cause silent failures for the entire TTL if the ID is not a valid
|
||||
// Graph team GUID (e.g. Bot Framework conversation key).
|
||||
teamGroupIdCache.set(conversationTeamId, {
|
||||
groupId,
|
||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||
});
|
||||
const expiresAt = resolveTeamGroupIdCacheExpiresAt();
|
||||
if (expiresAt !== undefined) {
|
||||
teamGroupIdCache.set(conversationTeamId, {
|
||||
groupId,
|
||||
expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
return groupId;
|
||||
} catch {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GraphThreadMessage } from "./graph-thread.js";
|
||||
import {
|
||||
resetThreadParentContextCachesForTest,
|
||||
@@ -95,6 +95,10 @@ describe("fetchParentMessageCached", () => {
|
||||
resetThreadParentContextCachesForTest();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("invokes the fetcher on first call", async () => {
|
||||
const mockMsg: GraphThreadMessage = {
|
||||
id: "p1",
|
||||
@@ -150,21 +154,31 @@ describe("fetchParentMessageCached", () => {
|
||||
|
||||
it("re-fetches after TTL expires", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const fetcher = vi.fn(async () => ({
|
||||
id: "p1",
|
||||
body: { content: "hi", contentType: "text" },
|
||||
}));
|
||||
const fetcher = vi.fn(async () => ({
|
||||
id: "p1",
|
||||
body: { content: "hi", contentType: "text" },
|
||||
}));
|
||||
|
||||
await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher);
|
||||
// 5 min TTL: advance just beyond.
|
||||
vi.advanceTimersByTime(5 * 60 * 1000 + 1);
|
||||
await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher);
|
||||
await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher);
|
||||
// 5 min TTL: advance just beyond.
|
||||
vi.advanceTimersByTime(5 * 60 * 1000 + 1);
|
||||
await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher);
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not cache parent fetches when the expiry would exceed Date range", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(8_640_000_000_000_000));
|
||||
const fetcher = vi.fn(async () => ({
|
||||
id: "p1",
|
||||
body: { content: "hi", contentType: "text" },
|
||||
}));
|
||||
|
||||
await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher);
|
||||
await fetchParentMessageCached("tok", "g1", "c1", "p1", fetcher);
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("evicts oldest entries when exceeding the 100-entry cap", async () => {
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
// the same parent is not re-injected on every subsequent reply in the
|
||||
// thread.
|
||||
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { fetchChannelMessage, stripHtmlFromTeamsMessage } from "./graph-thread.js";
|
||||
import type { GraphThreadMessage } from "./graph-thread.js";
|
||||
|
||||
@@ -61,6 +65,13 @@ function buildParentCacheKey(groupId: string, channelId: string, parentId: strin
|
||||
return `${groupId}\u0000${channelId}\u0000${parentId}`;
|
||||
}
|
||||
|
||||
function resolveParentCacheExpiresAt(nowRaw: number): number | undefined {
|
||||
const nowMs = asDateTimestampMs(nowRaw);
|
||||
return nowMs === undefined
|
||||
? undefined
|
||||
: resolveExpiresAtMsFromDurationMs(PARENT_CACHE_TTL_MS, { nowMs });
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a channel parent message with an LRU+TTL cache.
|
||||
*
|
||||
@@ -75,16 +86,23 @@ export async function fetchParentMessageCached(
|
||||
fetchParent: ThreadParentContextFetcher = fetchChannelMessage,
|
||||
): Promise<GraphThreadMessage | undefined> {
|
||||
const key = buildParentCacheKey(groupId, channelId, parentId);
|
||||
const now = Date.now();
|
||||
const now = asDateTimestampMs(Date.now());
|
||||
const cached = parentCache.get(key);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
const cachedExpiresAt = cached ? asDateTimestampMs(cached.expiresAt) : undefined;
|
||||
if (cached && now !== undefined && cachedExpiresAt !== undefined && cachedExpiresAt > now) {
|
||||
// Refresh LRU ordering on hit.
|
||||
parentCache.delete(key);
|
||||
parentCache.set(key, cached);
|
||||
return cached.message;
|
||||
}
|
||||
if (cached) {
|
||||
parentCache.delete(key);
|
||||
}
|
||||
const message = await fetchParent(token, groupId, channelId, parentId);
|
||||
touchLru(parentCache, key, { message, expiresAt: now + PARENT_CACHE_TTL_MS }, PARENT_CACHE_MAX);
|
||||
const expiresAt = resolveParentCacheExpiresAt(Date.now());
|
||||
if (expiresAt !== undefined) {
|
||||
touchLru(parentCache, key, { message, expiresAt }, PARENT_CACHE_MAX);
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ const ssrfRuntimeMocks = vi.hoisted(() => ({
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ssrfRuntimeMocks);
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
clearNvidiaFeaturedModelCacheForTests();
|
||||
ssrfRuntimeMocks.fetchWithSsrFGuard.mockReset();
|
||||
ssrfRuntimeMocks.ssrfPolicyFromHttpBaseUrlAllowedHostname.mockClear();
|
||||
@@ -195,4 +196,35 @@ describe("nvidia provider catalog", () => {
|
||||
|
||||
expect(ssrfRuntimeMocks.fetchWithSsrFGuard).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("skips featured catalog cache when ttl expiry overflows", async () => {
|
||||
vi.setSystemTime(new Date(8_640_000_000_000_000));
|
||||
mockFeaturedCatalogResponse({
|
||||
"featured-models": [
|
||||
{
|
||||
model: "minimaxai/minimax-m2.7",
|
||||
"model-name": "Minimax M2.7",
|
||||
context: 196608,
|
||||
"max-output": 8192,
|
||||
},
|
||||
],
|
||||
});
|
||||
mockFeaturedCatalogResponse({
|
||||
"featured-models": [
|
||||
{
|
||||
model: "z-ai/glm-5.1",
|
||||
"model-name": "GLM 5.1",
|
||||
context: 202752,
|
||||
"max-output": 8192,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const first = await buildLiveNvidiaProvider();
|
||||
const second = await buildLiveNvidiaProvider();
|
||||
|
||||
expect(first.models.map((model) => model.id)).toEqual(["minimaxai/minimax-m2.7"]);
|
||||
expect(second.models.map((model) => model.id)).toEqual(["z-ai/glm-5.1"]);
|
||||
expect(ssrfRuntimeMocks.fetchWithSsrFGuard).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { lookup as dnsLookup } from "node:dns/promises";
|
||||
import {
|
||||
isFutureDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { buildManifestModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-shared";
|
||||
import type {
|
||||
ModelDefinitionConfig,
|
||||
@@ -106,17 +110,26 @@ export function clearNvidiaFeaturedModelCacheForTests() {
|
||||
|
||||
async function loadNvidiaFeaturedModels(): Promise<ModelDefinitionConfig[] | null> {
|
||||
const now = Date.now();
|
||||
if (featuredModelCache && featuredModelCache.expiresAtMs > now) {
|
||||
if (
|
||||
featuredModelCache &&
|
||||
isFutureDateTimestampMs(featuredModelCache.expiresAtMs, { nowMs: now })
|
||||
) {
|
||||
return featuredModelCache.models;
|
||||
}
|
||||
featuredModelCache = undefined;
|
||||
featuredModelRequest ??= fetchNvidiaFeaturedModels();
|
||||
try {
|
||||
const models = await featuredModelRequest;
|
||||
if (models && models.length > 0) {
|
||||
featuredModelCache = {
|
||||
expiresAtMs: now + FEATURED_MODEL_CACHE_TTL_MS,
|
||||
models,
|
||||
};
|
||||
const expiresAtMs = resolveExpiresAtMsFromDurationMs(FEATURED_MODEL_CACHE_TTL_MS, {
|
||||
nowMs: now,
|
||||
});
|
||||
if (expiresAtMs !== undefined) {
|
||||
featuredModelCache = {
|
||||
expiresAtMs,
|
||||
models,
|
||||
};
|
||||
}
|
||||
}
|
||||
return models;
|
||||
} finally {
|
||||
|
||||
@@ -269,6 +269,25 @@ describe("phone-control plugin", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects arm requests when the expiry would exceed a valid Date", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(8_640_000_000_000_000));
|
||||
try {
|
||||
await withRegisteredPhoneControl(async ({ command, writeConfigFile }) => {
|
||||
const res = await command.handler({
|
||||
...createCommandContext("arm writes 30s"),
|
||||
channel: "webchat",
|
||||
gatewayClientScopes: ["operator.admin"],
|
||||
});
|
||||
|
||||
expect(res?.text ?? "").toContain("Invalid duration");
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("allows external owner callers without gateway scopes to mutate phone control", async () => {
|
||||
await withRegisteredPhoneControl(async ({ command, writeConfigFile }) => {
|
||||
const res = await command.handler({
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
@@ -294,14 +298,38 @@ function lacksAdminToMutatePhoneControl(params: {
|
||||
return senderIsOwner !== true;
|
||||
}
|
||||
|
||||
function resolveArmExpiryStatus(state: ArmStateFile, nowRaw = Date.now()): string {
|
||||
if (state.expiresAtMs == null) {
|
||||
return "manual disarm required";
|
||||
}
|
||||
const now = asDateTimestampMs(nowRaw);
|
||||
if (now === undefined) {
|
||||
return "expiry unavailable";
|
||||
}
|
||||
const expiresAt = asDateTimestampMs(state.expiresAtMs);
|
||||
if (expiresAt === undefined || expiresAt <= now) {
|
||||
return "expired";
|
||||
}
|
||||
return `expires in ${formatDuration(expiresAt - now)}`;
|
||||
}
|
||||
|
||||
function isArmStateExpired(state: ArmStateFile, nowRaw = Date.now()): boolean {
|
||||
if (state.expiresAtMs == null) {
|
||||
return false;
|
||||
}
|
||||
const now = asDateTimestampMs(nowRaw);
|
||||
if (now === undefined) {
|
||||
return false;
|
||||
}
|
||||
const expiresAt = asDateTimestampMs(state.expiresAtMs);
|
||||
return expiresAt === undefined || expiresAt <= now;
|
||||
}
|
||||
|
||||
function formatStatus(state: ArmStateFile | null): string {
|
||||
if (!state) {
|
||||
return "Phone control: disarmed.";
|
||||
}
|
||||
const until =
|
||||
state.expiresAtMs == null
|
||||
? "manual disarm required"
|
||||
: `expires in ${formatDuration(Math.max(0, state.expiresAtMs - Date.now()))}`;
|
||||
const until = resolveArmExpiryStatus(state);
|
||||
const cmds = uniqSorted(
|
||||
state.version === 1
|
||||
? state.removedFromDeny
|
||||
@@ -329,7 +357,7 @@ export default definePluginEntry({
|
||||
if (!state || state.expiresAtMs == null) {
|
||||
return;
|
||||
}
|
||||
if (Date.now() < state.expiresAtMs) {
|
||||
if (!isArmStateExpired(state)) {
|
||||
return;
|
||||
}
|
||||
await disarmNow({
|
||||
@@ -430,7 +458,14 @@ export default definePluginEntry({
|
||||
if (durationMs === null) {
|
||||
return { text: "Invalid duration. Use values like 30s, 10m, 2h, or 1d." };
|
||||
}
|
||||
const expiresAtMs = Date.now() + durationMs;
|
||||
const armedAtMs = asDateTimestampMs(Date.now());
|
||||
const expiresAtMs =
|
||||
armedAtMs === undefined
|
||||
? undefined
|
||||
: resolveExpiresAtMsFromDurationMs(durationMs, { nowMs: armedAtMs });
|
||||
if (armedAtMs === undefined || expiresAtMs === undefined) {
|
||||
return { text: "Invalid duration. Use values like 30s, 10m, 2h, or 1d." };
|
||||
}
|
||||
|
||||
const commands = resolveCommandsForGroup(group);
|
||||
const cfg = api.runtime.config.current() as OpenClawConfig;
|
||||
@@ -461,7 +496,7 @@ export default definePluginEntry({
|
||||
|
||||
await writeArmState(statePath, {
|
||||
version: STATE_VERSION,
|
||||
armedAtMs: Date.now(),
|
||||
armedAtMs,
|
||||
expiresAtMs,
|
||||
group,
|
||||
armedCommands: uniqSorted(commands),
|
||||
|
||||
@@ -98,6 +98,7 @@ export class MessageApi {
|
||||
msgId?: string;
|
||||
messageReference?: string;
|
||||
inlineKeyboard?: InlineKeyboard;
|
||||
forcePlainText?: boolean;
|
||||
},
|
||||
): Promise<MessageResponse> {
|
||||
const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret);
|
||||
@@ -108,6 +109,7 @@ export class MessageApi {
|
||||
msgSeq,
|
||||
opts?.messageReference,
|
||||
opts?.inlineKeyboard,
|
||||
opts?.forcePlainText,
|
||||
);
|
||||
const path = messagePath(scope, targetId);
|
||||
return this.sendAndNotify(creds.appId, token, "POST", path, body, { text: content });
|
||||
@@ -119,12 +121,13 @@ export class MessageApi {
|
||||
targetId: string,
|
||||
content: string,
|
||||
creds: Credentials,
|
||||
opts?: { forcePlainText?: boolean },
|
||||
): Promise<MessageResponse> {
|
||||
if (!content?.trim()) {
|
||||
throw new Error("Proactive message content must not be empty");
|
||||
}
|
||||
const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret);
|
||||
const body = this.buildProactiveBody(content);
|
||||
const body = this.buildProactiveBody(content, opts?.forcePlainText);
|
||||
const path = messagePath(scope, targetId);
|
||||
return this.sendAndNotify(creds.appId, token, "POST", path, body, { text: content });
|
||||
}
|
||||
@@ -262,15 +265,17 @@ export class MessageApi {
|
||||
msgSeq: number,
|
||||
messageReference?: string,
|
||||
inlineKeyboard?: InlineKeyboard,
|
||||
forcePlainText = false,
|
||||
): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = this.markdownSupport
|
||||
const useMarkdown = this.markdownSupport && !forcePlainText;
|
||||
const body: Record<string, unknown> = useMarkdown
|
||||
? { markdown: { content }, msg_type: 2, msg_seq: msgSeq }
|
||||
: { content, msg_type: 0, msg_seq: msgSeq };
|
||||
|
||||
if (msgId) {
|
||||
body.msg_id = msgId;
|
||||
}
|
||||
if (messageReference && !this.markdownSupport) {
|
||||
if (messageReference && !useMarkdown) {
|
||||
body.message_reference = { message_id: messageReference };
|
||||
}
|
||||
if (inlineKeyboard) {
|
||||
@@ -279,8 +284,10 @@ export class MessageApi {
|
||||
return body;
|
||||
}
|
||||
|
||||
private buildProactiveBody(content: string): Record<string, unknown> {
|
||||
return this.markdownSupport ? { markdown: { content }, msg_type: 2 } : { content, msg_type: 0 };
|
||||
private buildProactiveBody(content: string, forcePlainText = false): Record<string, unknown> {
|
||||
return this.markdownSupport && !forcePlainText
|
||||
? { markdown: { content }, msg_type: 2 }
|
||||
: { content, msg_type: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { InboundContext } from "./inbound-context.js";
|
||||
import { dispatchOutbound } from "./outbound-dispatch.js";
|
||||
import type { GatewayAccount, GatewayPluginRuntime } from "./types.js";
|
||||
@@ -10,7 +10,10 @@ const sendMediaMock = vi.hoisted(() =>
|
||||
vi.fn(async (_params: unknown) => ({ id: "media-1", timestamp: "2026-04-25T00:00:00.000Z" })),
|
||||
);
|
||||
const sendTextMock = vi.hoisted(() =>
|
||||
vi.fn(async (_params: unknown) => ({ id: "text-1", timestamp: "2026-04-25T00:00:00.000Z" })),
|
||||
vi.fn(async (..._params: unknown[]) => ({
|
||||
id: "text-1",
|
||||
timestamp: "2026-04-25T00:00:00.000Z",
|
||||
})),
|
||||
);
|
||||
const audioFileToSilkBase64Mock = vi.hoisted(() => vi.fn(async () => "silk-base64"));
|
||||
|
||||
@@ -107,7 +110,7 @@ function makeRuntime(params: {
|
||||
isControlCommandMessage?: (text?: string, cfg?: unknown) => boolean;
|
||||
onDeliver?: (
|
||||
deliver: (
|
||||
payload: { text?: string; audioAsVoice?: boolean },
|
||||
payload: { text?: string; mediaUrl?: string; mediaUrls?: string[]; audioAsVoice?: boolean },
|
||||
info: { kind: string },
|
||||
) => Promise<void>,
|
||||
) => Promise<void>;
|
||||
@@ -127,7 +130,12 @@ function makeRuntime(params: {
|
||||
rawParams as {
|
||||
dispatcherOptions: {
|
||||
deliver: (
|
||||
payload: { text?: string; audioAsVoice?: boolean },
|
||||
payload: {
|
||||
text?: string;
|
||||
mediaUrl?: string;
|
||||
mediaUrls?: string[];
|
||||
audioAsVoice?: boolean;
|
||||
},
|
||||
info: { kind: string },
|
||||
) => Promise<void>;
|
||||
};
|
||||
@@ -171,6 +179,10 @@ describe("dispatchOutbound", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("keeps waiting past 300s when a slow provider timeout is configured", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
@@ -257,6 +269,137 @@ describe("dispatchOutbound", () => {
|
||||
expect(sendTextMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delivers text-only tool progress immediately in partial streaming mode", async () => {
|
||||
const runtime = makeRuntime({
|
||||
onDeliver: async (deliver) => {
|
||||
await deliver({ text: "Working: checking logs" }, { kind: "tool" });
|
||||
await deliver({ text: "final answer" }, { kind: "block" });
|
||||
},
|
||||
});
|
||||
|
||||
await dispatchOutbound(makeInbound(), {
|
||||
runtime,
|
||||
cfg: {},
|
||||
account: { ...account, config: { streaming: { mode: "partial" } } },
|
||||
});
|
||||
|
||||
expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual([
|
||||
"Working: checking logs",
|
||||
"final answer",
|
||||
]);
|
||||
expect(sendMediaMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delivers text-only tool progress immediately in recommended C2C streaming mode", async () => {
|
||||
const runtime = makeRuntime({
|
||||
onDeliver: async (deliver) => {
|
||||
await deliver({ text: "Working: checking logs" }, { kind: "tool" });
|
||||
await deliver({ text: "final answer" }, { kind: "block" });
|
||||
},
|
||||
});
|
||||
|
||||
await dispatchOutbound(makeInbound(), {
|
||||
runtime,
|
||||
cfg: {},
|
||||
account: { ...account, config: { streaming: true } },
|
||||
});
|
||||
|
||||
expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual([
|
||||
"Working: checking logs",
|
||||
"final answer",
|
||||
]);
|
||||
expect(sendMediaMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delivers text-only tool progress for legacy C2C stream API accounts", async () => {
|
||||
const runtime = makeRuntime({
|
||||
onDeliver: async (deliver) => {
|
||||
await deliver({ text: "Working: checking logs" }, { kind: "tool" });
|
||||
await deliver({ text: "final answer" }, { kind: "block" });
|
||||
},
|
||||
});
|
||||
|
||||
await dispatchOutbound(makeInbound(), {
|
||||
runtime,
|
||||
cfg: {},
|
||||
account: {
|
||||
...account,
|
||||
config: { streaming: { mode: "off", c2cStreamApi: true } },
|
||||
},
|
||||
});
|
||||
|
||||
expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual([
|
||||
"Working: checking logs",
|
||||
"final answer",
|
||||
]);
|
||||
expect(sendMediaMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps immediate tool progress media-like text inert with markdown support enabled", async () => {
|
||||
const progress = "progress ";
|
||||
const runtime = makeRuntime({
|
||||
onDeliver: async (deliver) => {
|
||||
await deliver({ text: progress }, { kind: "tool" });
|
||||
await deliver({ text: "final answer" }, { kind: "block" });
|
||||
},
|
||||
});
|
||||
|
||||
await dispatchOutbound(makeInbound(), {
|
||||
runtime,
|
||||
cfg: {},
|
||||
account: { ...account, markdownSupport: true, config: { streaming: { mode: "partial" } } },
|
||||
});
|
||||
|
||||
expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual([progress, "final answer"]);
|
||||
expect(sendTextMock.mock.calls[0]?.[3]).toMatchObject({ forcePlainText: true });
|
||||
expect(sendMediaMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps text-only tool progress buffered when streaming is off", async () => {
|
||||
const runtime = makeRuntime({
|
||||
onDeliver: async (deliver) => {
|
||||
await deliver({ text: "Working: checking logs" }, { kind: "tool" });
|
||||
await deliver({ text: "final answer" }, { kind: "block" });
|
||||
},
|
||||
});
|
||||
|
||||
await dispatchOutbound(makeInbound(), {
|
||||
runtime,
|
||||
cfg: {},
|
||||
account: { ...account, config: { streaming: false } },
|
||||
});
|
||||
|
||||
expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual(["final answer"]);
|
||||
expect(sendMediaMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renews pending tool-media fallback when partial progress is delivered", async () => {
|
||||
vi.useFakeTimers();
|
||||
const mediaUrl = "https://example.com/progress.png";
|
||||
const runtime = makeRuntime({
|
||||
onDeliver: async (deliver) => {
|
||||
await deliver({ mediaUrl }, { kind: "tool" });
|
||||
await vi.advanceTimersByTimeAsync(59_000);
|
||||
await deliver({ text: "Working: checking logs" }, { kind: "tool" });
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(sendMediaMock).not.toHaveBeenCalled();
|
||||
await deliver({ text: "final answer" }, { kind: "block" });
|
||||
},
|
||||
});
|
||||
|
||||
await dispatchOutbound(makeInbound(), {
|
||||
runtime,
|
||||
cfg: {},
|
||||
account: { ...account, config: { streaming: { mode: "partial" } } },
|
||||
});
|
||||
|
||||
expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual([
|
||||
"Working: checking logs",
|
||||
"final answer",
|
||||
]);
|
||||
expect(sendMediaMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("marks recognized C2C framework slash commands as text commands", async () => {
|
||||
let finalized: Record<string, unknown> | undefined;
|
||||
const runtime = makeRuntime({
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { FinalizedMsgContext } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import {
|
||||
parseAndSendMediaTags,
|
||||
sendPlainReply,
|
||||
sendTextOnlyReply,
|
||||
type DeliverDeps,
|
||||
} from "../messaging/outbound-deliver.js";
|
||||
import {
|
||||
@@ -68,8 +69,34 @@ type ReplyDeliverPayload = {
|
||||
mediaUrls?: string[];
|
||||
mediaUrl?: string;
|
||||
audioAsVoice?: boolean;
|
||||
isError?: boolean;
|
||||
};
|
||||
|
||||
function shouldDeliverToolProgressImmediately(
|
||||
account: GatewayAccount,
|
||||
useOfficialC2cStream: boolean,
|
||||
): boolean {
|
||||
if (useOfficialC2cStream) {
|
||||
return true;
|
||||
}
|
||||
const streaming = account.config?.streaming;
|
||||
if (streaming === true) {
|
||||
return true;
|
||||
}
|
||||
return typeof streaming === "object" && streaming !== null && streaming.mode !== "off";
|
||||
}
|
||||
|
||||
function immediateToolProgressText(payload: ReplyDeliverPayload): string | undefined {
|
||||
const text = (payload.text ?? "").trim();
|
||||
if (!text || payload.isError || payload.audioAsVoice) {
|
||||
return undefined;
|
||||
}
|
||||
if (payload.mediaUrl || payload.mediaUrls?.length) {
|
||||
return undefined;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
// ============ dispatchOutbound ============
|
||||
|
||||
/**
|
||||
@@ -155,6 +182,31 @@ export async function dispatchOutbound(
|
||||
}
|
||||
};
|
||||
|
||||
const hasPendingToolFallbackPayload = (): boolean =>
|
||||
toolTexts.length > 0 || toolMediaUrls.length > 0;
|
||||
|
||||
const renewToolOnlyFallback = (): boolean => {
|
||||
if (toolFallbackSent) {
|
||||
return false;
|
||||
}
|
||||
if (toolOnlyTimeoutId) {
|
||||
if (toolRenewalCount >= MAX_TOOL_RENEWALS) {
|
||||
return false;
|
||||
}
|
||||
clearTimeout(toolOnlyTimeoutId);
|
||||
toolRenewalCount++;
|
||||
}
|
||||
toolOnlyTimeoutId = setTimeout(async () => {
|
||||
if (!hasBlockResponse && !toolFallbackSent) {
|
||||
toolFallbackSent = true;
|
||||
try {
|
||||
await sendToolFallback();
|
||||
} catch {}
|
||||
}
|
||||
}, TOOL_ONLY_TIMEOUT);
|
||||
return true;
|
||||
};
|
||||
|
||||
// ---- Timeout promise ----
|
||||
// #85267: derive watchdog from existing agent / provider timeout config so
|
||||
// a longer configured ceiling (e.g. slow local ollama models) is not
|
||||
@@ -208,6 +260,10 @@ export async function dispatchOutbound(
|
||||
? ("group" as const)
|
||||
: ("channel" as const);
|
||||
const useOfficialC2cStream = shouldUseOfficialC2cStream(account, targetType);
|
||||
const deliverToolProgressImmediately = shouldDeliverToolProgressImmediately(
|
||||
account,
|
||||
useOfficialC2cStream,
|
||||
);
|
||||
let streamingController: StreamingController | null = null;
|
||||
if (useOfficialC2cStream) {
|
||||
streamingController = new StreamingController({
|
||||
@@ -275,6 +331,29 @@ export async function dispatchOutbound(
|
||||
if (info.kind === "tool") {
|
||||
toolDeliverCount++;
|
||||
const toolText = (payload.text ?? "").trim();
|
||||
const textOnlyProgress = immediateToolProgressText(payload);
|
||||
if (!hasBlockResponse && deliverToolProgressImmediately && textOnlyProgress) {
|
||||
if (toolOnlyTimeoutId || hasPendingToolFallbackPayload()) {
|
||||
renewToolOnlyFallback();
|
||||
}
|
||||
await sendTextOnlyReply(
|
||||
textOnlyProgress,
|
||||
{
|
||||
type: event.type,
|
||||
senderId: event.senderId,
|
||||
messageId: event.messageId,
|
||||
channelId: event.channelId,
|
||||
groupOpenid: event.groupOpenid,
|
||||
msgIdx: event.msgIdx,
|
||||
},
|
||||
{ account, qualifiedTarget, log },
|
||||
sendWithRetry,
|
||||
() => undefined,
|
||||
deliverDeps,
|
||||
);
|
||||
recordOutbound();
|
||||
return;
|
||||
}
|
||||
if (toolText) {
|
||||
toolTexts.push(toolText);
|
||||
}
|
||||
@@ -305,22 +384,7 @@ export async function dispatchOutbound(
|
||||
if (toolFallbackSent) {
|
||||
return;
|
||||
}
|
||||
if (toolOnlyTimeoutId) {
|
||||
if (toolRenewalCount < MAX_TOOL_RENEWALS) {
|
||||
clearTimeout(toolOnlyTimeoutId);
|
||||
toolRenewalCount++;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
toolOnlyTimeoutId = setTimeout(async () => {
|
||||
if (!hasBlockResponse && !toolFallbackSent) {
|
||||
toolFallbackSent = true;
|
||||
try {
|
||||
await sendToolFallback();
|
||||
} catch {}
|
||||
}
|
||||
}, TOOL_ONLY_TIMEOUT);
|
||||
renewToolOnlyFallback();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -173,8 +173,9 @@ async function sendTextChunkToTarget(params: {
|
||||
text: string;
|
||||
consumeQuoteRef: ConsumeQuoteRefFn;
|
||||
allowDm: boolean;
|
||||
forcePlainText?: boolean;
|
||||
}): Promise<unknown> {
|
||||
const { account, event, text, consumeQuoteRef, allowDm } = params;
|
||||
const { account, event, text, consumeQuoteRef, allowDm, forcePlainText } = params;
|
||||
const ref = consumeQuoteRef();
|
||||
const target = buildDeliveryTarget(event);
|
||||
if (target.type === "dm" && !allowDm) {
|
||||
@@ -184,6 +185,7 @@ async function sendTextChunkToTarget(params: {
|
||||
return await senderSendText(target, text, creds, {
|
||||
msgId: event.messageId,
|
||||
messageReference: ref,
|
||||
forcePlainText,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -211,6 +213,35 @@ async function sendTextChunks(
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendTextOnlyReply(
|
||||
text: string,
|
||||
event: DeliverEventContext,
|
||||
actx: DeliverAccountContext,
|
||||
sendWithRetry: SendWithRetryFn,
|
||||
consumeQuoteRef: ConsumeQuoteRefFn,
|
||||
deps: DeliverDeps,
|
||||
): Promise<void> {
|
||||
const safeText = filterInternalMarkers(text).trim();
|
||||
if (!safeText) {
|
||||
return;
|
||||
}
|
||||
const { account, log } = actx;
|
||||
const chunks = deps.chunkText(safeText, TEXT_CHUNK_LIMIT);
|
||||
await sendTextChunksWithRetry({
|
||||
account,
|
||||
event,
|
||||
chunks,
|
||||
sendWithRetry,
|
||||
consumeQuoteRef,
|
||||
allowDm: true,
|
||||
forcePlainText: true,
|
||||
log,
|
||||
onSuccess: (chunk) =>
|
||||
`Sent text-only chunk (${chunk.length}/${safeText.length} chars): ${chunk.slice(0, 50)}...`,
|
||||
onError: (err) => `Failed to send text-only chunk: ${formatErrorMessage(err)}`,
|
||||
});
|
||||
}
|
||||
|
||||
async function sendTextChunksWithRetry(params: {
|
||||
account: GatewayAccount;
|
||||
event: DeliverEventContext;
|
||||
@@ -218,11 +249,13 @@ async function sendTextChunksWithRetry(params: {
|
||||
sendWithRetry: SendWithRetryFn;
|
||||
consumeQuoteRef: ConsumeQuoteRefFn;
|
||||
allowDm: boolean;
|
||||
forcePlainText?: boolean;
|
||||
log?: DeliverAccountContext["log"];
|
||||
onSuccess: (chunk: string) => string;
|
||||
onError: (err: unknown) => string;
|
||||
}): Promise<void> {
|
||||
const { account, event, chunks, sendWithRetry, consumeQuoteRef, allowDm, log } = params;
|
||||
const { account, event, chunks, sendWithRetry, consumeQuoteRef, allowDm, forcePlainText, log } =
|
||||
params;
|
||||
for (const chunk of chunks) {
|
||||
try {
|
||||
await sendWithRetry((token) =>
|
||||
@@ -233,6 +266,7 @@ async function sendTextChunksWithRetry(params: {
|
||||
text: chunk,
|
||||
consumeQuoteRef,
|
||||
allowDm,
|
||||
forcePlainText,
|
||||
}),
|
||||
);
|
||||
log?.info(params.onSuccess(chunk));
|
||||
|
||||
@@ -383,7 +383,7 @@ export async function sendText(
|
||||
target: DeliveryTarget,
|
||||
content: string,
|
||||
creds: AccountCreds,
|
||||
opts?: { msgId?: string; messageReference?: string },
|
||||
opts?: { msgId?: string; messageReference?: string; forcePlainText?: boolean },
|
||||
): Promise<MessageResponse> {
|
||||
const api = resolveAccount(creds.appId).messageApi;
|
||||
const c: Credentials = { appId: creds.appId, clientSecret: creds.clientSecret };
|
||||
@@ -394,9 +394,12 @@ export async function sendText(
|
||||
return api.sendMessage(scope, target.id, content, c, {
|
||||
msgId: opts.msgId,
|
||||
messageReference: opts.messageReference,
|
||||
forcePlainText: opts.forcePlainText,
|
||||
});
|
||||
}
|
||||
return api.sendProactiveMessage(scope, target.id, content, c);
|
||||
return api.sendProactiveMessage(scope, target.id, content, c, {
|
||||
forcePlainText: opts?.forcePlainText,
|
||||
});
|
||||
}
|
||||
|
||||
if (target.type === "dm") {
|
||||
|
||||
34
extensions/qqbot/src/engine/utils/upload-cache.test.ts
Normal file
34
extensions/qqbot/src/engine/utils/upload-cache.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { computeFileHash, getCachedFileInfo, setCachedFileInfo } from "./upload-cache.js";
|
||||
|
||||
describe("qqbot upload-cache", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("reuses cached file info before expiry", () => {
|
||||
const hash = computeFileHash("qqbot-cache-hit");
|
||||
|
||||
setCachedFileInfo(hash, "group", "target-hit", 1, "file-info-hit", "uuid-hit", 3600);
|
||||
|
||||
expect(getCachedFileInfo(hash, "group", "target-hit", 1)).toBe("file-info-hit");
|
||||
});
|
||||
|
||||
it("drops cached file info when the current clock is invalid", () => {
|
||||
const hash = computeFileHash("qqbot-invalid-clock");
|
||||
setCachedFileInfo(hash, "group", "target-invalid-clock", 1, "file-info-invalid", "uuid", 3600);
|
||||
vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
|
||||
|
||||
expect(getCachedFileInfo(hash, "group", "target-invalid-clock", 1)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not cache file info when ttl expiry exceeds the Date range", () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
|
||||
const hash = computeFileHash("qqbot-overflow");
|
||||
|
||||
setCachedFileInfo(hash, "group", "target-overflow", 1, "file-info-overflow", "uuid", 3600);
|
||||
|
||||
expect(getCachedFileInfo(hash, "group", "target-overflow", 1)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,10 @@
|
||||
*/
|
||||
|
||||
import * as crypto from "node:crypto";
|
||||
import {
|
||||
isFutureDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationSeconds,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import type { ChatScope } from "../types.js";
|
||||
import { debugLog } from "./log.js";
|
||||
|
||||
@@ -46,7 +50,7 @@ export function getCachedFileInfo(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Date.now() >= entry.expiresAt) {
|
||||
if (!isFutureDateTimestampMs(entry.expiresAt)) {
|
||||
cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
@@ -68,7 +72,7 @@ export function setCachedFileInfo(
|
||||
if (cache.size >= MAX_CACHE_SIZE) {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of cache) {
|
||||
if (now >= v.expiresAt) {
|
||||
if (!isFutureDateTimestampMs(v.expiresAt, { nowMs: now })) {
|
||||
cache.delete(k);
|
||||
}
|
||||
}
|
||||
@@ -83,11 +87,16 @@ export function setCachedFileInfo(
|
||||
const key = buildCacheKey(contentHash, scope, targetId, fileType);
|
||||
const safetyMargin = 60;
|
||||
const effectiveTtl = Math.max(ttl - safetyMargin, 10);
|
||||
const expiresAt = resolveExpiresAtMsFromDurationSeconds(effectiveTtl);
|
||||
if (expiresAt === undefined) {
|
||||
cache.delete(key);
|
||||
return;
|
||||
}
|
||||
|
||||
cache.set(key, {
|
||||
fileInfo,
|
||||
fileUuid,
|
||||
expiresAt: Date.now() + effectiveTtl * 1000,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
debugLog(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
signalRpcRequest as signalRpcRequestImpl,
|
||||
detectSignalApiMode,
|
||||
@@ -37,6 +37,11 @@ beforeEach(() => {
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function setApiMode(mode: SignalApiMode) {
|
||||
currentApiMode = mode;
|
||||
}
|
||||
@@ -378,6 +383,44 @@ describe("signalCheck", () => {
|
||||
error: "Signal API not reachable at http://localhost:8080",
|
||||
});
|
||||
});
|
||||
|
||||
it("drops cached auto mode when the current clock is not a valid date timestamp", async () => {
|
||||
setApiMode("auto");
|
||||
vi.spyOn(Date, "now").mockReturnValueOnce(1_700_000_000_000).mockReturnValueOnce(Number.NaN);
|
||||
mockNativeCheck.mockResolvedValue({ ok: true, status: 200 });
|
||||
mockContainerCheck.mockResolvedValue({ ok: false, status: 404 });
|
||||
|
||||
await expect(signalCheck("http://auto-invalid-clock.local:8080")).resolves.toEqual({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
await expect(signalCheck("http://auto-invalid-clock.local:8080")).resolves.toEqual({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
expect(mockNativeCheck).toHaveBeenCalledTimes(4);
|
||||
expect(mockContainerCheck).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not cache auto mode when the expiry timestamp would exceed the valid date range", async () => {
|
||||
setApiMode("auto");
|
||||
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
|
||||
mockNativeCheck.mockResolvedValue({ ok: true, status: 200 });
|
||||
mockContainerCheck.mockResolvedValue({ ok: false, status: 404 });
|
||||
|
||||
await expect(signalCheck("http://auto-overflow-clock.local:8080")).resolves.toEqual({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
await expect(signalCheck("http://auto-overflow-clock.local:8080")).resolves.toEqual({
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
|
||||
expect(mockNativeCheck).toHaveBeenCalledTimes(4);
|
||||
expect(mockContainerCheck).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("streamSignalEvents", () => {
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
* only need to change their import path.
|
||||
*/
|
||||
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
containerCheck,
|
||||
containerRpcRequest,
|
||||
@@ -74,24 +78,33 @@ async function resolveAutoApiMode(
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
options: { account?: string; requireContainerReceive?: boolean } = {},
|
||||
): Promise<"native" | "container"> {
|
||||
const rawNow = Date.now();
|
||||
const now = asDateTimestampMs(rawNow);
|
||||
const cached = detectedModeCache.get(baseUrl);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
if (
|
||||
cached.mode !== "container" ||
|
||||
!options.requireContainerReceive ||
|
||||
(Boolean(options.account?.trim()) && cached.receiveAccount === options.account?.trim())
|
||||
) {
|
||||
return cached.mode;
|
||||
if (cached) {
|
||||
if (now !== undefined && cached.expiresAt > now) {
|
||||
if (
|
||||
cached.mode !== "container" ||
|
||||
!options.requireContainerReceive ||
|
||||
(Boolean(options.account?.trim()) && cached.receiveAccount === options.account?.trim())
|
||||
) {
|
||||
return cached.mode;
|
||||
}
|
||||
} else {
|
||||
detectedModeCache.delete(baseUrl);
|
||||
}
|
||||
}
|
||||
const detected = await detectSignalApiMode(baseUrl, timeoutMs, options);
|
||||
detectedModeCache.set(baseUrl, {
|
||||
mode: detected,
|
||||
expiresAt: Date.now() + MODE_CACHE_TTL_MS,
|
||||
...(detected === "container" && options.requireContainerReceive && options.account
|
||||
? { receiveAccount: options.account }
|
||||
: {}),
|
||||
});
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(MODE_CACHE_TTL_MS, { nowMs: rawNow });
|
||||
if (expiresAt !== undefined) {
|
||||
detectedModeCache.set(baseUrl, {
|
||||
mode: detected,
|
||||
expiresAt,
|
||||
...(detected === "container" && options.requireContainerReceive && options.account
|
||||
? { receiveAccount: options.account }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
return detected;
|
||||
}
|
||||
|
||||
|
||||
@@ -160,6 +160,117 @@ describe("authorizeSlackSystemEventSender", () => {
|
||||
expect(conversationsMembers).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("drops cached channel members when the current clock is not a valid date timestamp", async () => {
|
||||
vi.spyOn(Date, "now")
|
||||
.mockReturnValueOnce(1_700_000_000_000)
|
||||
.mockReturnValueOnce(1_700_000_000_000)
|
||||
.mockReturnValueOnce(Number.NaN)
|
||||
.mockReturnValue(1_700_000_000_000);
|
||||
const conversationsMembers = vi.fn(async () => ({
|
||||
members: ["UOWNER"],
|
||||
response_metadata: {},
|
||||
}));
|
||||
const ctx = {
|
||||
allowFrom: [],
|
||||
accountId: "main",
|
||||
allowNameMatching: false,
|
||||
app: { client: { conversations: { members: conversationsMembers } } },
|
||||
botToken: "xoxb-test",
|
||||
} as unknown as SlackMonitorContext;
|
||||
|
||||
await expect(
|
||||
authorizeSlackBotRoomMessage({
|
||||
ctx,
|
||||
channelId: "C1",
|
||||
senderId: "U_BOT",
|
||||
allowFromLower: ["uowner"],
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
authorizeSlackBotRoomMessage({
|
||||
ctx,
|
||||
channelId: "C1",
|
||||
senderId: "U_BOT",
|
||||
allowFromLower: ["uowner"],
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(conversationsMembers).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not cache channel members when the expiry timestamp would exceed the valid date range", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
|
||||
const conversationsMembers = vi.fn(async () => ({
|
||||
members: ["UOWNER"],
|
||||
response_metadata: {},
|
||||
}));
|
||||
const ctx = {
|
||||
allowFrom: [],
|
||||
accountId: "main",
|
||||
allowNameMatching: false,
|
||||
app: { client: { conversations: { members: conversationsMembers } } },
|
||||
botToken: "xoxb-test",
|
||||
} as unknown as SlackMonitorContext;
|
||||
|
||||
await expect(
|
||||
authorizeSlackBotRoomMessage({
|
||||
ctx,
|
||||
channelId: "C1",
|
||||
senderId: "U_BOT",
|
||||
allowFromLower: ["uowner"],
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
authorizeSlackBotRoomMessage({
|
||||
ctx,
|
||||
channelId: "C1",
|
||||
senderId: "U_BOT",
|
||||
allowFromLower: ["uowner"],
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(conversationsMembers).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("still coalesces in-flight channel member lookups when durable cache expiry is invalid", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
|
||||
let resolveMembers: (value: {
|
||||
members: string[];
|
||||
response_metadata: Record<string, never>;
|
||||
}) => void;
|
||||
const membersPromise = new Promise<{
|
||||
members: string[];
|
||||
response_metadata: Record<string, never>;
|
||||
}>((resolve) => {
|
||||
resolveMembers = resolve;
|
||||
});
|
||||
const conversationsMembers = vi.fn(() => membersPromise);
|
||||
const ctx = {
|
||||
allowFrom: [],
|
||||
accountId: "main",
|
||||
allowNameMatching: false,
|
||||
app: { client: { conversations: { members: conversationsMembers } } },
|
||||
botToken: "xoxb-test",
|
||||
} as unknown as SlackMonitorContext;
|
||||
|
||||
const first = authorizeSlackBotRoomMessage({
|
||||
ctx,
|
||||
channelId: "C1",
|
||||
senderId: "U_BOT",
|
||||
allowFromLower: ["uowner"],
|
||||
});
|
||||
const second = authorizeSlackBotRoomMessage({
|
||||
ctx,
|
||||
channelId: "C1",
|
||||
senderId: "U_BOT",
|
||||
allowFromLower: ["uowner"],
|
||||
});
|
||||
resolveMembers!({ members: ["UOWNER"], response_metadata: {} });
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([true, true]);
|
||||
expect(conversationsMembers).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps non-interactive channel senders open when only global allowFrom is configured", async () => {
|
||||
const result = await authorizeSlackSystemEventSender({
|
||||
ctx: makeAuthorizeCtx({ allowFrom: ["U_OWNER"] }),
|
||||
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
readChannelIngressStoreAllowFromForDmPolicy,
|
||||
} from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import {
|
||||
allowListMatches,
|
||||
@@ -236,26 +240,33 @@ async function resolveSlackChannelMemberIds(
|
||||
"OPENCLAW_SLACK_CHANNEL_MEMBERS_CACHE_TTL_MS",
|
||||
DEFAULT_CHANNEL_MEMBERS_CACHE_TTL_MS,
|
||||
);
|
||||
const nowMs = Date.now();
|
||||
const rawNowMs = Date.now();
|
||||
const nowMs = asDateTimestampMs(rawNowMs);
|
||||
const cached = cache.get(key);
|
||||
if (ttlMs > 0 && cached?.members && cached.expiresAtMs >= nowMs) {
|
||||
return cached.members;
|
||||
if (cached?.members) {
|
||||
if (ttlMs > 0 && nowMs !== undefined && cached.expiresAtMs >= nowMs) {
|
||||
return cached.members;
|
||||
}
|
||||
cache.delete(key);
|
||||
}
|
||||
if (cached?.pending) {
|
||||
return await cached.pending;
|
||||
}
|
||||
|
||||
const pending = fetchSlackChannelMemberIds(ctx, channelId);
|
||||
const pendingExpiresAtMs =
|
||||
ttlMs > 0 ? resolveExpiresAtMsFromDurationMs(ttlMs, { nowMs: rawNowMs }) : undefined;
|
||||
cache.set(key, {
|
||||
expiresAtMs: ttlMs > 0 ? nowMs + ttlMs : 0,
|
||||
expiresAtMs: pendingExpiresAtMs ?? 0,
|
||||
pending,
|
||||
});
|
||||
pruneChannelMembersCache(cache);
|
||||
try {
|
||||
const members = await pending;
|
||||
if (ttlMs > 0) {
|
||||
const membersExpiresAtMs = ttlMs > 0 ? resolveExpiresAtMsFromDurationMs(ttlMs) : undefined;
|
||||
if (membersExpiresAtMs !== undefined) {
|
||||
cache.set(key, {
|
||||
expiresAtMs: Date.now() + ttlMs,
|
||||
expiresAtMs: membersExpiresAtMs,
|
||||
members,
|
||||
});
|
||||
pruneChannelMembersCache(cache);
|
||||
|
||||
44
extensions/slack/src/monitor/external-arg-menu-store.test.ts
Normal file
44
extensions/slack/src/monitor/external-arg-menu-store.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createSlackExternalArgMenuStore,
|
||||
SLACK_EXTERNAL_ARG_MENU_PREFIX,
|
||||
} from "./external-arg-menu-store.js";
|
||||
|
||||
describe("createSlackExternalArgMenuStore", () => {
|
||||
const choices = [{ label: "Daily", value: "day" }];
|
||||
|
||||
it("returns entries before their expiry", () => {
|
||||
const store = createSlackExternalArgMenuStore();
|
||||
const token = store.create({ choices, userId: "U1" }, 1_700_000_000_000);
|
||||
|
||||
expect(store.get(token, 1_700_000_001_000)).toEqual({
|
||||
choices,
|
||||
userId: "U1",
|
||||
expiresAt: 1_700_000_600_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("drops entries when the current clock is not a valid date timestamp", () => {
|
||||
const store = createSlackExternalArgMenuStore();
|
||||
const token = store.create({ choices, userId: "U1" }, 1_700_000_000_000);
|
||||
|
||||
expect(store.get(token, Number.NaN)).toBeUndefined();
|
||||
expect(store.get(token, 1_700_000_001_000)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not retain entries when expiry would exceed the valid date range", () => {
|
||||
const store = createSlackExternalArgMenuStore();
|
||||
const token = store.create({ choices, userId: "U1" }, 8_640_000_000_000_000);
|
||||
|
||||
expect(store.get(token, 1_700_000_001_000)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads only prefixed valid menu tokens", () => {
|
||||
const store = createSlackExternalArgMenuStore();
|
||||
const token = store.create({ choices, userId: "U1" }, 1_700_000_000_000);
|
||||
|
||||
expect(store.readToken(`${SLACK_EXTERNAL_ARG_MENU_PREFIX}${token}`)).toBe(token);
|
||||
expect(store.readToken(token)).toBeUndefined();
|
||||
expect(store.readToken(`${SLACK_EXTERNAL_ARG_MENU_PREFIX}not a token`)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { generateSecureToken } from "openclaw/plugin-sdk/secure-random-runtime";
|
||||
|
||||
const SLACK_EXTERNAL_ARG_MENU_TOKEN_BYTES = 18;
|
||||
@@ -20,10 +24,15 @@ type SlackExternalArgMenuEntry = {
|
||||
|
||||
function pruneSlackExternalArgMenuStore(
|
||||
store: Map<string, SlackExternalArgMenuEntry>,
|
||||
now: number,
|
||||
rawNow: number,
|
||||
): void {
|
||||
const now = asDateTimestampMs(rawNow);
|
||||
if (now === undefined) {
|
||||
store.clear();
|
||||
return;
|
||||
}
|
||||
for (const [token, entry] of store.entries()) {
|
||||
if (entry.expiresAt <= now) {
|
||||
if (asDateTimestampMs(entry.expiresAt) === undefined || entry.expiresAt <= now) {
|
||||
store.delete(token);
|
||||
}
|
||||
}
|
||||
@@ -47,11 +56,16 @@ export function createSlackExternalArgMenuStore() {
|
||||
): string {
|
||||
pruneSlackExternalArgMenuStore(store, now);
|
||||
const token = createSlackExternalArgMenuToken(store);
|
||||
store.set(token, {
|
||||
choices: params.choices,
|
||||
userId: params.userId,
|
||||
expiresAt: now + SLACK_EXTERNAL_ARG_MENU_TTL_MS,
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(SLACK_EXTERNAL_ARG_MENU_TTL_MS, {
|
||||
nowMs: now,
|
||||
});
|
||||
if (expiresAt !== undefined) {
|
||||
store.set(token, {
|
||||
choices: params.choices,
|
||||
userId: params.userId,
|
||||
expiresAt,
|
||||
});
|
||||
}
|
||||
return token;
|
||||
},
|
||||
readToken(raw: unknown): string | undefined {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const prepareSlackMessageMock =
|
||||
vi.fn<
|
||||
@@ -151,6 +151,10 @@ describe("createSlackMessageHandler app_mention race handling", () => {
|
||||
clearSlackRuntime();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("allows a single app_mention retry when message event was dropped before dispatch", async () => {
|
||||
prepareSlackMessageMock.mockImplementation(async ({ opts }) => {
|
||||
if (opts.source === "message") {
|
||||
@@ -169,6 +173,43 @@ describe("createSlackMessageHandler app_mention race handling", () => {
|
||||
expect(dispatchPreparedSlackMessageMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not retain app_mention retry allowance when the current clock is not a valid date timestamp", async () => {
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
|
||||
prepareSlackMessageMock.mockImplementation(async ({ opts }) => {
|
||||
if (opts.source === "message") {
|
||||
return null;
|
||||
}
|
||||
return { ctxPayload: {} };
|
||||
});
|
||||
|
||||
const handler = createTestHandler();
|
||||
|
||||
await sendMessageEvent(handler, "1700000000.000125");
|
||||
nowSpy.mockReturnValue(1_700_000_000_000);
|
||||
await sendMentionEvent(handler, "1700000000.000125");
|
||||
|
||||
expect(prepareSlackMessageMock).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchPreparedSlackMessageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not retain app_mention retry allowance when the expiry timestamp would exceed the valid date range", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
|
||||
prepareSlackMessageMock.mockImplementation(async ({ opts }) => {
|
||||
if (opts.source === "message") {
|
||||
return null;
|
||||
}
|
||||
return { ctxPayload: {} };
|
||||
});
|
||||
|
||||
const handler = createTestHandler();
|
||||
|
||||
await sendMessageEvent(handler, "1700000000.000126");
|
||||
await sendMentionEvent(handler, "1700000000.000126");
|
||||
|
||||
expect(prepareSlackMessageMock).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchPreparedSlackMessageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows app_mention while message handling is still in-flight, then keeps later duplicates deduped", async () => {
|
||||
const { handler, messagePending, resolveMessagePrepare } =
|
||||
await createInFlightMessageScenario("1700000000.000150");
|
||||
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
shouldDebounceTextInbound,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import type { ResolvedSlackAccount } from "../accounts.js";
|
||||
import type { SlackMessageEvent } from "../types.js";
|
||||
import { stripSlackMentionsForCommandDetection } from "./commands.js";
|
||||
@@ -123,7 +127,7 @@ export function createSlackMessageHandler(params: {
|
||||
pruneAppMentionRetryKeys(Date.now());
|
||||
if (last.opts.source === "app_mention") {
|
||||
// If app_mention wins the race and dispatches first, drop the later message dispatch.
|
||||
appMentionDispatchedKeys.set(seenMessageKey, Date.now() + APP_MENTION_RETRY_TTL_MS);
|
||||
rememberExpiringAppMentionKey(appMentionDispatchedKeys, seenMessageKey);
|
||||
} else if (
|
||||
last.opts.source === "message" &&
|
||||
appMentionDispatchedKeys.has(seenMessageKey)
|
||||
@@ -176,28 +180,46 @@ export function createSlackMessageHandler(params: {
|
||||
const appMentionRetryKeys = new Map<string, number>();
|
||||
const appMentionDispatchedKeys = new Map<string, number>();
|
||||
|
||||
const pruneAppMentionRetryKeys = (now: number) => {
|
||||
const pruneAppMentionRetryKeys = (rawNow: number): boolean => {
|
||||
const now = asDateTimestampMs(rawNow);
|
||||
if (now === undefined) {
|
||||
appMentionRetryKeys.clear();
|
||||
appMentionDispatchedKeys.clear();
|
||||
return false;
|
||||
}
|
||||
for (const [key, expiresAt] of appMentionRetryKeys) {
|
||||
if (expiresAt <= now) {
|
||||
if (asDateTimestampMs(expiresAt) === undefined || expiresAt <= now) {
|
||||
appMentionRetryKeys.delete(key);
|
||||
}
|
||||
}
|
||||
for (const [key, expiresAt] of appMentionDispatchedKeys) {
|
||||
if (expiresAt <= now) {
|
||||
if (asDateTimestampMs(expiresAt) === undefined || expiresAt <= now) {
|
||||
appMentionDispatchedKeys.delete(key);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const rememberExpiringAppMentionKey = (map: Map<string, number>, key: string): void => {
|
||||
const now = Date.now();
|
||||
if (!pruneAppMentionRetryKeys(now)) {
|
||||
return;
|
||||
}
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(APP_MENTION_RETRY_TTL_MS, { nowMs: now });
|
||||
if (expiresAt !== undefined) {
|
||||
map.set(key, expiresAt);
|
||||
}
|
||||
};
|
||||
|
||||
const rememberAppMentionRetryKey = (key: string) => {
|
||||
const now = Date.now();
|
||||
pruneAppMentionRetryKeys(now);
|
||||
appMentionRetryKeys.set(key, now + APP_MENTION_RETRY_TTL_MS);
|
||||
rememberExpiringAppMentionKey(appMentionRetryKeys, key);
|
||||
};
|
||||
|
||||
const consumeAppMentionRetryKey = (key: string) => {
|
||||
const now = Date.now();
|
||||
pruneAppMentionRetryKeys(now);
|
||||
if (!pruneAppMentionRetryKeys(now)) {
|
||||
return false;
|
||||
}
|
||||
if (!appMentionRetryKeys.has(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -71,6 +71,52 @@ describe("Slack subteam mentions", () => {
|
||||
expect(client.usergroups.users.list).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("drops cached membership lookups when the current clock is not a valid date timestamp", async () => {
|
||||
const client = createClient(["U_BOT"]);
|
||||
|
||||
await expect(
|
||||
isSlackSubteamMentionForBot({
|
||||
client,
|
||||
text: "<!subteam^S123> ping",
|
||||
botUserId: "U_BOT",
|
||||
now: 1_700_000_000_000,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
isSlackSubteamMentionForBot({
|
||||
client,
|
||||
text: "<!subteam^S123> ping again",
|
||||
botUserId: "U_BOT",
|
||||
now: Number.NaN,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(client.usergroups.users.list).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not cache membership lookups when the expiry timestamp would exceed the valid date range", async () => {
|
||||
const client = createClient(["U_BOT"]);
|
||||
|
||||
await expect(
|
||||
isSlackSubteamMentionForBot({
|
||||
client,
|
||||
text: "<!subteam^S123> ping",
|
||||
botUserId: "U_BOT",
|
||||
now: 8_640_000_000_000_000,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
isSlackSubteamMentionForBot({
|
||||
client,
|
||||
text: "<!subteam^S123> ping again",
|
||||
botUserId: "U_BOT",
|
||||
now: 1_700_000_000_000,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(client.usergroups.users.list).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("fails closed when Slack rejects the user-group lookup", async () => {
|
||||
const log = vi.fn();
|
||||
const client = createClient([]);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { WebClient } from "@slack/web-api";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
const SUBTEAM_MENTION_RE = /<!subteam\^([A-Z0-9]+)(?:\|[^>]*)?>/gi;
|
||||
@@ -44,8 +48,16 @@ async function readSlackSubteamUsers(params: {
|
||||
}
|
||||
const cacheKey = `${normalizeSlackId(params.teamId) ?? ""}:${params.subteamId}`;
|
||||
const cached = bySubteam.get(cacheKey);
|
||||
if (cached && cached.expiresAt > params.now) {
|
||||
return cached.users;
|
||||
const now = asDateTimestampMs(params.now);
|
||||
if (cached) {
|
||||
if (
|
||||
now !== undefined &&
|
||||
asDateTimestampMs(cached.expiresAt) !== undefined &&
|
||||
cached.expiresAt > now
|
||||
) {
|
||||
return cached.users;
|
||||
}
|
||||
bySubteam.delete(cacheKey);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -62,10 +74,15 @@ async function readSlackSubteamUsers(params: {
|
||||
const users = new Set(
|
||||
(response.users ?? []).map((userId) => normalizeSlackId(userId)).filter(Boolean) as string[],
|
||||
);
|
||||
bySubteam.set(cacheKey, {
|
||||
expiresAt: params.now + SUBTEAM_MEMBER_CACHE_TTL_MS,
|
||||
users,
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(SUBTEAM_MEMBER_CACHE_TTL_MS, {
|
||||
nowMs: params.now,
|
||||
});
|
||||
if (expiresAt !== undefined) {
|
||||
bySubteam.set(cacheKey, {
|
||||
expiresAt,
|
||||
users,
|
||||
});
|
||||
}
|
||||
return users;
|
||||
} catch (err) {
|
||||
params.log?.(
|
||||
|
||||
@@ -61,6 +61,45 @@ describe("resolveSlackThreadStarter cache", () => {
|
||||
expect(replies).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("drops cached thread starters when the current clock is not a valid date timestamp", async () => {
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
||||
const { replies, client } = createThreadStarterRepliesClient();
|
||||
|
||||
const first = await resolveSlackThreadStarter({
|
||||
channelId: "C1",
|
||||
threadTs: "1000.1",
|
||||
client,
|
||||
});
|
||||
nowSpy.mockReturnValue(Number.NaN);
|
||||
const second = await resolveSlackThreadStarter({
|
||||
channelId: "C1",
|
||||
threadTs: "1000.1",
|
||||
client,
|
||||
});
|
||||
|
||||
expect(first).toEqual(second);
|
||||
expect(replies).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not cache thread starters when the expiry timestamp would exceed the valid date range", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
|
||||
const { replies, client } = createThreadStarterRepliesClient();
|
||||
|
||||
const first = await resolveSlackThreadStarter({
|
||||
channelId: "C1",
|
||||
threadTs: "1000.1",
|
||||
client,
|
||||
});
|
||||
const second = await resolveSlackThreadStarter({
|
||||
channelId: "C1",
|
||||
threadTs: "1000.1",
|
||||
client,
|
||||
});
|
||||
|
||||
expect(first).toEqual(second);
|
||||
expect(replies).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not cache empty starter text", async () => {
|
||||
const { replies, client } = createThreadStarterRepliesClient({
|
||||
messages: [{ text: " ", user: "U1", ts: "1000.1" }],
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SlackMessageEvent } from "../types.js";
|
||||
import { createSlackThreadTsResolver } from "./thread-resolution.js";
|
||||
|
||||
describe("createSlackThreadTsResolver", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function makeThreadReplyMessage(ts: string): SlackMessageEvent {
|
||||
return {
|
||||
channel: "C1",
|
||||
@@ -53,25 +58,75 @@ describe("createSlackThreadTsResolver", () => {
|
||||
|
||||
it("falls back to the default ttl when cacheTtlMs is non-finite", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const historyMock = vi.fn().mockResolvedValue({
|
||||
messages: [{ ts: "1", thread_ts: "9" }],
|
||||
});
|
||||
const resolver = createSlackThreadTsResolver({
|
||||
client: { conversations: { history: historyMock } } as never,
|
||||
cacheTtlMs: Number.NaN,
|
||||
maxSize: 5,
|
||||
});
|
||||
const message = makeThreadReplyMessage("1");
|
||||
const historyMock = vi.fn().mockResolvedValue({
|
||||
messages: [{ ts: "1", thread_ts: "9" }],
|
||||
});
|
||||
const resolver = createSlackThreadTsResolver({
|
||||
client: { conversations: { history: historyMock } } as never,
|
||||
cacheTtlMs: Number.NaN,
|
||||
maxSize: 5,
|
||||
});
|
||||
const message = makeThreadReplyMessage("1");
|
||||
|
||||
await resolver.resolve({ message, source: "message" });
|
||||
vi.advanceTimersByTime(60_001);
|
||||
await resolver.resolve({ message, source: "message" });
|
||||
await resolver.resolve({ message, source: "message" });
|
||||
vi.advanceTimersByTime(60_001);
|
||||
await resolver.resolve({ message, source: "message" });
|
||||
|
||||
expect(historyMock).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
expect(historyMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("drops cached thread_ts lookups when the current clock is not a valid date timestamp", async () => {
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
||||
const historyMock = vi.fn().mockResolvedValue({
|
||||
messages: [{ ts: "1", thread_ts: "9" }],
|
||||
});
|
||||
const resolver = createSlackThreadTsResolver({
|
||||
client: { conversations: { history: historyMock } } as never,
|
||||
cacheTtlMs: 60_000,
|
||||
maxSize: 5,
|
||||
});
|
||||
const message = makeThreadReplyMessage("1");
|
||||
|
||||
await resolver.resolve({ message, source: "message" });
|
||||
nowSpy.mockReturnValue(Number.NaN);
|
||||
await resolver.resolve({ message, source: "message" });
|
||||
|
||||
expect(historyMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not cache thread_ts lookups when the expiry timestamp would exceed the valid date range", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
|
||||
const historyMock = vi.fn().mockResolvedValue({
|
||||
messages: [{ ts: "1", thread_ts: "9" }],
|
||||
});
|
||||
const resolver = createSlackThreadTsResolver({
|
||||
client: { conversations: { history: historyMock } } as never,
|
||||
cacheTtlMs: 60_000,
|
||||
maxSize: 5,
|
||||
});
|
||||
const message = makeThreadReplyMessage("1");
|
||||
|
||||
await resolver.resolve({ message, source: "message" });
|
||||
await resolver.resolve({ message, source: "message" });
|
||||
|
||||
expect(historyMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("preserves cacheTtlMs zero as a non-expiring cache entry", async () => {
|
||||
const historyMock = vi.fn().mockResolvedValue({
|
||||
messages: [{ ts: "1", thread_ts: "9" }],
|
||||
});
|
||||
const resolver = createSlackThreadTsResolver({
|
||||
client: { conversations: { history: historyMock } } as never,
|
||||
cacheTtlMs: 0,
|
||||
maxSize: 5,
|
||||
});
|
||||
const message = makeThreadReplyMessage("1");
|
||||
|
||||
await resolver.resolve({ message, source: "message" });
|
||||
await resolver.resolve({ message, source: "message" });
|
||||
|
||||
expect(historyMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back to the default max size when maxSize is non-finite", async () => {
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import type { WebClient as SlackWebClient } from "@slack/web-api";
|
||||
import { pruneMapToMaxSize } from "openclaw/plugin-sdk/collection-runtime";
|
||||
import { parseFiniteNumber } from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
parseFiniteNumber,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { formatSlackError } from "../errors.js";
|
||||
import type { SlackMessageEvent } from "../types.js";
|
||||
|
||||
type ThreadTsCacheEntry = {
|
||||
threadTs: string | null;
|
||||
updatedAt: number;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
const DEFAULT_THREAD_TS_CACHE_TTL_MS = 60_000;
|
||||
@@ -64,18 +68,33 @@ export function createSlackThreadTsResolver(params: {
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
if (ttlMs > 0 && now - entry.updatedAt > ttlMs) {
|
||||
if (entry.expiresAt === 0) {
|
||||
cache.delete(key);
|
||||
cache.set(key, entry);
|
||||
return entry.threadTs;
|
||||
}
|
||||
const normalizedNow = asDateTimestampMs(now);
|
||||
if (
|
||||
normalizedNow === undefined ||
|
||||
asDateTimestampMs(entry.expiresAt) === undefined ||
|
||||
entry.expiresAt <= normalizedNow
|
||||
) {
|
||||
cache.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
cache.delete(key);
|
||||
cache.set(key, { ...entry, updatedAt: now });
|
||||
cache.set(key, entry);
|
||||
return entry.threadTs;
|
||||
};
|
||||
|
||||
const setCached = (key: string, threadTs: string | null, now: number) => {
|
||||
const expiresAt = ttlMs > 0 ? resolveExpiresAtMsFromDurationMs(ttlMs, { nowMs: now }) : 0;
|
||||
if (expiresAt === undefined) {
|
||||
cache.delete(key);
|
||||
return;
|
||||
}
|
||||
cache.delete(key);
|
||||
cache.set(key, { threadTs, updatedAt: now });
|
||||
cache.set(key, { threadTs, expiresAt });
|
||||
pruneMapToMaxSize(cache, maxSize);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { WebClient as SlackWebClient } from "@slack/web-api";
|
||||
import { pruneMapToMaxSize } from "openclaw/plugin-sdk/collection-runtime";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { formatSlackFileReferenceList } from "../file-reference.js";
|
||||
import type { SlackFile } from "../types.js";
|
||||
import { logVerbose } from "./thread.runtime.js";
|
||||
@@ -15,7 +19,7 @@ export type SlackThreadStarter = {
|
||||
|
||||
type SlackThreadStarterCacheEntry = {
|
||||
value: SlackThreadStarter;
|
||||
cachedAt: number;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
const THREAD_STARTER_CACHE = new Map<string, SlackThreadStarterCacheEntry>();
|
||||
@@ -23,9 +27,13 @@ const THREAD_STARTER_CACHE_TTL_MS = 6 * 60 * 60_000;
|
||||
const THREAD_STARTER_CACHE_MAX = 2000;
|
||||
|
||||
function evictThreadStarterCache(): void {
|
||||
const now = Date.now();
|
||||
const now = asDateTimestampMs(Date.now());
|
||||
if (now === undefined) {
|
||||
THREAD_STARTER_CACHE.clear();
|
||||
return;
|
||||
}
|
||||
for (const [cacheKey, entry] of THREAD_STARTER_CACHE.entries()) {
|
||||
if (now - entry.cachedAt > THREAD_STARTER_CACHE_TTL_MS) {
|
||||
if (asDateTimestampMs(entry.expiresAt) === undefined || entry.expiresAt <= now) {
|
||||
THREAD_STARTER_CACHE.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
@@ -44,10 +52,11 @@ export async function resolveSlackThreadStarter(params: {
|
||||
evictThreadStarterCache();
|
||||
const cacheKey = `${params.channelId}:${params.threadTs}`;
|
||||
const cached = THREAD_STARTER_CACHE.get(cacheKey);
|
||||
if (cached && Date.now() - cached.cachedAt <= THREAD_STARTER_CACHE_TTL_MS) {
|
||||
return cached.value;
|
||||
}
|
||||
if (cached) {
|
||||
const now = asDateTimestampMs(Date.now());
|
||||
if (now !== undefined && cached.expiresAt > now) {
|
||||
return cached.value;
|
||||
}
|
||||
THREAD_STARTER_CACHE.delete(cacheKey);
|
||||
}
|
||||
try {
|
||||
@@ -78,14 +87,17 @@ export async function resolveSlackThreadStarter(params: {
|
||||
ts: message.ts,
|
||||
files,
|
||||
};
|
||||
if (THREAD_STARTER_CACHE.has(cacheKey)) {
|
||||
THREAD_STARTER_CACHE.delete(cacheKey);
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(THREAD_STARTER_CACHE_TTL_MS);
|
||||
if (expiresAt !== undefined) {
|
||||
if (THREAD_STARTER_CACHE.has(cacheKey)) {
|
||||
THREAD_STARTER_CACHE.delete(cacheKey);
|
||||
}
|
||||
THREAD_STARTER_CACHE.set(cacheKey, {
|
||||
value: starter,
|
||||
expiresAt,
|
||||
});
|
||||
evictThreadStarterCache();
|
||||
}
|
||||
THREAD_STARTER_CACHE.set(cacheKey, {
|
||||
value: starter,
|
||||
cachedAt: Date.now(),
|
||||
});
|
||||
evictThreadStarterCache();
|
||||
return starter;
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
|
||||
@@ -2076,6 +2076,118 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
expect(deliverReplies).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides Telegram commentary progress unless explicitly enabled", async () => {
|
||||
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => {
|
||||
await replyOptions?.onItemEvent?.({
|
||||
itemId: "preamble-1",
|
||||
kind: "preamble",
|
||||
progressText: "Checking private context before replying.",
|
||||
});
|
||||
return { queuedFinal: false };
|
||||
});
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext(),
|
||||
streamMode: "progress",
|
||||
telegramCfg: { streaming: { mode: "progress", progress: { label: false } } },
|
||||
});
|
||||
|
||||
expect(answerDraftStream.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows opt-in Telegram commentary progress through the shared progress draft", async () => {
|
||||
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => {
|
||||
await replyOptions?.onItemEvent?.({
|
||||
itemId: "preamble-1",
|
||||
kind: "preamble",
|
||||
progressText: "Checking the current weather source before summarizing.",
|
||||
});
|
||||
await replyOptions?.onItemEvent?.({
|
||||
itemId: "preamble-1",
|
||||
kind: "preamble",
|
||||
progressText: "Checking the current weather source before summarizing clearly.",
|
||||
});
|
||||
await replyOptions?.onItemEvent?.({
|
||||
itemId: "preamble-2",
|
||||
kind: "preamble",
|
||||
progressText: "[[reply_to_current]] Checking route impacts.",
|
||||
});
|
||||
await replyOptions?.onItemEvent?.({
|
||||
itemId: "preamble-2",
|
||||
kind: "preamble",
|
||||
progressText: "NO_REPLY",
|
||||
});
|
||||
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
|
||||
return { queuedFinal: false };
|
||||
});
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext(),
|
||||
streamMode: "progress",
|
||||
telegramCfg: {
|
||||
streaming: {
|
||||
mode: "progress",
|
||||
progress: {
|
||||
label: false,
|
||||
toolProgress: false,
|
||||
commentary: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(answerDraftStream.update).toHaveBeenLastCalledWith(
|
||||
"_Checking the current weather source before summarizing clearly._",
|
||||
);
|
||||
const updates = answerDraftStream.update.mock.calls.map((call) => call[0]).join("\n");
|
||||
expect(updates).not.toContain("Exec");
|
||||
expect(updates).not.toContain("reply_to_current");
|
||||
expect(updates).not.toContain("NO_REPLY");
|
||||
});
|
||||
|
||||
it("keeps Telegram progress drafts usable after the last commentary line becomes silent", async () => {
|
||||
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => {
|
||||
await replyOptions?.onItemEvent?.({
|
||||
itemId: "preamble-1",
|
||||
kind: "preamble",
|
||||
progressText: "Temporary note.",
|
||||
});
|
||||
await replyOptions?.onItemEvent?.({
|
||||
itemId: "preamble-1",
|
||||
kind: "preamble",
|
||||
progressText: "NO_REPLY",
|
||||
});
|
||||
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
|
||||
return { queuedFinal: false };
|
||||
});
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext(),
|
||||
streamMode: "progress",
|
||||
telegramCfg: {
|
||||
streaming: {
|
||||
mode: "progress",
|
||||
progress: {
|
||||
label: false,
|
||||
commentary: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(answerDraftStream.clear).toHaveBeenCalled();
|
||||
expect(answerDraftStream.forceNewMessage).toHaveBeenCalled();
|
||||
const clearOrder = answerDraftStream.clear.mock.invocationCallOrder[0];
|
||||
const forceNewMessageOrder = answerDraftStream.forceNewMessage.mock.invocationCallOrder[0];
|
||||
const lastUpdateOrder = answerDraftStream.update.mock.invocationCallOrder.at(-1)!;
|
||||
expect(clearOrder).toBeLessThan(forceNewMessageOrder);
|
||||
expect(forceNewMessageOrder).toBeLessThan(lastUpdateOrder);
|
||||
expect(answerDraftStream.update).toHaveBeenLastCalledWith("`🛠️ Exec`");
|
||||
});
|
||||
|
||||
it("does not restart progress drafts after final answer delivery", async () => {
|
||||
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user