mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-09 23:41:55 +08:00
Compare commits
1 Commits
fix-plugin
...
feat/code-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2f893c14a |
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/model-catalog-core/package.json', 'packages/memory-host-sdk/package.json', 'scripts/build-all.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entries.mjs', 'tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'src/plugin-sdk/**', 'packages/llm-core/src/**', 'packages/model-catalog-core/src/**', 'packages/memory-host-sdk/src/**', 'src/types/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'scripts/copy-export-html-templates.ts', 'scripts/lib/copy-assets.ts', 'src/auto-reply/reply/export-html/**') }}
|
||||
key: ${{ runner.os }}-build-all-v3-${{ hashFiles('package.json', 'pnpm-lock.yaml', 'npm-shrinkwrap.json', 'packages/plugin-sdk/package.json', 'packages/llm-core/package.json', 'packages/memory-host-sdk/package.json', 'scripts/build-all.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entries.mjs', 'tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'src/plugin-sdk/**', 'packages/llm-core/src/**', 'packages/memory-host-sdk/src/**', 'src/types/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'scripts/copy-export-html-templates.ts', 'scripts/lib/copy-assets.ts', 'src/auto-reply/reply/export-html/**') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-all-v3-
|
||||
|
||||
@@ -1403,7 +1403,7 @@ jobs:
|
||||
packages/plugin-sdk/dist
|
||||
extensions/*/dist/.boundary-tsc.tsbuildinfo
|
||||
extensions/*/dist/.boundary-tsc.stamp
|
||||
key: ${{ runner.os }}-extension-package-boundary-v1-${{ hashFiles('tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'packages/plugin-sdk/tsconfig.json', 'packages/llm-core/package.json', 'packages/model-catalog-core/package.json', 'scripts/check-extension-package-tsc-boundary.mjs', 'scripts/prepare-extension-package-boundary-artifacts.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-entries.mjs', 'src/plugin-sdk/**', 'src/auto-reply/**', 'packages/llm-core/src/**', 'packages/model-catalog-core/src/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'src/types/**', 'extensions/**', 'extensions/tsconfig.package-boundary*.json', 'package.json', 'pnpm-lock.yaml') }}
|
||||
key: ${{ runner.os }}-extension-package-boundary-v1-${{ hashFiles('tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'packages/plugin-sdk/tsconfig.json', 'packages/llm-core/package.json', 'scripts/check-extension-package-tsc-boundary.mjs', 'scripts/prepare-extension-package-boundary-artifacts.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-entries.mjs', 'src/plugin-sdk/**', 'src/auto-reply/**', 'packages/llm-core/src/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'src/types/**', 'extensions/**', 'extensions/tsconfig.package-boundary*.json', 'package.json', 'pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-extension-package-boundary-v1-
|
||||
|
||||
@@ -1425,17 +1425,11 @@ 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,8 +36,6 @@ 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.
|
||||
@@ -46,8 +44,6 @@ 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,8 +5,6 @@
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
- Added hosted push relay defaults, realtime Talk playback, and safer WebSocket ping handling for mobile sessions.
|
||||
- Updated App Store screenshots to cover Gateway pairing, Command, Chat, Talk, Agent, and Settings flows.
|
||||
- Highlighted realtime Talk relay, Gateway connection status, node capabilities, push wake, and privacy controls.
|
||||
|
||||
## 2026.5.28 - 2026-05-28
|
||||
|
||||
|
||||
@@ -29,14 +29,6 @@ def clear_empty_env_var(key)
|
||||
ENV.delete(key) unless env_present?(ENV[key])
|
||||
end
|
||||
|
||||
def screenshot_upload_requested?
|
||||
ENV["DELIVER_SCREENSHOTS"] == "1"
|
||||
end
|
||||
|
||||
def screenshot_paths
|
||||
Dir[File.join(__dir__, "screenshots", "**", "*.png")]
|
||||
end
|
||||
|
||||
def maybe_decode_hex_keychain_secret(value)
|
||||
return value unless env_present?(value)
|
||||
|
||||
@@ -322,7 +314,6 @@ platform :ios do
|
||||
desc "Upload App Store metadata (and optionally screenshots)"
|
||||
lane :metadata do
|
||||
sync_ios_versioning!
|
||||
version_metadata = read_ios_version_metadata
|
||||
api_key = asc_api_key
|
||||
clear_empty_env_var("APP_STORE_CONNECT_API_KEY_PATH")
|
||||
app_identifier = ENV["ASC_APP_IDENTIFIER"]
|
||||
@@ -330,21 +321,11 @@ platform :ios do
|
||||
app_identifier = nil unless env_present?(app_identifier)
|
||||
app_id = nil unless env_present?(app_id)
|
||||
|
||||
if screenshot_upload_requested? && screenshot_paths.empty?
|
||||
UI.user_error!("DELIVER_SCREENSHOTS=1 but no PNG screenshots were found under apps/ios/fastlane/screenshots.")
|
||||
end
|
||||
|
||||
deliver_options = {
|
||||
api_key: api_key,
|
||||
force: true,
|
||||
app_version: version_metadata[:short_version],
|
||||
copyright: "2026 OpenClaw",
|
||||
primary_category: "PRODUCTIVITY",
|
||||
secondary_category: "UTILITIES",
|
||||
skip_screenshots: !screenshot_upload_requested?,
|
||||
skip_screenshots: ENV["DELIVER_SCREENSHOTS"] != "1",
|
||||
skip_metadata: ENV["DELIVER_METADATA"] != "1",
|
||||
skip_binary_upload: true,
|
||||
overwrite_screenshots: screenshot_upload_requested?,
|
||||
run_precheck_before_submit: false
|
||||
}
|
||||
deliver_options[:app_identifier] = app_identifier if app_identifier
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
OpenClaw is a personal AI assistant you run on your own devices.
|
||||
|
||||
Pair this iPhone app with your OpenClaw Gateway to use your phone as a secure node for chat, voice, approvals, sharing, and device-aware automation.
|
||||
Pair this iPhone app with your OpenClaw Gateway to connect your phone as a secure node for voice, camera, and device automation.
|
||||
|
||||
What you can do:
|
||||
- Pair with your private OpenClaw Gateway by QR code or setup code
|
||||
- Chat with your assistant from iPhone
|
||||
- Use realtime Talk mode and push-to-talk
|
||||
- Review Gateway action approvals from your phone
|
||||
- Use voice wake and push-to-talk
|
||||
- Capture photos and short clips on request
|
||||
- Record screen snippets for troubleshooting and workflows
|
||||
- Share text, links, and media directly from iOS into OpenClaw
|
||||
- Enable device capabilities such as camera, screen, location, photos, contacts, calendar, and reminders when you choose
|
||||
- Receive push wakes and node status updates for connected workflows
|
||||
- Run location-aware and device-aware automations
|
||||
|
||||
OpenClaw is local-first: you control your gateway, keys, configuration, and permissions. Device access is managed by iOS permissions and can be enabled only for the capabilities you want to use.
|
||||
OpenClaw is local-first: you control your gateway, keys, and configuration.
|
||||
|
||||
Getting started:
|
||||
1) Set up your OpenClaw Gateway
|
||||
2) Open the iOS app and pair with your gateway
|
||||
3) Start using chat, Talk mode, approvals, and automations from your phone
|
||||
3) Start using commands and automations from your phone
|
||||
|
||||
@@ -1 +1 @@
|
||||
openclaw,ai assistant,local ai,iphone ai,voice assistant,automation,gateway,chat,agent
|
||||
openclaw,ai assistant,local ai,voice assistant,automation,gateway,chat,agent,node
|
||||
|
||||
@@ -1 +1 @@
|
||||
Pair your iPhone with your OpenClaw Gateway for chat, realtime voice, approvals, device capabilities, and private automation.
|
||||
Run OpenClaw from your iPhone: pair with your own gateway, trigger automations, and use voice, camera, and share actions.
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
Maintenance update for the current OpenClaw release.
|
||||
|
||||
- Added hosted push relay defaults, realtime Talk playback, and safer WebSocket ping handling for mobile sessions.
|
||||
- Updated App Store screenshots to cover Gateway pairing, Command, Chat, Talk, Agent, and Settings flows.
|
||||
- Highlighted realtime Talk relay, Gateway connection status, node capabilities, push wake, and privacy controls.
|
||||
|
||||
@@ -326,8 +326,6 @@ Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`):
|
||||
openclaw plugins install -l ./my-plugin
|
||||
```
|
||||
|
||||
Standalone plugin files must be listed in `plugins.load.paths` rather than placed directly in `~/.openclaw/extensions` or `<workspace>/.openclaw/extensions`. Those auto-discovered roots load plugin package or bundle directories, while top-level script files are treated as local helpers and skipped.
|
||||
|
||||
<Note>
|
||||
`--force` is not supported with `--link` because linked installs reuse the source path instead of copying over a managed install target.
|
||||
|
||||
|
||||
@@ -214,8 +214,7 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and
|
||||
}
|
||||
```
|
||||
|
||||
- Loaded from package or bundle directories under `~/.openclaw/extensions` and `<workspace>/.openclaw/extensions`, plus files or directories listed in `plugins.load.paths`.
|
||||
- Put standalone plugin files in `plugins.load.paths`; auto-discovered extension roots ignore top-level `.js`, `.mjs`, and `.ts` files so helper scripts in those roots do not block startup.
|
||||
- Loaded from `~/.openclaw/extensions`, `<workspace>/.openclaw/extensions`, plus `plugins.load.paths`.
|
||||
- Discovery accepts native OpenClaw plugins plus compatible Codex bundles and Claude bundles, including manifestless Claude default-layout bundles.
|
||||
- **Config changes require a gateway restart.**
|
||||
- `allow`: optional allowlist (only listed plugins load). `deny` wins.
|
||||
|
||||
@@ -7,7 +7,7 @@ read_when:
|
||||
title: "iOS app"
|
||||
---
|
||||
|
||||
Availability: iPhone app builds are distributed through Apple channels when enabled for a release. Local development builds can also run from source.
|
||||
Availability: internal preview. The iOS app is not publicly distributed yet.
|
||||
|
||||
## What it does
|
||||
|
||||
|
||||
@@ -108,18 +108,6 @@ Workboard also exposes optional agent tools for board-aware workflows:
|
||||
final summaries, proof, artifacts, created-card manifests, and blocker
|
||||
reasons. Created-card manifests must reference cards linked back to the
|
||||
completed card, which keeps phantom children out of summaries.
|
||||
- `workboard_board_create`, `workboard_board_archive`, and
|
||||
`workboard_board_delete` manage persisted board metadata such as display name,
|
||||
description, archive state, and default workspace.
|
||||
- `workboard_runs` returns the persisted run-attempt history stored on a card.
|
||||
- `workboard_specify` turns a rough triage or backlog card into a clarified
|
||||
`todo` card and records the specification summary on the card.
|
||||
- `workboard_decompose` fans a parent orchestration card into linked children,
|
||||
inherits board and tenant metadata, and can complete the parent with a
|
||||
created-card manifest.
|
||||
- `workboard_notify_subscribe`, `workboard_notify_list`, and
|
||||
`workboard_notify_unsubscribe` manage notification subscriptions in plugin
|
||||
state so operators and agents can discover durable notification intent.
|
||||
- `workboard_boards`, `workboard_stats`, `workboard_promote`,
|
||||
`workboard_reassign`, `workboard_reclaim`, `workboard_comment`,
|
||||
`workboard_proof`, `workboard_unblock`, and `workboard_dispatch` let an agent
|
||||
@@ -131,12 +119,6 @@ Claimed cards reject agent-tool mutations from other agents unless the caller
|
||||
has the claim token returned by `workboard_claim`. Dashboard operators still use
|
||||
the normal Gateway RPC surface and can recover or reassign cards.
|
||||
|
||||
Workboard stores all durable board data through the plugin SQLite key-value
|
||||
store. Cards live in `workboard.cards`, board metadata in `workboard.boards`,
|
||||
and notification subscriptions in `workboard.notify`. Run history, comments,
|
||||
proof, artifacts, diagnostics, dependencies, lifecycle events, and automation
|
||||
metadata stay on the card record so a card export remains self-contained.
|
||||
|
||||
Workboard diagnostics are computed from local card metadata. The built-in checks
|
||||
flag assigned cards that wait too long, running cards without recent heartbeat,
|
||||
blocked cards that need attention, repeated failures, done cards without proof,
|
||||
@@ -144,9 +126,9 @@ and running cards that only have a loose session link.
|
||||
|
||||
Dispatch is intentionally Gateway-local. It does not spawn arbitrary operating
|
||||
system processes; normal OpenClaw sessions still own execution. A dispatch nudge
|
||||
promotes dependency-ready cards, records dispatch metadata on ready cards,
|
||||
blocks expired claims or timed-out runs, and leaves durable notification
|
||||
subscriptions for the caller that delivers notifications.
|
||||
promotes dependency-ready cards, records dispatch metadata on ready cards, and
|
||||
blocks expired claims or timed-out runs so operators can recover them from the
|
||||
board.
|
||||
|
||||
## Session lifecycle sync
|
||||
|
||||
|
||||
@@ -4130,50 +4130,6 @@ describe("active-memory plugin", () => {
|
||||
expect(cached?.summary).toBe("memory 1");
|
||||
});
|
||||
|
||||
it("drops cached active-memory results when the current clock is not a valid date timestamp", () => {
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
||||
const cacheKey = testing.buildCacheKey({
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:invalid-clock-cache",
|
||||
query: "cache invalid clock prompt",
|
||||
});
|
||||
testing.setCachedResult(
|
||||
cacheKey,
|
||||
{
|
||||
status: "ok",
|
||||
elapsedMs: 1,
|
||||
rawReply: "memory",
|
||||
summary: "memory",
|
||||
},
|
||||
15_000,
|
||||
);
|
||||
|
||||
nowSpy.mockReturnValue(Number.NaN);
|
||||
|
||||
expect(testing.getCachedResult(cacheKey)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not cache active-memory results when the expiry timestamp would exceed the valid date range", () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
|
||||
const cacheKey = testing.buildCacheKey({
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:overflow-cache",
|
||||
query: "cache overflow prompt",
|
||||
});
|
||||
testing.setCachedResult(
|
||||
cacheKey,
|
||||
{
|
||||
status: "ok",
|
||||
elapsedMs: 1,
|
||||
rawReply: "memory",
|
||||
summary: "memory",
|
||||
},
|
||||
15_000,
|
||||
);
|
||||
|
||||
expect(testing.getCachedResult(cacheKey)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips recall after consecutive timeouts when circuit breaker trips (#74054)", async () => {
|
||||
const CONFIGURED_TIMEOUT_MS = 25;
|
||||
testing.setMinimumTimeoutMsForTests(1);
|
||||
|
||||
@@ -13,11 +13,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/agent-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { closeActiveMemorySearchManager } from "openclaw/plugin-sdk/memory-host-search";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
parseStrictPositiveInteger,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
resolveLivePluginConfigObject,
|
||||
resolvePluginConfigObject,
|
||||
@@ -1364,12 +1360,7 @@ function getCachedResult(cacheKey: string): ActiveRecallResult | undefined {
|
||||
if (!cached) {
|
||||
return undefined;
|
||||
}
|
||||
const now = asDateTimestampMs(Date.now());
|
||||
if (
|
||||
now === undefined ||
|
||||
asDateTimestampMs(cached.expiresAt) === undefined ||
|
||||
cached.expiresAt <= now
|
||||
) {
|
||||
if (cached.expiresAt <= Date.now()) {
|
||||
activeRecallCache.delete(cacheKey);
|
||||
return undefined;
|
||||
}
|
||||
@@ -1377,27 +1368,19 @@ function getCachedResult(cacheKey: string): ActiveRecallResult | undefined {
|
||||
}
|
||||
|
||||
function setCachedResult(cacheKey: string, result: ActiveRecallResult, ttlMs: number): void {
|
||||
const rawNow = Date.now();
|
||||
const now = asDateTimestampMs(rawNow);
|
||||
const now = Date.now();
|
||||
if (
|
||||
activeRecallCache.size >= DEFAULT_MAX_CACHE_ENTRIES ||
|
||||
(now !== undefined && now - lastActiveRecallCacheSweepAt >= CACHE_SWEEP_INTERVAL_MS)
|
||||
now - lastActiveRecallCacheSweepAt >= CACHE_SWEEP_INTERVAL_MS
|
||||
) {
|
||||
sweepExpiredCacheEntries(now);
|
||||
if (now !== undefined) {
|
||||
lastActiveRecallCacheSweepAt = now;
|
||||
}
|
||||
}
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(ttlMs, { nowMs: rawNow });
|
||||
if (expiresAt === undefined) {
|
||||
activeRecallCache.delete(cacheKey);
|
||||
return;
|
||||
lastActiveRecallCacheSweepAt = now;
|
||||
}
|
||||
if (activeRecallCache.has(cacheKey)) {
|
||||
activeRecallCache.delete(cacheKey);
|
||||
}
|
||||
activeRecallCache.set(cacheKey, {
|
||||
expiresAt,
|
||||
expiresAt: now + ttlMs,
|
||||
result,
|
||||
});
|
||||
while (activeRecallCache.size > DEFAULT_MAX_CACHE_ENTRIES) {
|
||||
@@ -1409,13 +1392,9 @@ function setCachedResult(cacheKey: string, result: ActiveRecallResult, ttlMs: nu
|
||||
}
|
||||
}
|
||||
|
||||
function sweepExpiredCacheEntries(now = asDateTimestampMs(Date.now())): void {
|
||||
if (now === undefined) {
|
||||
activeRecallCache.clear();
|
||||
return;
|
||||
}
|
||||
function sweepExpiredCacheEntries(now = Date.now()): void {
|
||||
for (const [cacheKey, cached] of activeRecallCache.entries()) {
|
||||
if (asDateTimestampMs(cached.expiresAt) === undefined || cached.expiresAt <= now) {
|
||||
if (cached.expiresAt <= now) {
|
||||
activeRecallCache.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,32 +230,6 @@ describe("bedrock mantle discovery", () => {
|
||||
expect(getCachedIamToken("us-east-1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not cache generated IAM tokens when ttl expiry overflows", async () => {
|
||||
const tokenProvider = vi
|
||||
.fn<() => Promise<string>>()
|
||||
.mockResolvedValueOnce("bedrock-overflow-token-1") // pragma: allowlist secret
|
||||
.mockResolvedValueOnce("bedrock-overflow-token-2"); // pragma: allowlist secret
|
||||
const tokenProviderFactory = createTokenProviderFactory(tokenProvider);
|
||||
|
||||
await expect(
|
||||
generateBearerTokenFromIam({
|
||||
region: "us-east-1",
|
||||
now: () => 8_640_000_000_000_000,
|
||||
tokenProviderFactory,
|
||||
}),
|
||||
).resolves.toBe("bedrock-overflow-token-1");
|
||||
expect(getCachedIamToken("us-east-1")).toBeUndefined();
|
||||
|
||||
await expect(
|
||||
generateBearerTokenFromIam({
|
||||
region: "us-east-1",
|
||||
now: () => 8_640_000_000_000_000,
|
||||
tokenProviderFactory,
|
||||
}),
|
||||
).resolves.toBe("bedrock-overflow-token-2");
|
||||
expect(tokenProvider).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model discovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/core";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import {
|
||||
isFutureDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import type {
|
||||
ModelDefinitionConfig,
|
||||
ModelProviderConfig,
|
||||
@@ -96,10 +92,9 @@ function getCachedIamTokenEntry(
|
||||
now: number = Date.now(),
|
||||
): { token: string; expiresAt: number } | undefined {
|
||||
const cached = iamTokenCache.get(region);
|
||||
if (cached && isFutureDateTimestampMs(cached.expiresAt, { nowMs: now })) {
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached;
|
||||
}
|
||||
iamTokenCache.delete(region);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -128,10 +123,7 @@ export async function generateBearerTokenFromIam(params: {
|
||||
region: params.region,
|
||||
expiresInSeconds: 7200, // 2 hours
|
||||
})();
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(IAM_TOKEN_TTL_MS, { nowMs: now });
|
||||
if (expiresAt !== undefined) {
|
||||
iamTokenCache.set(params.region, { token, expiresAt });
|
||||
}
|
||||
iamTokenCache.set(params.region, { token, expiresAt: now + IAM_TOKEN_TTL_MS });
|
||||
return token;
|
||||
} catch (error) {
|
||||
log.debug?.("Mantle IAM token generation unavailable", {
|
||||
|
||||
@@ -256,28 +256,6 @@ describe("bedrock discovery", () => {
|
||||
expect(sendMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("skips cache when refreshInterval expiry overflows", async () => {
|
||||
sendMock
|
||||
.mockResolvedValueOnce({ modelSummaries: [baseActiveAnthropicSummary] })
|
||||
.mockResolvedValueOnce({ inferenceProfileSummaries: [] })
|
||||
.mockResolvedValueOnce({ modelSummaries: [baseActiveAnthropicSummary] })
|
||||
.mockResolvedValueOnce({ inferenceProfileSummaries: [] });
|
||||
|
||||
await discoverBedrockModels({
|
||||
region: "us-east-1",
|
||||
config: { refreshInterval: 1 },
|
||||
now: () => 8_640_000_000_000_000,
|
||||
clientFactory,
|
||||
});
|
||||
await discoverBedrockModels({
|
||||
region: "us-east-1",
|
||||
config: { refreshInterval: 1 },
|
||||
now: () => 8_640_000_000_000_000,
|
||||
clientFactory,
|
||||
});
|
||||
expect(sendMock).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("skips cache when refreshInterval is 0", async () => {
|
||||
sendMock
|
||||
.mockResolvedValueOnce({ modelSummaries: [baseActiveAnthropicSummary] })
|
||||
|
||||
@@ -5,10 +5,6 @@ import {
|
||||
} from "@aws-sdk/client-bedrock";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/core";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import {
|
||||
isFutureDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationSeconds,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import type {
|
||||
BedrockDiscoveryConfig,
|
||||
ModelDefinitionConfig,
|
||||
@@ -507,16 +503,11 @@ export async function discoverBedrockModels(params: {
|
||||
|
||||
if (refreshIntervalSeconds > 0) {
|
||||
const cached = discoveryCache.get(cacheKey);
|
||||
if (cached && isFutureDateTimestampMs(cached.expiresAt, { nowMs: now })) {
|
||||
if (cached.value) {
|
||||
return cached.value;
|
||||
}
|
||||
if (cached.inFlight) {
|
||||
return cached.inFlight;
|
||||
}
|
||||
if (cached?.value && cached.expiresAt > now) {
|
||||
return cached.value;
|
||||
}
|
||||
if (cached) {
|
||||
discoveryCache.delete(cacheKey);
|
||||
if (cached?.inFlight) {
|
||||
return cached.inFlight;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -590,27 +581,19 @@ export async function discoverBedrockModels(params: {
|
||||
})();
|
||||
|
||||
if (refreshIntervalSeconds > 0) {
|
||||
const expiresAt = resolveExpiresAtMsFromDurationSeconds(refreshIntervalSeconds, { nowMs: now });
|
||||
if (expiresAt !== undefined) {
|
||||
discoveryCache.set(cacheKey, {
|
||||
expiresAt,
|
||||
inFlight: discoveryPromise,
|
||||
});
|
||||
}
|
||||
discoveryCache.set(cacheKey, {
|
||||
expiresAt: now + refreshIntervalSeconds * 1000,
|
||||
inFlight: discoveryPromise,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const value = await discoveryPromise;
|
||||
if (refreshIntervalSeconds > 0) {
|
||||
const expiresAt = resolveExpiresAtMsFromDurationSeconds(refreshIntervalSeconds, {
|
||||
nowMs: now,
|
||||
discoveryCache.set(cacheKey, {
|
||||
expiresAt: now + refreshIntervalSeconds * 1000,
|
||||
value,
|
||||
});
|
||||
if (expiresAt !== undefined) {
|
||||
discoveryCache.set(cacheKey, {
|
||||
expiresAt,
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
return value;
|
||||
} catch (error) {
|
||||
|
||||
@@ -189,7 +189,7 @@ function resolveEffectiveExecHost(params: {
|
||||
|
||||
function readRuntimeSessionEntryBestEffort(sessionKey: string): SessionEntry | undefined {
|
||||
try {
|
||||
return getSessionEntry({ sessionKey, hydrateSkillPromptRefs: false });
|
||||
return getSessionEntry({ sessionKey });
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -77,31 +77,6 @@ describe("Codex app-server startup binding", () => {
|
||||
expect(savedBinding?.threadId).toBe("thread-existing");
|
||||
});
|
||||
|
||||
it("reuses the session record cache while sessions.json is unchanged", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const agentDir = path.join(tempDir, "agent");
|
||||
await writeExistingBinding(sessionFile, workspaceDir, { dynamicToolsFingerprint: "[]" });
|
||||
await writeSessionRecord(sessionFile, { totalTokens: 12_000 });
|
||||
const sessionsJson = path.join(path.dirname(sessionFile), "sessions.json");
|
||||
const readFileSpy = vi.spyOn(fs, "readFile");
|
||||
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
const binding = await rotateOversizedCodexAppServerStartupBinding({
|
||||
binding: await readCodexAppServerBinding(sessionFile),
|
||||
sessionFile,
|
||||
agentDir,
|
||||
config: undefined,
|
||||
});
|
||||
expect(binding?.threadId).toBe("thread-existing");
|
||||
}
|
||||
|
||||
const sessionStoreReads = readFileSpy.mock.calls.filter(
|
||||
([file]) => typeof file === "string" && file === sessionsJson,
|
||||
);
|
||||
expect(sessionStoreReads).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("checks native rollout token pressure under default compaction config", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
|
||||
@@ -30,14 +30,6 @@ const CODEX_APP_SERVER_BYTE_UNITS: Record<string, number> = {
|
||||
tb: 1024 * 1024 * 1024 * 1024,
|
||||
tib: 1024 * 1024 * 1024 * 1024,
|
||||
};
|
||||
type CodexSessionRecordCacheEntry = {
|
||||
sessionsFile: string;
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
record: (Record<string, unknown> & { sessionKey: string }) | undefined;
|
||||
};
|
||||
|
||||
const codexSessionRecordCache = new Map<string, CodexSessionRecordCacheEntry>();
|
||||
|
||||
function parseCodexAppServerByteLimit(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
@@ -120,34 +112,16 @@ async function readCodexSessionRecordForSessionFile(
|
||||
sessionFile: string,
|
||||
): Promise<(Record<string, unknown> & { sessionKey: string }) | undefined> {
|
||||
const sessionsFile = path.join(path.dirname(sessionFile), "sessions.json");
|
||||
const resolvedSessionFile = path.resolve(sessionFile);
|
||||
let stat: Awaited<ReturnType<typeof fs.stat>>;
|
||||
try {
|
||||
stat = await fs.stat(sessionsFile);
|
||||
} catch {
|
||||
codexSessionRecordCache.delete(resolvedSessionFile);
|
||||
return undefined;
|
||||
}
|
||||
const cached = codexSessionRecordCache.get(resolvedSessionFile);
|
||||
if (
|
||||
cached?.sessionsFile === sessionsFile &&
|
||||
cached.mtimeMs === stat.mtimeMs &&
|
||||
cached.size === stat.size
|
||||
) {
|
||||
return cached.record;
|
||||
}
|
||||
let store: JsonValue | undefined;
|
||||
try {
|
||||
store = JSON.parse(await fs.readFile(sessionsFile, "utf8")) as JsonValue;
|
||||
} catch {
|
||||
codexSessionRecordCache.delete(resolvedSessionFile);
|
||||
return undefined;
|
||||
}
|
||||
if (!isJsonObject(store)) {
|
||||
codexSessionRecordCache.delete(resolvedSessionFile);
|
||||
return undefined;
|
||||
}
|
||||
let found: (Record<string, unknown> & { sessionKey: string }) | undefined;
|
||||
const resolvedSessionFile = path.resolve(sessionFile);
|
||||
for (const [sessionKey, record] of Object.entries(store)) {
|
||||
if (!isJsonObject(record) || typeof record.sessionFile !== "string") {
|
||||
continue;
|
||||
@@ -155,16 +129,9 @@ async function readCodexSessionRecordForSessionFile(
|
||||
if (path.resolve(record.sessionFile) !== resolvedSessionFile) {
|
||||
continue;
|
||||
}
|
||||
found = { sessionKey, ...record };
|
||||
break;
|
||||
return { sessionKey, ...record };
|
||||
}
|
||||
codexSessionRecordCache.set(resolvedSessionFile, {
|
||||
sessionsFile,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
size: stat.size,
|
||||
record: found,
|
||||
});
|
||||
return found;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
type CodexAppServerRolloutTokenSnapshot = {
|
||||
|
||||
@@ -342,47 +342,6 @@ describe("Client.deployCommands", () => {
|
||||
await client.fetchChannel("c1");
|
||||
expect(get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not reuse cached REST objects while the process clock is invalid", async () => {
|
||||
const client = createInternalTestClient();
|
||||
const get = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: "c1", type: 0, name: "old" })
|
||||
.mockResolvedValueOnce({ id: "c1", type: 0, name: "fresh" })
|
||||
.mockResolvedValueOnce({ id: "c1", type: 0, name: "recovered" });
|
||||
attachRestMock(client, { get });
|
||||
|
||||
const first = await client.fetchChannel("c1");
|
||||
expect(first.name).toBe("old");
|
||||
|
||||
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
|
||||
const second = await client.fetchChannel("c1");
|
||||
|
||||
expect(second.name).toBe("fresh");
|
||||
|
||||
vi.mocked(Date.now).mockReturnValue(1_000);
|
||||
const third = await client.fetchChannel("c1");
|
||||
|
||||
expect(third.name).toBe("recovered");
|
||||
expect(get).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("does not cache REST objects when the cache expiry would exceed the Date range", async () => {
|
||||
const client = createInternalTestClient();
|
||||
const get = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: "c1", type: 0, name: "first" })
|
||||
.mockResolvedValueOnce({ id: "c1", type: 0, name: "second" });
|
||||
attachRestMock(client, { get });
|
||||
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
|
||||
|
||||
const first = await client.fetchChannel("c1");
|
||||
const second = await client.fetchChannel("c1");
|
||||
|
||||
expect(first.name).toBe("first");
|
||||
expect(second.name).toBe("second");
|
||||
expect(get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Client gateway event queue", () => {
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { GatewayDispatchEvents } from "discord-api-types/v10";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { getChannel, getGuild, getGuildMember, getUser } from "./api.js";
|
||||
import type { RequestClient } from "./rest.js";
|
||||
import { Guild, GuildMember, User, channelFactory, type StructureClient } from "./structures.js";
|
||||
@@ -83,23 +79,15 @@ export class DiscordEntityCache {
|
||||
|
||||
private async fetchCached<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
||||
const ttl = this.params.ttlMs ?? DEFAULT_REST_CACHE_TTL_MS;
|
||||
const rawNow = Date.now();
|
||||
const now = asDateTimestampMs(rawNow);
|
||||
if (ttl > 0) {
|
||||
const cached = this.entries.get(key) as CacheEntry<T> | undefined;
|
||||
if (cached && now !== undefined && cached.expiresAt > now) {
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.value;
|
||||
}
|
||||
if (cached) {
|
||||
this.entries.delete(key);
|
||||
}
|
||||
}
|
||||
const value = await fetcher();
|
||||
if (ttl > 0) {
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(ttl, { nowMs: rawNow });
|
||||
if (expiresAt !== undefined) {
|
||||
this.entries.set(key, { expiresAt, value });
|
||||
}
|
||||
this.entries.set(key, { expiresAt: Date.now() + ttl, value });
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { normalizeOptionalStringifiedId } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { ChannelType, Message } from "../internal/discord.js";
|
||||
@@ -34,22 +30,6 @@ export function resetDiscordChannelInfoCacheForTest() {
|
||||
DISCORD_CHANNEL_INFO_CACHE.clear();
|
||||
}
|
||||
|
||||
function resolveDiscordChannelInfoCacheExpiresAt(ttlMs: number, nowMs: number): number | undefined {
|
||||
return resolveExpiresAtMsFromDurationMs(ttlMs, { nowMs });
|
||||
}
|
||||
|
||||
function cacheDiscordChannelInfo(
|
||||
channelId: string,
|
||||
value: DiscordChannelInfo | null,
|
||||
ttlMs: number,
|
||||
nowMs: number,
|
||||
): void {
|
||||
const expiresAt = resolveDiscordChannelInfoCacheExpiresAt(ttlMs, nowMs);
|
||||
if (expiresAt !== undefined) {
|
||||
DISCORD_CHANNEL_INFO_CACHE.set(channelId, { value, expiresAt });
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDiscordChannelId(value: unknown): string {
|
||||
return normalizeOptionalStringifiedId(value) ?? "";
|
||||
}
|
||||
@@ -71,11 +51,9 @@ export async function resolveDiscordChannelInfo(
|
||||
client: DiscordChannelInfoClient,
|
||||
channelId: string,
|
||||
): Promise<DiscordChannelInfo | null> {
|
||||
const rawNow = Date.now();
|
||||
const now = asDateTimestampMs(rawNow);
|
||||
const cached = DISCORD_CHANNEL_INFO_CACHE.get(channelId);
|
||||
if (cached) {
|
||||
if (now !== undefined && cached.expiresAt > now) {
|
||||
if (cached.expiresAt > Date.now()) {
|
||||
return cached.value;
|
||||
}
|
||||
DISCORD_CHANNEL_INFO_CACHE.delete(channelId);
|
||||
@@ -83,7 +61,10 @@ export async function resolveDiscordChannelInfo(
|
||||
try {
|
||||
const channel = await client.fetchChannel(channelId);
|
||||
if (!channel) {
|
||||
cacheDiscordChannelInfo(channelId, null, DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS, rawNow);
|
||||
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
|
||||
value: null,
|
||||
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const channelInfo = resolveDiscordChannelInfoSafe(channel);
|
||||
@@ -99,11 +80,17 @@ export async function resolveDiscordChannelInfo(
|
||||
parentId: channelInfo.parentId,
|
||||
ownerId: channelInfo.ownerId,
|
||||
};
|
||||
cacheDiscordChannelInfo(channelId, payload, DISCORD_CHANNEL_INFO_CACHE_TTL_MS, rawNow);
|
||||
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
|
||||
value: payload,
|
||||
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_CACHE_TTL_MS,
|
||||
});
|
||||
return payload;
|
||||
} catch (err) {
|
||||
logVerbose(`discord: failed to fetch channel ${channelId}: ${String(err)}`);
|
||||
cacheDiscordChannelInfo(channelId, null, DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS, rawNow);
|
||||
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
|
||||
value: null,
|
||||
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
MessageReferenceType,
|
||||
StickerFormatType,
|
||||
} from "discord-api-types/v10";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ChannelType, type Client, type Message } from "../internal/discord.js";
|
||||
|
||||
const readRemoteMediaBuffer = vi.fn();
|
||||
@@ -65,10 +65,6 @@ beforeAll(async () => {
|
||||
} = await import("./message-utils.js"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function asMessage(payload: Record<string, unknown>): Message {
|
||||
return payload as unknown as Message;
|
||||
}
|
||||
@@ -1235,37 +1231,4 @@ describe("resolveDiscordChannelInfo", () => {
|
||||
expect(second).toBeNull();
|
||||
expect(fetchChannel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not reuse cached channel info while the process clock is invalid", async () => {
|
||||
const fetchChannel = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ type: ChannelType.GuildText, name: "old" })
|
||||
.mockResolvedValueOnce({ type: ChannelType.GuildText, name: "fresh" });
|
||||
const client = { fetchChannel } as unknown as Client;
|
||||
|
||||
const first = await resolveDiscordChannelInfo(client, "invalid-clock-channel");
|
||||
expect(first?.name).toBe("old");
|
||||
|
||||
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
|
||||
const second = await resolveDiscordChannelInfo(client, "invalid-clock-channel");
|
||||
|
||||
expect(second?.name).toBe("fresh");
|
||||
expect(fetchChannel).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not cache channel info when the cache expiry would exceed the Date range", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
|
||||
const fetchChannel = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ type: ChannelType.GuildText, name: "first" })
|
||||
.mockResolvedValueOnce({ type: ChannelType.GuildText, name: "second" });
|
||||
const client = { fetchChannel } as unknown as Client;
|
||||
|
||||
const first = await resolveDiscordChannelInfo(client, "overflow-cache-channel");
|
||||
const second = await resolveDiscordChannelInfo(client, "overflow-cache-channel");
|
||||
|
||||
expect(first?.name).toBe("first");
|
||||
expect(second?.name).toBe("second");
|
||||
expect(fetchChannel).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { PassThrough } from "node:stream";
|
||||
import type { DiscordAccountConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
buildRealtimeVoiceAgentConsultChatMessage,
|
||||
buildRealtimeVoiceAgentConsultPolicyInstructions,
|
||||
@@ -1501,14 +1497,10 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(DISCORD_REALTIME_WAKE_NAME_FOLLOWUP_TTL_MS);
|
||||
if (expiresAt === undefined) {
|
||||
return;
|
||||
}
|
||||
this.pendingWakeNameFollowup = {
|
||||
context,
|
||||
startedAt: turn?.startedAt ?? Date.now(),
|
||||
expiresAt,
|
||||
expiresAt: Date.now() + DISCORD_REALTIME_WAKE_NAME_FOLLOWUP_TTL_MS,
|
||||
};
|
||||
logger.info(
|
||||
`discord voice: realtime wake-name follow-up armed speaker=${context.speakerLabel} voiceSession=${this.params.entry.voiceSessionKey} agent=${this.params.entry.route.agentId}`,
|
||||
@@ -1518,9 +1510,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
|
||||
private consumePendingWakeNameFollowup(): TranscriptUtteranceAttribution | undefined {
|
||||
const pending = this.pendingWakeNameFollowup;
|
||||
this.pendingWakeNameFollowup = undefined;
|
||||
const now = asDateTimestampMs(Date.now());
|
||||
const expiresAt = pending ? asDateTimestampMs(pending.expiresAt) : undefined;
|
||||
if (!pending || now === undefined || expiresAt === undefined || now > expiresAt) {
|
||||
if (!pending || Date.now() > pending.expiresAt) {
|
||||
return undefined;
|
||||
}
|
||||
const currentTurn = this.peekPendingSpeakerTurn();
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DiscordRealtimeVoiceSession } from "./realtime.js";
|
||||
|
||||
type WakeNameFollowupTestSession = {
|
||||
armWakeNameFollowup: () => void;
|
||||
consumePendingWakeNameFollowup: () => unknown;
|
||||
pendingWakeNameFollowup?: unknown;
|
||||
speakerTurns: {
|
||||
consumeAudioContext: () => unknown;
|
||||
peekAudioTurn: () => unknown;
|
||||
};
|
||||
};
|
||||
|
||||
function createSession(): WakeNameFollowupTestSession {
|
||||
return new DiscordRealtimeVoiceSession({
|
||||
cfg: {},
|
||||
discordConfig: { voice: { realtime: {} } },
|
||||
entry: {
|
||||
voiceSessionKey: "voice-1",
|
||||
route: { agentId: "agent-1" },
|
||||
},
|
||||
mode: "agent-proxy",
|
||||
runAgentTurn: vi.fn(),
|
||||
} as never) as unknown as WakeNameFollowupTestSession;
|
||||
}
|
||||
|
||||
describe("DiscordRealtimeVoiceSession wake-name follow-up cache", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("arms and consumes a valid wake-name follow-up", () => {
|
||||
const session = createSession();
|
||||
session.speakerTurns = {
|
||||
consumeAudioContext: vi.fn(() => ({
|
||||
userId: "u1",
|
||||
speakerLabel: "Ada",
|
||||
senderIsOwner: true,
|
||||
})),
|
||||
peekAudioTurn: vi.fn(() => undefined),
|
||||
};
|
||||
|
||||
session.armWakeNameFollowup();
|
||||
|
||||
expect(session.consumePendingWakeNameFollowup()).toMatchObject({
|
||||
context: { userId: "u1", speakerLabel: "Ada" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not arm follow-ups when the expiry would exceed Date range", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(8_640_000_000_000_000));
|
||||
const session = createSession();
|
||||
session.speakerTurns = {
|
||||
consumeAudioContext: vi.fn(() => ({
|
||||
userId: "u1",
|
||||
speakerLabel: "Ada",
|
||||
senderIsOwner: true,
|
||||
})),
|
||||
peekAudioTurn: vi.fn(() => undefined),
|
||||
};
|
||||
|
||||
session.armWakeNameFollowup();
|
||||
|
||||
expect(session.pendingWakeNameFollowup).toBeUndefined();
|
||||
expect(session.consumePendingWakeNameFollowup()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Client } from "../internal/discord.js";
|
||||
import { DiscordVoiceSpeakerContextResolver } from "./speaker-context.js";
|
||||
|
||||
function createClient(fetchMember: ReturnType<typeof vi.fn>): Client {
|
||||
return {
|
||||
fetchMember,
|
||||
fetchUser: vi.fn(),
|
||||
} as unknown as Client;
|
||||
}
|
||||
|
||||
describe("DiscordVoiceSpeakerContextResolver", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("reuses cached speaker context for repeated speaker lookups", async () => {
|
||||
const fetchMember = vi.fn().mockResolvedValue({
|
||||
nickname: "Ada",
|
||||
roles: [],
|
||||
user: { id: "u1", username: "ada", globalName: "Ada" },
|
||||
});
|
||||
const resolver = new DiscordVoiceSpeakerContextResolver({
|
||||
client: createClient(fetchMember),
|
||||
});
|
||||
|
||||
await expect(resolver.resolveContext("g1", "u1")).resolves.toMatchObject({ label: "Ada" });
|
||||
await expect(resolver.resolveContext("g1", "u1")).resolves.toMatchObject({ label: "Ada" });
|
||||
|
||||
expect(fetchMember).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not cache speaker context when the cache expiry would exceed Date range", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(8_640_000_000_000_000));
|
||||
const fetchMember = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
nickname: "Ada",
|
||||
roles: [],
|
||||
user: { id: "u1", username: "ada", globalName: "Ada" },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
nickname: "Grace",
|
||||
roles: [],
|
||||
user: { id: "u1", username: "grace", globalName: "Grace" },
|
||||
});
|
||||
const resolver = new DiscordVoiceSpeakerContextResolver({
|
||||
client: createClient(fetchMember),
|
||||
});
|
||||
|
||||
await expect(resolver.resolveContext("g1", "u1")).resolves.toMatchObject({ label: "Ada" });
|
||||
await expect(resolver.resolveContext("g1", "u1")).resolves.toMatchObject({ label: "Grace" });
|
||||
|
||||
expect(fetchMember).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,3 @@
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import type { Client } from "../internal/discord.js";
|
||||
import { resolveDiscordOwnerAccess } from "../monitor/allow-list.js";
|
||||
import { formatDiscordUserTag } from "../monitor/format.js";
|
||||
@@ -108,9 +104,7 @@ export class DiscordVoiceSpeakerContextResolver {
|
||||
if (!cached) {
|
||||
return undefined;
|
||||
}
|
||||
const now = asDateTimestampMs(Date.now());
|
||||
const expiresAt = asDateTimestampMs(cached.expiresAt);
|
||||
if (now === undefined || expiresAt === undefined || expiresAt <= now) {
|
||||
if (cached.expiresAt <= Date.now()) {
|
||||
this.cache.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
@@ -125,12 +119,9 @@ export class DiscordVoiceSpeakerContextResolver {
|
||||
|
||||
private setCachedContext(guildId: string, userId: string, context: VoiceSpeakerContext): void {
|
||||
const key = this.resolveCacheKey(guildId, userId);
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(SPEAKER_CONTEXT_CACHE_TTL_MS);
|
||||
if (expiresAt !== undefined) {
|
||||
this.cache.set(key, {
|
||||
...context,
|
||||
expiresAt,
|
||||
});
|
||||
}
|
||||
this.cache.set(key, {
|
||||
...context,
|
||||
expiresAt: Date.now() + SPEAKER_CONTEXT_CACHE_TTL_MS,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,37 +84,6 @@ describe("resolveGroupName", () => {
|
||||
expect(mockGetChatInfo).toHaveBeenCalledOnce(); // only 1 API call
|
||||
});
|
||||
|
||||
it("does not cache group names when the expiry would exceed a valid Date", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(8_640_000_000_000_000));
|
||||
try {
|
||||
mockGetChatInfo.mockResolvedValue({ name: "Boundary Group" });
|
||||
|
||||
const first = await resolveGroupName({ account, chatId: "oc_boundary", log });
|
||||
const second = await resolveGroupName({ account, chatId: "oc_boundary", log });
|
||||
|
||||
expect(first).toBe("Boundary Group");
|
||||
expect(second).toBe("Boundary Group");
|
||||
expect(mockGetChatInfo).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("evicts cached group names when the current clock is invalid", async () => {
|
||||
mockGetChatInfo.mockResolvedValue({ name: "Cached Group" });
|
||||
await resolveGroupName({ account, chatId: "oc_invalid_clock", log });
|
||||
const dateNow = vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
|
||||
try {
|
||||
const result = await resolveGroupName({ account, chatId: "oc_invalid_clock", log });
|
||||
|
||||
expect(result).toBe("Cached Group");
|
||||
} finally {
|
||||
dateNow.mockRestore();
|
||||
}
|
||||
expect(mockGetChatInfo).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("caches negative result (API failure) and skips retry", async () => {
|
||||
mockGetChatInfo.mockRejectedValue(new Error("fail"));
|
||||
await resolveGroupName({ account, chatId: "oc_test5", log });
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveFeishuSenderName } from "./bot-sender-name.js";
|
||||
import { FeishuConfigSchema } from "./config-schema.js";
|
||||
import type { ResolvedFeishuAccount } from "./types.js";
|
||||
|
||||
const createFeishuClientMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
createFeishuClient: createFeishuClientMock,
|
||||
}));
|
||||
|
||||
const account = {
|
||||
accountId: "main",
|
||||
selectionSource: "explicit",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
appId: "app-id",
|
||||
appSecret: "secret",
|
||||
domain: "feishu",
|
||||
config: FeishuConfigSchema.parse({}),
|
||||
} satisfies ResolvedFeishuAccount;
|
||||
|
||||
function mockUserNames(...names: string[]): ReturnType<typeof vi.fn> {
|
||||
const get = vi.fn();
|
||||
for (const name of names) {
|
||||
get.mockResolvedValueOnce({ data: { user: { name } } });
|
||||
}
|
||||
createFeishuClientMock.mockReturnValue({
|
||||
contact: { user: { get } },
|
||||
});
|
||||
return get;
|
||||
}
|
||||
|
||||
describe("resolveFeishuSenderName", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
createFeishuClientMock.mockReset();
|
||||
});
|
||||
|
||||
it("reuses a cached sender name within the TTL", async () => {
|
||||
const get = mockUserNames("Ada");
|
||||
|
||||
await expect(
|
||||
resolveFeishuSenderName({ account, senderId: "ou_sender_cache", log: vi.fn() }),
|
||||
).resolves.toEqual({ name: "Ada" });
|
||||
await expect(
|
||||
resolveFeishuSenderName({ account, senderId: "ou_sender_cache", log: vi.fn() }),
|
||||
).resolves.toEqual({ name: "Ada" });
|
||||
|
||||
expect(get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not cache sender names when the expiry would exceed Date range", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(8_640_000_000_000_000));
|
||||
const get = mockUserNames("Ada", "Grace");
|
||||
|
||||
await expect(
|
||||
resolveFeishuSenderName({ account, senderId: "ou_sender_overflow", log: vi.fn() }),
|
||||
).resolves.toEqual({ name: "Ada" });
|
||||
await expect(
|
||||
resolveFeishuSenderName({ account, senderId: "ou_sender_overflow", log: vi.fn() }),
|
||||
).resolves.toEqual({ name: "Grace" });
|
||||
|
||||
expect(get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,3 @@
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import type { ResolvedFeishuAccount } from "./types.js";
|
||||
@@ -93,14 +89,10 @@ export async function resolveFeishuSenderName(params: {
|
||||
}
|
||||
|
||||
const cached = senderNameCache.get(normalizedSenderId);
|
||||
const now = asDateTimestampMs(Date.now());
|
||||
const cachedExpireAt = cached ? asDateTimestampMs(cached.expireAt) : undefined;
|
||||
if (cached && now !== undefined && cachedExpireAt !== undefined && cachedExpireAt > now) {
|
||||
const now = Date.now();
|
||||
if (cached && cached.expireAt > now) {
|
||||
return { name: cached.name };
|
||||
}
|
||||
if (cached) {
|
||||
senderNameCache.delete(normalizedSenderId);
|
||||
}
|
||||
|
||||
try {
|
||||
const client = createFeishuClient(account);
|
||||
@@ -113,10 +105,7 @@ export async function resolveFeishuSenderName(params: {
|
||||
const name = user?.name ?? user?.nickname ?? user?.en_name;
|
||||
|
||||
if (name) {
|
||||
const expireAt = resolveExpiresAtMsFromDurationMs(SENDER_NAME_TTL_MS);
|
||||
if (expireAt !== undefined) {
|
||||
senderNameCache.set(normalizedSenderId, { name, expireAt });
|
||||
}
|
||||
senderNameCache.set(normalizedSenderId, { name, expireAt: now + SENDER_NAME_TTL_MS });
|
||||
return { name };
|
||||
}
|
||||
return {};
|
||||
|
||||
@@ -270,45 +270,6 @@ describe("Feishu Card Action Handler", () => {
|
||||
expect(handleFeishuMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not open approval cards when the expiry would exceed a valid Date", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(8_640_000_000_000_000));
|
||||
try {
|
||||
const event: FeishuCardActionEvent = {
|
||||
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
|
||||
token: "tok4-boundary",
|
||||
action: {
|
||||
value: createFeishuCardInteractionEnvelope({
|
||||
k: "meta",
|
||||
a: FEISHU_APPROVAL_REQUEST_ACTION,
|
||||
m: {
|
||||
command: "/new",
|
||||
prompt: "Start a fresh session?",
|
||||
},
|
||||
c: {
|
||||
u: "u123",
|
||||
h: "chat1",
|
||||
t: "group",
|
||||
s: "agent:codex:feishu:chat:chat1",
|
||||
e: 8_640_000_000_000_000,
|
||||
},
|
||||
}),
|
||||
tag: "button",
|
||||
},
|
||||
context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
|
||||
};
|
||||
|
||||
await handleFeishuCardAction({ cfg, event, runtime, accountId: "main" });
|
||||
|
||||
expect(sendCardFeishuMock).not.toHaveBeenCalled();
|
||||
const sendMessage = sendMessageCall();
|
||||
expect(sendMessage.to).toBe("chat:chat1");
|
||||
expect(String(sendMessage.text)).toContain("payload is invalid");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("runs approval confirmation through the normal message path", async () => {
|
||||
const event = createStructuredQuickActionEvent({
|
||||
token: "tok5",
|
||||
@@ -415,39 +376,6 @@ describe("Feishu Card Action Handler", () => {
|
||||
expect(createFeishuClientMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not cache resolved chat type when expiry would exceed a valid Date", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(8_640_000_000_000_000));
|
||||
try {
|
||||
const getChat = vi.fn().mockResolvedValue({ code: 0, data: { chat_type: "p2p" } });
|
||||
createFeishuClientMock.mockReturnValue({
|
||||
im: {
|
||||
chat: {
|
||||
get: getChat,
|
||||
},
|
||||
},
|
||||
});
|
||||
const firstEvent = createCardActionEvent({
|
||||
token: "tok9b-boundary-1",
|
||||
chatId: "oc_dm_chat_boundary",
|
||||
actionValue: { text: "/help" },
|
||||
});
|
||||
const secondEvent = createCardActionEvent({
|
||||
token: "tok9b-boundary-2",
|
||||
chatId: "oc_dm_chat_boundary",
|
||||
actionValue: { text: "/help" },
|
||||
});
|
||||
|
||||
await handleFeishuCardAction({ cfg, event: firstEvent, runtime });
|
||||
await handleFeishuCardAction({ cfg, event: secondEvent, runtime });
|
||||
|
||||
expect(getChat).toHaveBeenCalledTimes(2);
|
||||
expect(handleFeishuMessage).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses resolved DM chat type when building approval cards without stored context", async () => {
|
||||
createFeishuClientMock.mockReturnValueOnce({
|
||||
im: {
|
||||
@@ -531,20 +459,6 @@ describe("Feishu Card Action Handler", () => {
|
||||
expect(handleFeishuMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not cache callback tokens when token ttl expiry overflows", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(8_640_000_000_000_000));
|
||||
const event = createCardActionEvent({
|
||||
token: "tok10-boundary",
|
||||
actionValue: { text: "/help" },
|
||||
});
|
||||
|
||||
await handleFeishuCardAction({ cfg, event, runtime });
|
||||
await handleFeishuCardAction({ cfg, event, runtime });
|
||||
|
||||
expect(handleFeishuMessage).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("rejects empty callback tokens before dispatch", async () => {
|
||||
const log = vi.fn();
|
||||
const event = createStructuredQuickActionEvent({
|
||||
|
||||
@@ -10,11 +10,7 @@ import {
|
||||
resolveConfiguredBindingRoute,
|
||||
resolveRuntimeConversationBindingRoute,
|
||||
} from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
parseStrictNonNegativeInteger,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
createChannelHistoryWindow,
|
||||
@@ -112,14 +108,9 @@ function isFeishuTopicSessionScope(scope: FeishuGroupSessionScope): boolean {
|
||||
}
|
||||
|
||||
function evictGroupNameCache(): void {
|
||||
const now = asDateTimestampMs(Date.now());
|
||||
if (now === undefined) {
|
||||
groupNameCache.clear();
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
for (const [key, val] of groupNameCache) {
|
||||
const expiresAt = asDateTimestampMs(val.expiresAt);
|
||||
if (expiresAt === undefined || expiresAt <= now) {
|
||||
if (val.expiresAt <= now) {
|
||||
groupNameCache.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -137,12 +128,9 @@ function evictGroupNameCache(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function setCacheEntry(key: string, name: string): void {
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(GROUP_NAME_CACHE_TTL_MS);
|
||||
function setCacheEntry(key: string, value: { name: string; expiresAt: number }): void {
|
||||
groupNameCache.delete(key);
|
||||
if (expiresAt !== undefined) {
|
||||
groupNameCache.set(key, { name, expiresAt });
|
||||
}
|
||||
groupNameCache.set(key, value);
|
||||
}
|
||||
|
||||
export function clearGroupNameCache(): void {
|
||||
@@ -162,34 +150,37 @@ export async function resolveGroupName(params: {
|
||||
const cacheKey = `${account.accountId}:${chatId}`;
|
||||
|
||||
const cached = groupNameCache.get(cacheKey);
|
||||
if (cached) {
|
||||
const now = asDateTimestampMs(Date.now());
|
||||
const expiresAt = asDateTimestampMs(cached.expiresAt);
|
||||
if (now !== undefined && expiresAt !== undefined && expiresAt > now) {
|
||||
return cached.name || undefined;
|
||||
}
|
||||
groupNameCache.delete(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.name || undefined;
|
||||
}
|
||||
|
||||
let resolvedName: string | undefined;
|
||||
try {
|
||||
const client = createFeishuClient(account);
|
||||
const chatInfo = await getChatInfo(client, chatId);
|
||||
const name = chatInfo?.name?.trim();
|
||||
if (name) {
|
||||
setCacheEntry(cacheKey, name);
|
||||
resolvedName = name;
|
||||
setCacheEntry(cacheKey, {
|
||||
name,
|
||||
expiresAt: Date.now() + GROUP_NAME_CACHE_TTL_MS,
|
||||
});
|
||||
} else {
|
||||
setCacheEntry(cacheKey, "");
|
||||
setCacheEntry(cacheKey, {
|
||||
name: "",
|
||||
expiresAt: Date.now() + GROUP_NAME_CACHE_TTL_MS,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
log(`feishu[${account.accountId}]: getChatInfo failed for ${chatId}: ${String(err)}`);
|
||||
setCacheEntry(cacheKey, "");
|
||||
setCacheEntry(cacheKey, {
|
||||
name: "",
|
||||
expiresAt: Date.now() + GROUP_NAME_CACHE_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
const result = groupNameCache.get(cacheKey)?.name || undefined;
|
||||
evictGroupNameCache();
|
||||
|
||||
return resolvedName;
|
||||
return result;
|
||||
}
|
||||
|
||||
async function resolveFeishuAudioPreflightTranscript(params: {
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
isFutureDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js";
|
||||
import { resolveFeishuRuntimeAccount } from "./accounts.js";
|
||||
import { handleFeishuMessage, type FeishuMessageEvent } from "./bot.js";
|
||||
@@ -52,26 +47,16 @@ export class FeishuRetryableCardActionError extends Error {
|
||||
|
||||
export function resetProcessedFeishuCardActionTokensForTests(): void {
|
||||
processedCardActionTokens.clear();
|
||||
resolvedChatTypeCache.clear();
|
||||
}
|
||||
|
||||
function pruneProcessedCardActionTokens(now: number): void {
|
||||
const validNow = asDateTimestampMs(now);
|
||||
if (validNow === undefined) {
|
||||
processedCardActionTokens.clear();
|
||||
return;
|
||||
}
|
||||
for (const [key, entry] of processedCardActionTokens.entries()) {
|
||||
if (!isFutureDateTimestampMs(entry.expiresAt, { nowMs: validNow })) {
|
||||
if (entry.expiresAt <= now) {
|
||||
processedCardActionTokens.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveProcessedCardActionTokenExpiresAt(now: number): number | undefined {
|
||||
return resolveExpiresAtMsFromDurationMs(FEISHU_CARD_ACTION_TOKEN_TTL_MS, { nowMs: now });
|
||||
}
|
||||
|
||||
function beginFeishuCardActionToken(params: {
|
||||
token: string;
|
||||
accountId: string;
|
||||
@@ -85,17 +70,13 @@ function beginFeishuCardActionToken(params: {
|
||||
}
|
||||
const key = `${params.accountId}:${normalizedToken}`;
|
||||
const existing = processedCardActionTokens.get(key);
|
||||
if (existing && isFutureDateTimestampMs(existing.expiresAt, { nowMs: now })) {
|
||||
if (existing && existing.expiresAt > now) {
|
||||
return false;
|
||||
}
|
||||
processedCardActionTokens.delete(key);
|
||||
const expiresAt = resolveProcessedCardActionTokenExpiresAt(now);
|
||||
if (expiresAt !== undefined) {
|
||||
processedCardActionTokens.set(key, {
|
||||
status: "inflight",
|
||||
expiresAt,
|
||||
});
|
||||
}
|
||||
processedCardActionTokens.set(key, {
|
||||
status: "inflight",
|
||||
expiresAt: now + FEISHU_CARD_ACTION_TOKEN_TTL_MS,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -109,15 +90,9 @@ function completeFeishuCardActionToken(params: {
|
||||
if (!normalizedToken) {
|
||||
return;
|
||||
}
|
||||
const key = `${params.accountId}:${normalizedToken}`;
|
||||
const expiresAt = resolveProcessedCardActionTokenExpiresAt(now);
|
||||
if (expiresAt === undefined) {
|
||||
processedCardActionTokens.delete(key);
|
||||
return;
|
||||
}
|
||||
processedCardActionTokens.set(key, {
|
||||
processedCardActionTokens.set(`${params.accountId}:${normalizedToken}`, {
|
||||
status: "completed",
|
||||
expiresAt,
|
||||
expiresAt: now + FEISHU_CARD_ACTION_TOKEN_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -210,14 +185,8 @@ const CHAT_TYPE_CACHE_TTL_MS = 30 * 60_000;
|
||||
const CHAT_TYPE_CACHE_MAX_SIZE = 5_000;
|
||||
|
||||
function pruneChatTypeCache(now: number): void {
|
||||
const validNow = asDateTimestampMs(now);
|
||||
if (validNow === undefined) {
|
||||
resolvedChatTypeCache.clear();
|
||||
return;
|
||||
}
|
||||
for (const [key, entry] of resolvedChatTypeCache.entries()) {
|
||||
const expiresAt = asDateTimestampMs(entry.expiresAt);
|
||||
if (expiresAt === undefined || expiresAt <= validNow) {
|
||||
if (entry.expiresAt <= now) {
|
||||
resolvedChatTypeCache.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -237,25 +206,6 @@ function sanitizeLogValue(v: string): string {
|
||||
return v.replace(/[\r\n]/g, " ").slice(0, 500);
|
||||
}
|
||||
|
||||
function resolveFeishuApprovalCardExpiresAt(nowRaw = Date.now()): number | undefined {
|
||||
const now = asDateTimestampMs(nowRaw);
|
||||
return now === undefined
|
||||
? undefined
|
||||
: resolveExpiresAtMsFromDurationMs(FEISHU_APPROVAL_CARD_TTL_MS, { nowMs: now });
|
||||
}
|
||||
|
||||
function cacheResolvedCardActionChatType(
|
||||
cacheKey: string,
|
||||
value: "p2p" | "group",
|
||||
now: number,
|
||||
): void {
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(CHAT_TYPE_CACHE_TTL_MS, { nowMs: now });
|
||||
resolvedChatTypeCache.delete(cacheKey);
|
||||
if (expiresAt !== undefined) {
|
||||
resolvedChatTypeCache.set(cacheKey, { value, expiresAt });
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveCardActionChatType(params: {
|
||||
event: FeishuCardActionEvent;
|
||||
account: ReturnType<typeof resolveFeishuRuntimeAccount>;
|
||||
@@ -276,12 +226,8 @@ async function resolveCardActionChatType(params: {
|
||||
const now = Date.now();
|
||||
pruneChatTypeCache(now);
|
||||
const cached = resolvedChatTypeCache.get(cacheKey);
|
||||
const cachedExpiresAt = cached ? asDateTimestampMs(cached.expiresAt) : undefined;
|
||||
if (cached && cachedExpiresAt !== undefined) {
|
||||
return cached.value;
|
||||
}
|
||||
if (cached) {
|
||||
resolvedChatTypeCache.delete(cacheKey);
|
||||
return cached.value;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -293,7 +239,10 @@ async function resolveCardActionChatType(params: {
|
||||
normalizeResolvedCardActionChatType(response.data?.chat_mode) ??
|
||||
normalizeResolvedCardActionChatType(response.data?.chat_type);
|
||||
if (resolvedChatType) {
|
||||
cacheResolvedCardActionChatType(cacheKey, resolvedChatType, now);
|
||||
resolvedChatTypeCache.set(cacheKey, {
|
||||
value: resolvedChatType,
|
||||
expiresAt: now + CHAT_TYPE_CACHE_TTL_MS,
|
||||
});
|
||||
return resolvedChatType;
|
||||
}
|
||||
params.log(
|
||||
@@ -400,17 +349,6 @@ export async function handleFeishuCardAction(params: {
|
||||
typeof envelope.m?.prompt === "string" && envelope.m.prompt.trim()
|
||||
? envelope.m.prompt
|
||||
: `Run \`${command}\` in this Feishu conversation?`;
|
||||
const expiresAt = resolveFeishuApprovalCardExpiresAt();
|
||||
if (expiresAt === undefined) {
|
||||
await sendInvalidInteractionNotice({
|
||||
cfg,
|
||||
event,
|
||||
reason: "malformed",
|
||||
accountId,
|
||||
});
|
||||
completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
|
||||
return;
|
||||
}
|
||||
await sendCardFeishu({
|
||||
cfg,
|
||||
to: resolveCallbackTarget(event),
|
||||
@@ -420,7 +358,7 @@ export async function handleFeishuCardAction(params: {
|
||||
command,
|
||||
prompt,
|
||||
sessionKey: envelope.c?.s,
|
||||
expiresAt,
|
||||
expiresAt: Date.now() + FEISHU_APPROVAL_CARD_TTL_MS,
|
||||
chatType: await resolveCardActionChatType({
|
||||
event,
|
||||
account,
|
||||
|
||||
@@ -88,25 +88,6 @@ describe("feishu quick-action launcher", () => {
|
||||
expectFirstSentCardUsesFillWidthOnly(sendCardFeishuMock);
|
||||
});
|
||||
|
||||
it("does not send launcher cards when expiry would exceed a valid Date", async () => {
|
||||
const runtime: RuntimeEnv = createRuntimeEnv();
|
||||
|
||||
const handled = await maybeHandleFeishuQuickActionMenu({
|
||||
cfg,
|
||||
eventKey: "quick-actions",
|
||||
operatorOpenId: "u123",
|
||||
accountId: "main",
|
||||
runtime,
|
||||
now: 8_640_000_000_000_000,
|
||||
});
|
||||
|
||||
expect(handled).toBe(false);
|
||||
expect(sendCardFeishuMock).not.toHaveBeenCalled();
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
"feishu[main]: failed to open quick-action launcher for u123: invalid expiry clock",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to legacy menu handling when launcher send fails", async () => {
|
||||
sendCardFeishuMock.mockRejectedValueOnce(new Error("network"));
|
||||
const runtime: RuntimeEnv = createRuntimeEnv();
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js";
|
||||
import { createFeishuCardInteractionEnvelope } from "./card-interaction.js";
|
||||
@@ -100,17 +96,7 @@ export async function maybeHandleFeishuQuickActionMenu(params: {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = asDateTimestampMs(params.now ?? Date.now());
|
||||
const expiresAt =
|
||||
now === undefined
|
||||
? undefined
|
||||
: resolveExpiresAtMsFromDurationMs(FEISHU_QUICK_ACTION_CARD_TTL_MS, { nowMs: now });
|
||||
if (expiresAt === undefined) {
|
||||
params.runtime?.log?.(
|
||||
`feishu[${params.accountId ?? "default"}]: failed to open quick-action launcher for ${params.operatorOpenId}: invalid expiry clock`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const expiresAt = (params.now ?? Date.now()) + FEISHU_QUICK_ACTION_CARD_TTL_MS;
|
||||
try {
|
||||
await sendCardFeishu({
|
||||
cfg: params.cfg,
|
||||
|
||||
@@ -187,32 +187,6 @@ describe("probeFeishu", () => {
|
||||
expect(requestFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not cache probe results when the expiry would exceed a valid Date", async () => {
|
||||
await withFakeTimers(async () => {
|
||||
vi.setSystemTime(new Date(8_640_000_000_000_000));
|
||||
const requestFn = setupSuccessClient();
|
||||
|
||||
const { first, second } = await readSequentialDefaultProbePair();
|
||||
|
||||
expect(first).toEqual(second);
|
||||
expect(requestFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it("evicts cached probe results when the current clock is invalid", async () => {
|
||||
const requestFn = setupSuccessClient();
|
||||
|
||||
await probeFeishu(DEFAULT_CREDS);
|
||||
const dateNow = vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
|
||||
try {
|
||||
await probeFeishu(DEFAULT_CREDS);
|
||||
} finally {
|
||||
dateNow.mockRestore();
|
||||
}
|
||||
|
||||
expect(requestFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("makes a fresh API call after cache expires", async () => {
|
||||
await withFakeTimers(async () => {
|
||||
const requestFn = setupSuccessClient();
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { raceWithTimeoutAndAbort } from "./async.js";
|
||||
import { createFeishuClient, type FeishuClientCredentials } from "./client.js";
|
||||
import type { FeishuProbeResult } from "./types.js";
|
||||
@@ -42,12 +38,7 @@ function setCachedProbeResult(
|
||||
result: FeishuProbeResult,
|
||||
ttlMs: number,
|
||||
): FeishuProbeResult {
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(ttlMs);
|
||||
if (expiresAt === undefined) {
|
||||
probeCache.delete(cacheKey);
|
||||
return result;
|
||||
}
|
||||
probeCache.set(cacheKey, { result, expiresAt });
|
||||
probeCache.set(cacheKey, { result, expiresAt: Date.now() + ttlMs });
|
||||
if (probeCache.size > MAX_PROBE_CACHE_SIZE) {
|
||||
const oldest = probeCache.keys().next().value;
|
||||
if (oldest !== undefined) {
|
||||
@@ -83,13 +74,8 @@ export async function probeFeishu(
|
||||
// pollute each other's cache entry.
|
||||
const cacheKey = creds.accountId ?? `${creds.appId}:${creds.appSecret.slice(0, 8)}`;
|
||||
const cached = probeCache.get(cacheKey);
|
||||
if (cached) {
|
||||
const now = asDateTimestampMs(Date.now());
|
||||
const expiresAt = asDateTimestampMs(cached.expiresAt);
|
||||
if (now !== undefined && expiresAt !== undefined && expiresAt > now) {
|
||||
return cached.result;
|
||||
}
|
||||
probeCache.delete(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.result;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -50,7 +50,6 @@ describe("FeishuStreamingSession", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -112,45 +111,6 @@ describe("FeishuStreamingSession", () => {
|
||||
);
|
||||
}
|
||||
|
||||
function mockStreamingTokenStart(resolveAuthJson: (token: string) => Record<string, unknown>): {
|
||||
authTokens: string[];
|
||||
client: ConstructorParameters<typeof FeishuStreamingSession>[0];
|
||||
} {
|
||||
const release = vi.fn(async () => {});
|
||||
const authTokens: string[] = [];
|
||||
fetchWithSsrFGuardMock.mockImplementation(
|
||||
async ({ url }: { url: string; init?: { body?: string } }) => {
|
||||
if (url.includes("/auth/")) {
|
||||
const token = `token-${authTokens.length + 1}`;
|
||||
authTokens.push(token);
|
||||
return {
|
||||
response: { ok: true, json: async () => resolveAuthJson(token) },
|
||||
release,
|
||||
};
|
||||
}
|
||||
return {
|
||||
response: {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
code: 0,
|
||||
msg: "ok",
|
||||
data: { card_id: `card-${authTokens.length}` },
|
||||
}),
|
||||
},
|
||||
release,
|
||||
};
|
||||
},
|
||||
);
|
||||
const client = {
|
||||
im: {
|
||||
message: {
|
||||
create: vi.fn(async () => ({ code: 0, msg: "ok", data: { message_id: "om_1" } })),
|
||||
},
|
||||
},
|
||||
} as unknown as ConstructorParameters<typeof FeishuStreamingSession>[0];
|
||||
return { authTokens, client };
|
||||
}
|
||||
|
||||
it("flushes throttled pending text after the throttle window", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_000);
|
||||
@@ -386,12 +346,46 @@ describe("FeishuStreamingSession", () => {
|
||||
it("bounds streaming token cache lifetime when token expiry overflows", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-29T12:00:00.000Z"));
|
||||
const { authTokens, client } = mockStreamingTokenStart((token) => ({
|
||||
code: 0,
|
||||
msg: "ok",
|
||||
tenant_access_token: token,
|
||||
expire: Number.MAX_SAFE_INTEGER,
|
||||
}));
|
||||
const release = vi.fn(async () => {});
|
||||
const authTokens: string[] = [];
|
||||
fetchWithSsrFGuardMock.mockImplementation(
|
||||
async ({ url }: { url: string; init?: { body?: string } }) => {
|
||||
if (url.includes("/auth/")) {
|
||||
const token = `token-${authTokens.length + 1}`;
|
||||
authTokens.push(token);
|
||||
return {
|
||||
response: {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
code: 0,
|
||||
msg: "ok",
|
||||
tenant_access_token: token,
|
||||
expire: Number.MAX_SAFE_INTEGER,
|
||||
}),
|
||||
},
|
||||
release,
|
||||
};
|
||||
}
|
||||
return {
|
||||
response: {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
code: 0,
|
||||
msg: "ok",
|
||||
data: { card_id: `card-${authTokens.length}` },
|
||||
}),
|
||||
},
|
||||
release,
|
||||
};
|
||||
},
|
||||
);
|
||||
const client = {
|
||||
im: {
|
||||
message: {
|
||||
create: vi.fn(async () => ({ code: 0, msg: "ok", data: { message_id: "om_1" } })),
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
|
||||
await new FeishuStreamingSession(client, {
|
||||
appId: "app_unsafe_token_expiry",
|
||||
@@ -407,55 +401,6 @@ describe("FeishuStreamingSession", () => {
|
||||
|
||||
expect(authTokens).toEqual(["token-1", "token-2"]);
|
||||
});
|
||||
|
||||
it("bounds streaming token fallback lifetime when the process clock is invalid", async () => {
|
||||
const dateNow = vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
|
||||
const { authTokens, client } = mockStreamingTokenStart((token) => ({
|
||||
code: 0,
|
||||
msg: "ok",
|
||||
tenant_access_token: token,
|
||||
}));
|
||||
|
||||
await new FeishuStreamingSession(client, {
|
||||
appId: "app_invalid_clock_token_expiry",
|
||||
appSecret: "secret",
|
||||
}).start("chat_id", "open_id");
|
||||
expect(authTokens).toEqual(["token-1"]);
|
||||
|
||||
dateNow.mockReturnValue(7200 * 1000 - 60_000 + 1);
|
||||
await new FeishuStreamingSession(client, {
|
||||
appId: "app_invalid_clock_token_expiry",
|
||||
appSecret: "secret",
|
||||
}).start("chat_id", "open_id");
|
||||
|
||||
expect(authTokens).toEqual(["token-1", "token-2"]);
|
||||
dateNow.mockRestore();
|
||||
});
|
||||
|
||||
it("treats an invalid process clock as a streaming token cache miss", async () => {
|
||||
const dateNow = vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-05-29T12:00:00.000Z"));
|
||||
const { authTokens, client } = mockStreamingTokenStart((token) => ({
|
||||
code: 0,
|
||||
msg: "ok",
|
||||
tenant_access_token: token,
|
||||
expire: 7200,
|
||||
}));
|
||||
|
||||
await new FeishuStreamingSession(client, {
|
||||
appId: "app_invalid_clock_cache_miss",
|
||||
appSecret: "secret",
|
||||
}).start("chat_id", "open_id");
|
||||
expect(authTokens).toEqual(["token-1"]);
|
||||
|
||||
dateNow.mockReturnValue(8_640_000_000_000_001);
|
||||
await new FeishuStreamingSession(client, {
|
||||
appId: "app_invalid_clock_cache_miss",
|
||||
appSecret: "secret",
|
||||
}).start("chat_id", "open_id");
|
||||
|
||||
expect(authTokens).toEqual(["token-1", "token-2"]);
|
||||
dateNow.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeStreamingText", () => {
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
*/
|
||||
|
||||
import type { Client } from "@larksuiteoapi/node-sdk";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationSeconds,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { resolveExpiresAtMsFromDurationSeconds } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { getFeishuUserAgent } from "./client.js";
|
||||
import { resolveFeishuCardTemplate, type CardHeaderConfig } from "./send.js";
|
||||
@@ -52,17 +48,13 @@ const FEISHU_STREAMING_TOKEN_DEFAULT_LIFETIME_SECONDS = 7200;
|
||||
// Token cache (keyed by domain + appId)
|
||||
const tokenCache = new Map<string, { token: string; expiresAt: number }>();
|
||||
|
||||
function resolveStreamingTokenExpiresAt(value: unknown, nowMs = Date.now()): number {
|
||||
const now = resolveDateTimestampMs(nowMs);
|
||||
function resolveStreamingTokenExpiresAt(value: unknown): number {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value <= 0) {
|
||||
return now;
|
||||
return Date.now();
|
||||
}
|
||||
return (
|
||||
resolveExpiresAtMsFromDurationSeconds(value, { nowMs: now }) ??
|
||||
resolveExpiresAtMsFromDurationSeconds(FEISHU_STREAMING_TOKEN_DEFAULT_LIFETIME_SECONDS, {
|
||||
nowMs: now,
|
||||
}) ??
|
||||
now
|
||||
resolveExpiresAtMsFromDurationSeconds(value) ??
|
||||
Date.now() + FEISHU_STREAMING_TOKEN_DEFAULT_LIFETIME_SECONDS * 1000
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,11 +85,7 @@ function resolveAllowedHostnames(domain?: FeishuDomain): string[] {
|
||||
async function getToken(creds: Credentials): Promise<string> {
|
||||
const key = `${creds.domain ?? "feishu"}|${creds.appId}`;
|
||||
const cached = tokenCache.get(key);
|
||||
const rawNow = Date.now();
|
||||
const hasValidClock = asDateTimestampMs(rawNow) !== undefined;
|
||||
const now = resolveDateTimestampMs(rawNow);
|
||||
const minUsableExpiresAt = resolveExpiresAtMsFromDurationSeconds(60, { nowMs: now }) ?? now;
|
||||
if (cached && hasValidClock && cached.expiresAt > minUsableExpiresAt) {
|
||||
if (cached && cached.expiresAt > Date.now() + 60000) {
|
||||
return cached.token;
|
||||
}
|
||||
|
||||
@@ -127,7 +115,7 @@ async function getToken(creds: Credentials): Promise<string> {
|
||||
}
|
||||
tokenCache.set(key, {
|
||||
token: data.tenant_access_token,
|
||||
expiresAt: resolveStreamingTokenExpiresAt(data.expire, now),
|
||||
expiresAt: resolveStreamingTokenExpiresAt(data.expire),
|
||||
});
|
||||
return data.tenant_access_token;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
|
||||
describe("Google Meet OAuth", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
@@ -118,27 +117,6 @@ 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,6 +1,5 @@
|
||||
import {
|
||||
MAX_DATE_TIMESTAMP_MS,
|
||||
resolveDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationSeconds,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { generateHexPkceVerifierChallenge } from "openclaw/plugin-sdk/provider-auth";
|
||||
@@ -25,17 +24,13 @@ const GOOGLE_MEET_SCOPES = [
|
||||
"https://www.googleapis.com/auth/drive.meet.readonly",
|
||||
] as const;
|
||||
|
||||
function resolveGoogleMeetTokenExpiresAt(value: unknown, nowMs = Date.now()): number {
|
||||
const now = resolveDateTimestampMs(nowMs);
|
||||
function resolveGoogleMeetTokenExpiresAt(value: unknown): number {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value <= 0) {
|
||||
return now;
|
||||
return Date.now();
|
||||
}
|
||||
return (
|
||||
resolveExpiresAtMsFromDurationSeconds(value, { nowMs: now }) ??
|
||||
resolveExpiresAtMsFromDurationSeconds(GOOGLE_MEET_DEFAULT_TOKEN_LIFETIME_SECONDS, {
|
||||
nowMs: now,
|
||||
}) ??
|
||||
now
|
||||
resolveExpiresAtMsFromDurationSeconds(value) ??
|
||||
Date.now() + GOOGLE_MEET_DEFAULT_TOKEN_LIFETIME_SECONDS * 1000
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -951,39 +951,6 @@ 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,7 +1,4 @@
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationSeconds,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { 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";
|
||||
@@ -37,18 +34,10 @@ 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, { nowMs, bufferMs: TOKEN_EXPIRY_BUFFER_MS }) ??
|
||||
resolveExpiredTokenTimestampMs(nowMs)
|
||||
resolveExpiresAtMsFromDurationSeconds(value, { bufferMs: TOKEN_EXPIRY_BUFFER_MS }) ??
|
||||
Date.now() - TOKEN_EXPIRY_BUFFER_MS
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -292,7 +292,6 @@ describe("google transport stream", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
@@ -768,29 +767,6 @@ 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,11 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
resolveExpiresAtMsFromDurationSeconds,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { resolveExpiresAtMsFromDurationSeconds } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
type GoogleAuthorizedUserCredentials = {
|
||||
@@ -36,7 +32,6 @@ 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:
|
||||
@@ -48,36 +43,18 @@ let cachedGoogleAuthClient:
|
||||
| undefined;
|
||||
let cachedGoogleVertexAdcToken: GoogleVertexAdcToken | undefined;
|
||||
|
||||
function isGoogleVertexTokenFresh(expiresAtMsRaw: number, nowRaw = Date.now()): boolean {
|
||||
const expiresAtMs = asDateTimestampMs(expiresAtMsRaw);
|
||||
const nowMs = asDateTimestampMs(nowRaw);
|
||||
if (expiresAtMs === undefined || nowMs === undefined) {
|
||||
return false;
|
||||
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
|
||||
);
|
||||
}
|
||||
const minFreshExpiresAtMs = resolveExpiresAtMsFromDurationMs(
|
||||
GOOGLE_VERTEX_TOKEN_EXPIRY_BUFFER_MS,
|
||||
{ nowMs },
|
||||
return (
|
||||
resolveExpiresAtMsFromDurationSeconds(GOOGLE_VERTEX_DEFAULT_TOKEN_LIFETIME_SECONDS, {
|
||||
nowMs,
|
||||
}) ?? nowMs - GOOGLE_VERTEX_TOKEN_EXPIRY_BUFFER_MS
|
||||
);
|
||||
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 {
|
||||
@@ -200,7 +177,7 @@ async function refreshGoogleVertexAuthorizedUserAccessToken(params: {
|
||||
if (
|
||||
cached?.credentialsPath === params.credentialsPath &&
|
||||
cached.refreshToken === refreshToken &&
|
||||
isGoogleVertexTokenFresh(cached.expiresAtMs)
|
||||
cached.expiresAtMs - Date.now() > GOOGLE_VERTEX_TOKEN_EXPIRY_BUFFER_MS
|
||||
) {
|
||||
return cached.token;
|
||||
}
|
||||
@@ -231,15 +208,12 @@ async function refreshGoogleVertexAuthorizedUserAccessToken(params: {
|
||||
throw new Error("Google Vertex ADC token refresh response did not include an access_token.");
|
||||
}
|
||||
const nowMs = Date.now();
|
||||
const expiresAtMs = resolveAuthorizedUserTokenExpiresAtMs(payload?.expires_in, nowMs);
|
||||
if (expiresAtMs !== undefined) {
|
||||
cachedGoogleVertexAuthorizedUserToken = {
|
||||
token,
|
||||
expiresAtMs,
|
||||
credentialsPath: params.credentialsPath,
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
cachedGoogleVertexAuthorizedUserToken = {
|
||||
token,
|
||||
expiresAtMs: resolveAuthorizedUserTokenExpiresAtMs(payload?.expires_in, nowMs),
|
||||
credentialsPath: params.credentialsPath,
|
||||
refreshToken,
|
||||
};
|
||||
return token;
|
||||
}
|
||||
|
||||
@@ -264,7 +238,7 @@ async function resolveGoogleVertexAccessTokenViaGoogleAuth(): Promise<string> {
|
||||
const auth = await cachedGoogleAuthClient.promise;
|
||||
|
||||
const cached = cachedGoogleVertexAdcToken;
|
||||
if (cached && isGoogleVertexTokenFresh(cached.expiresAtMs)) {
|
||||
if (cached && cached.expiresAtMs - Date.now() > GOOGLE_VERTEX_TOKEN_EXPIRY_BUFFER_MS) {
|
||||
return cached.token;
|
||||
}
|
||||
|
||||
@@ -281,13 +255,10 @@ 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.
|
||||
const expiresAtMs = resolveGoogleAuthLibraryTokenExpiresAtMs();
|
||||
if (expiresAtMs !== undefined) {
|
||||
cachedGoogleVertexAdcToken = {
|
||||
token: normalized,
|
||||
expiresAtMs,
|
||||
};
|
||||
}
|
||||
cachedGoogleVertexAdcToken = {
|
||||
token: normalized,
|
||||
expiresAtMs: Date.now() + 5 * 60_000,
|
||||
};
|
||||
return normalized;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +1,16 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { 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 & {
|
||||
@@ -40,13 +29,6 @@ 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();
|
||||
@@ -81,58 +63,6 @@ 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,11 +1,7 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { extname, join } from "node:path";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
parseStrictInteger,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { parseStrictInteger } 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";
|
||||
@@ -80,14 +76,12 @@ function chatListCacheGet(
|
||||
cliPath: string,
|
||||
dbPath?: string,
|
||||
): ReadonlyArray<Record<string, unknown>> | null {
|
||||
const key = chatListCacheKey(cliPath, dbPath);
|
||||
const entry = chatListCache.get(key);
|
||||
const entry = chatListCache.get(chatListCacheKey(cliPath, dbPath));
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
const now = asDateTimestampMs(Date.now());
|
||||
if (now === undefined || entry.expiresAt <= now) {
|
||||
chatListCache.delete(key);
|
||||
if (entry.expiresAt < Date.now()) {
|
||||
chatListCache.delete(chatListCacheKey(cliPath, dbPath));
|
||||
return null;
|
||||
}
|
||||
return entry.list;
|
||||
@@ -98,13 +92,9 @@ 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,
|
||||
expiresAt: Date.now() + CHAT_LIST_CACHE_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -168,7 +168,7 @@ describe("iMessage monitor last-route updates", () => {
|
||||
expect(recordParams?.updateLastRoute?.sessionKey).toBe(recordParams?.sessionKey);
|
||||
expect(recordParams?.updateLastRoute?.sessionKey).not.toBe("agent:main:main");
|
||||
expect(recordParams?.updateLastRoute?.channel).toBe("imessage");
|
||||
expect(recordParams?.updateLastRoute?.to).toBe("imessage:+15550001111");
|
||||
expect(recordParams?.updateLastRoute?.to).toBe("+15550001111");
|
||||
expect(recordParams?.updateLastRoute?.mainDmOwnerPin).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { asDateTimestampMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
|
||||
export type IMessagePrivateApiStatus = {
|
||||
available: boolean;
|
||||
v2Ready: boolean;
|
||||
@@ -58,11 +56,7 @@ export function getCachedIMessagePrivateApiStatus(
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
if (entry.expiresAt === 0) {
|
||||
return entry.status;
|
||||
}
|
||||
const now = asDateTimestampMs(Date.now());
|
||||
if (now === undefined || entry.expiresAt <= now) {
|
||||
if (entry.expiresAt > 0 && entry.expiresAt < Date.now()) {
|
||||
bridgeStatusCache.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
@@ -74,9 +68,6 @@ export function setCachedIMessagePrivateApiStatus(
|
||||
status: IMessagePrivateApiStatus,
|
||||
expiresAt = 0,
|
||||
): void {
|
||||
if (expiresAt !== 0 && asDateTimestampMs(expiresAt) === undefined) {
|
||||
return;
|
||||
}
|
||||
bridgeStatusCache.set(normalizeCliPath(cliPath), { status, expiresAt });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearCachedIMessagePrivateApiStatus,
|
||||
getCachedIMessagePrivateApiStatus,
|
||||
setCachedIMessagePrivateApiStatus,
|
||||
} from "./private-api-status.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { imessageRpcSupportsMethod } from "./probe.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
clearCachedIMessagePrivateApiStatus();
|
||||
});
|
||||
|
||||
describe("imessageRpcSupportsMethod", () => {
|
||||
it("returns false when the bridge is not available", () => {
|
||||
expect(
|
||||
@@ -102,35 +92,3 @@ 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,9 +1,5 @@
|
||||
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";
|
||||
@@ -57,27 +53,6 @@ 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");
|
||||
@@ -94,9 +69,9 @@ export function resolveIMessageNonMacHostError(
|
||||
}
|
||||
|
||||
async function probeRpcSupport(cliPath: string, timeoutMs: number): Promise<RpcSupportResult> {
|
||||
const cached = getCachedRpcSupport(cliPath);
|
||||
if (cached) {
|
||||
return cached;
|
||||
const cached = rpcSupportCache.get(cliPath);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.result;
|
||||
}
|
||||
try {
|
||||
const result = await runCommandWithTimeout([cliPath, "rpc", "--help"], { timeoutMs });
|
||||
@@ -108,12 +83,18 @@ async function probeRpcSupport(cliPath: string, timeoutMs: number): Promise<RpcS
|
||||
fatal: true,
|
||||
error: 'imsg CLI does not support the "rpc" subcommand (update imsg)',
|
||||
};
|
||||
setCachedRpcSupport(cliPath, fatal);
|
||||
rpcSupportCache.set(cliPath, {
|
||||
result: fatal,
|
||||
expiresAt: Date.now() + RPC_SUPPORT_CACHE_TTL_MS,
|
||||
});
|
||||
return fatal;
|
||||
}
|
||||
if (result.code === 0) {
|
||||
const supported = { supported: true };
|
||||
setCachedRpcSupport(cliPath, supported);
|
||||
rpcSupportCache.set(cliPath, {
|
||||
result: supported,
|
||||
expiresAt: Date.now() + RPC_SUPPORT_CACHE_TTL_MS,
|
||||
});
|
||||
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 { clearIMessagePrivateApiCache, probeIMessage } from "./probe.js";
|
||||
import { probeIMessage } from "./probe.js";
|
||||
import { imessageSetupWizard } from "./setup-surface.js";
|
||||
import { probeIMessageStatusAccount } from "./status-core.js";
|
||||
|
||||
@@ -159,7 +159,6 @@ describe("imessage setup status", () => {
|
||||
describe("probeIMessage", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
clearIMessagePrivateApiCache();
|
||||
spawnMock.mockClear();
|
||||
vi.spyOn(setupRuntime, "detectBinary").mockResolvedValue(true);
|
||||
vi.spyOn(processRuntime, "runCommandWithTimeout").mockResolvedValue({
|
||||
@@ -186,102 +185,6 @@ 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", serviceExplicit: true });
|
||||
expect(target).toEqual({ kind: "handle", to: "+1555", service: "sms" });
|
||||
});
|
||||
|
||||
it("normalizes handles", () => {
|
||||
|
||||
@@ -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 { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const fetchMattermostChannel = vi.hoisted(() => vi.fn());
|
||||
const fetchMattermostUser = vi.hoisted(() => vi.fn());
|
||||
@@ -32,10 +32,6 @@ 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",
|
||||
@@ -124,70 +120,6 @@ 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,7 +1,3 @@
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
fetchMattermostChannel,
|
||||
@@ -54,35 +50,6 @@ 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[]> => {
|
||||
@@ -122,35 +89,45 @@ export function createMattermostMonitorResources(params: {
|
||||
};
|
||||
|
||||
const resolveChannelInfo = async (channelId: string): Promise<MattermostChannel | null> => {
|
||||
const rawNow = Date.now();
|
||||
const cached = getCachedValue(channelCache, channelId, asDateTimestampMs(rawNow));
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
const cached = channelCache.get(channelId);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.value;
|
||||
}
|
||||
try {
|
||||
const info = await fetchMattermostChannel(client, channelId);
|
||||
setCachedValue(channelCache, channelId, info, CHANNEL_CACHE_TTL_MS, rawNow);
|
||||
channelCache.set(channelId, {
|
||||
value: info,
|
||||
expiresAt: Date.now() + CHANNEL_CACHE_TTL_MS,
|
||||
});
|
||||
return info;
|
||||
} catch (err) {
|
||||
logger.debug?.(`mattermost: channel lookup failed: ${String(err)}`);
|
||||
setCachedValue(channelCache, channelId, null, CHANNEL_CACHE_TTL_MS, rawNow);
|
||||
channelCache.set(channelId, {
|
||||
value: null,
|
||||
expiresAt: Date.now() + CHANNEL_CACHE_TTL_MS,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveUserInfo = async (userId: string): Promise<MattermostUser | null> => {
|
||||
const rawNow = Date.now();
|
||||
const cached = getCachedValue(userCache, userId, asDateTimestampMs(rawNow));
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
const cached = userCache.get(userId);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.value;
|
||||
}
|
||||
try {
|
||||
const info = await fetchMattermostUser(client, userId);
|
||||
setCachedValue(userCache, userId, info, USER_CACHE_TTL_MS, rawNow);
|
||||
userCache.set(userId, {
|
||||
value: info,
|
||||
expiresAt: Date.now() + USER_CACHE_TTL_MS,
|
||||
});
|
||||
return info;
|
||||
} catch (err) {
|
||||
logger.debug?.(`mattermost: user lookup failed: ${String(err)}`);
|
||||
setCachedValue(userCache, userId, null, USER_CACHE_TTL_MS, rawNow);
|
||||
userCache.set(userId, {
|
||||
value: null,
|
||||
expiresAt: Date.now() + USER_CACHE_TTL_MS,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
addMattermostReaction,
|
||||
removeMattermostReaction,
|
||||
@@ -15,10 +15,6 @@ describe("mattermost reactions", () => {
|
||||
resetMattermostReactionBotUserCacheForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function addReactionWithFetch(fetchMock: typeof fetch) {
|
||||
return addMattermostReaction({
|
||||
cfg: createMattermostTestConfig(),
|
||||
@@ -108,94 +104,4 @@ 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,7 +1,3 @@
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { resolveMattermostAccount } from "./accounts.js";
|
||||
import {
|
||||
@@ -30,24 +26,16 @@ 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) {
|
||||
if (now !== undefined && cached.expiresAt > now) {
|
||||
return cached.userId;
|
||||
}
|
||||
botUserIdCache.delete(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.userId;
|
||||
}
|
||||
const me = await fetchMattermostMe(client);
|
||||
const userId = me?.id?.trim();
|
||||
if (!userId) {
|
||||
return null;
|
||||
}
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(BOT_USER_CACHE_TTL_MS, { nowMs: rawNow });
|
||||
if (expiresAt !== undefined) {
|
||||
botUserIdCache.set(cacheKey, { userId, expiresAt });
|
||||
}
|
||||
botUserIdCache.set(cacheKey, { userId, expiresAt: Date.now() + BOT_USER_CACHE_TTL_MS });
|
||||
return userId;
|
||||
}
|
||||
|
||||
|
||||
@@ -458,119 +458,6 @@ 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,10 +6,6 @@
|
||||
*/
|
||||
|
||||
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";
|
||||
@@ -213,14 +209,8 @@ 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) {
|
||||
const expiresAt = asDateTimestampMs(entry.expiresAt);
|
||||
if (expiresAt === undefined || expiresAt <= validNow) {
|
||||
if (entry.expiresAt <= now) {
|
||||
commandValidationFailureCache.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -235,16 +225,11 @@ 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;
|
||||
}
|
||||
const expiresAt = asDateTimestampMs(cached.expiresAt);
|
||||
if (expiresAt !== undefined && expiresAt > validNow) {
|
||||
if (cached.expiresAt > now) {
|
||||
return true;
|
||||
}
|
||||
commandValidationFailureCache.delete(key);
|
||||
@@ -252,31 +237,17 @@ function hasCachedCommandValidationFailure(key: string, now = Date.now()): boole
|
||||
}
|
||||
|
||||
function cacheCommandValidationFailure(key: string, accountId: string): void {
|
||||
const now = Date.now();
|
||||
sweepCommandValidationFailureCache(now);
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(COMMAND_VALIDATION_FAILURE_CACHE_MS, {
|
||||
nowMs: now,
|
||||
});
|
||||
if (expiresAt === undefined) {
|
||||
commandValidationFailureCache.delete(key);
|
||||
return;
|
||||
}
|
||||
sweepCommandValidationFailureCache();
|
||||
commandValidationFailureCache.set(key, {
|
||||
accountId,
|
||||
expiresAt,
|
||||
expiresAt: Date.now() + COMMAND_VALIDATION_FAILURE_CACHE_MS,
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
const updatedAt = asDateTimestampMs(entry.updatedAt);
|
||||
if (updatedAt === undefined || validNow - updatedAt > staleAfterMs) {
|
||||
if (now - entry.updatedAt > staleAfterMs) {
|
||||
commandValidationLookupRateLimit.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -294,12 +265,7 @@ function reserveCommandValidationLookup(params: {
|
||||
accountId: string;
|
||||
now?: number;
|
||||
}): { allowed: true } | { allowed: false; shouldLog: boolean } {
|
||||
const rawNow = params.now ?? Date.now();
|
||||
const now = asDateTimestampMs(rawNow);
|
||||
if (now === undefined) {
|
||||
commandValidationLookupRateLimit.clear();
|
||||
return { allowed: true };
|
||||
}
|
||||
const now = params.now ?? Date.now();
|
||||
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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getAccessTokenResultAsync } from "./cli.js";
|
||||
import plugin from "./index.js";
|
||||
import { buildFoundryConnectionTest, isValidTenantIdentifier } from "./onboard.js";
|
||||
@@ -73,9 +73,7 @@ function requirePrepareRuntimeAuth(
|
||||
return prepareRuntimeAuth;
|
||||
}
|
||||
|
||||
function requireRuntimeAuthResult(
|
||||
result: { apiKey?: string; baseUrl?: string; expiresAt?: number } | undefined,
|
||||
) {
|
||||
function requireRuntimeAuthResult(result: { apiKey?: string; baseUrl?: string } | undefined) {
|
||||
if (!result) {
|
||||
throw new Error("expected Microsoft Foundry runtime auth result");
|
||||
}
|
||||
@@ -279,10 +277,6 @@ 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({
|
||||
@@ -495,42 +489,6 @@ 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,10 +1,5 @@
|
||||
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";
|
||||
@@ -20,7 +15,6 @@ 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();
|
||||
@@ -33,11 +27,7 @@ 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 now = resolveDateTimestampMs(Date.now());
|
||||
const expiresAt =
|
||||
asDateTimestampMs(rawExpiry) ??
|
||||
resolveExpiresAtMsFromDurationMs(FOUNDRY_TOKEN_FALLBACK_LIFETIME_MS, { nowMs: now }) ??
|
||||
now;
|
||||
const expiresAt = Number.isFinite(rawExpiry) ? rawExpiry : Date.now() + 55 * 60 * 1000;
|
||||
cachedTokens.set(getFoundryTokenCacheKey(params), {
|
||||
token: result.accessToken,
|
||||
expiresAt,
|
||||
@@ -81,12 +71,7 @@ export async function prepareFoundryRuntimeAuth(ctx: ProviderPrepareRuntimeAuthC
|
||||
tenantId: metadata?.tenantId,
|
||||
});
|
||||
const cachedToken = cachedTokens.get(cacheKey);
|
||||
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) {
|
||||
if (cachedToken && cachedToken.expiresAt > Date.now() + TOKEN_REFRESH_MARGIN_MS) {
|
||||
return {
|
||||
apiKey: cachedToken.token,
|
||||
expiresAt: cachedToken.expiresAt,
|
||||
|
||||
@@ -77,35 +77,6 @@ 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,7 +1,3 @@
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { fetchGraphJson, type GraphResponse } from "./graph.js";
|
||||
|
||||
export type GraphThreadMessage = {
|
||||
@@ -18,13 +14,6 @@ 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.
|
||||
@@ -55,13 +44,8 @@ export async function resolveTeamGroupId(
|
||||
conversationTeamId: string,
|
||||
): Promise<string> {
|
||||
const cached = teamGroupIdCache.get(conversationTeamId);
|
||||
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);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.groupId;
|
||||
}
|
||||
|
||||
// The team ID in channelData is typically the group ID itself for standard teams.
|
||||
@@ -75,13 +59,10 @@ 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).
|
||||
const expiresAt = resolveTeamGroupIdCacheExpiresAt();
|
||||
if (expiresAt !== undefined) {
|
||||
teamGroupIdCache.set(conversationTeamId, {
|
||||
groupId,
|
||||
expiresAt,
|
||||
});
|
||||
}
|
||||
teamGroupIdCache.set(conversationTeamId, {
|
||||
groupId,
|
||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||
});
|
||||
|
||||
return groupId;
|
||||
} catch {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GraphThreadMessage } from "./graph-thread.js";
|
||||
import {
|
||||
resetThreadParentContextCachesForTest,
|
||||
@@ -95,10 +95,6 @@ describe("fetchParentMessageCached", () => {
|
||||
resetThreadParentContextCachesForTest();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("invokes the fetcher on first call", async () => {
|
||||
const mockMsg: GraphThreadMessage = {
|
||||
id: "p1",
|
||||
@@ -154,31 +150,21 @@ describe("fetchParentMessageCached", () => {
|
||||
|
||||
it("re-fetches after TTL expires", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetcher = vi.fn(async () => ({
|
||||
id: "p1",
|
||||
body: { content: "hi", contentType: "text" },
|
||||
}));
|
||||
try {
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("evicts oldest entries when exceeding the 100-entry cap", async () => {
|
||||
|
||||
@@ -13,10 +13,6 @@
|
||||
// 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";
|
||||
|
||||
@@ -65,13 +61,6 @@ 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.
|
||||
*
|
||||
@@ -86,23 +75,16 @@ export async function fetchParentMessageCached(
|
||||
fetchParent: ThreadParentContextFetcher = fetchChannelMessage,
|
||||
): Promise<GraphThreadMessage | undefined> {
|
||||
const key = buildParentCacheKey(groupId, channelId, parentId);
|
||||
const now = asDateTimestampMs(Date.now());
|
||||
const now = Date.now();
|
||||
const cached = parentCache.get(key);
|
||||
const cachedExpiresAt = cached ? asDateTimestampMs(cached.expiresAt) : undefined;
|
||||
if (cached && now !== undefined && cachedExpiresAt !== undefined && cachedExpiresAt > now) {
|
||||
if (cached && cached.expiresAt > 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);
|
||||
const expiresAt = resolveParentCacheExpiresAt(Date.now());
|
||||
if (expiresAt !== undefined) {
|
||||
touchLru(parentCache, key, { message, expiresAt }, PARENT_CACHE_MAX);
|
||||
}
|
||||
touchLru(parentCache, key, { message, expiresAt: now + PARENT_CACHE_TTL_MS }, PARENT_CACHE_MAX);
|
||||
return message;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ const ssrfRuntimeMocks = vi.hoisted(() => ({
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ssrfRuntimeMocks);
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
clearNvidiaFeaturedModelCacheForTests();
|
||||
ssrfRuntimeMocks.fetchWithSsrFGuard.mockReset();
|
||||
ssrfRuntimeMocks.ssrfPolicyFromHttpBaseUrlAllowedHostname.mockClear();
|
||||
@@ -196,35 +195,4 @@ 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,8 +1,4 @@
|
||||
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,
|
||||
@@ -110,26 +106,17 @@ export function clearNvidiaFeaturedModelCacheForTests() {
|
||||
|
||||
async function loadNvidiaFeaturedModels(): Promise<ModelDefinitionConfig[] | null> {
|
||||
const now = Date.now();
|
||||
if (
|
||||
featuredModelCache &&
|
||||
isFutureDateTimestampMs(featuredModelCache.expiresAtMs, { nowMs: now })
|
||||
) {
|
||||
if (featuredModelCache && featuredModelCache.expiresAtMs > now) {
|
||||
return featuredModelCache.models;
|
||||
}
|
||||
featuredModelCache = undefined;
|
||||
featuredModelRequest ??= fetchNvidiaFeaturedModels();
|
||||
try {
|
||||
const models = await featuredModelRequest;
|
||||
if (models && models.length > 0) {
|
||||
const expiresAtMs = resolveExpiresAtMsFromDurationMs(FEATURED_MODEL_CACHE_TTL_MS, {
|
||||
nowMs: now,
|
||||
});
|
||||
if (expiresAtMs !== undefined) {
|
||||
featuredModelCache = {
|
||||
expiresAtMs,
|
||||
models,
|
||||
};
|
||||
}
|
||||
featuredModelCache = {
|
||||
expiresAtMs: now + FEATURED_MODEL_CACHE_TTL_MS,
|
||||
models,
|
||||
};
|
||||
}
|
||||
return models;
|
||||
} finally {
|
||||
|
||||
@@ -269,25 +269,6 @@ 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,10 +1,6 @@
|
||||
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,
|
||||
@@ -298,38 +294,14 @@ 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 = resolveArmExpiryStatus(state);
|
||||
const until =
|
||||
state.expiresAtMs == null
|
||||
? "manual disarm required"
|
||||
: `expires in ${formatDuration(Math.max(0, state.expiresAtMs - Date.now()))}`;
|
||||
const cmds = uniqSorted(
|
||||
state.version === 1
|
||||
? state.removedFromDeny
|
||||
@@ -357,7 +329,7 @@ export default definePluginEntry({
|
||||
if (!state || state.expiresAtMs == null) {
|
||||
return;
|
||||
}
|
||||
if (!isArmStateExpired(state)) {
|
||||
if (Date.now() < state.expiresAtMs) {
|
||||
return;
|
||||
}
|
||||
await disarmNow({
|
||||
@@ -458,14 +430,7 @@ export default definePluginEntry({
|
||||
if (durationMs === null) {
|
||||
return { text: "Invalid duration. Use values like 30s, 10m, 2h, or 1d." };
|
||||
}
|
||||
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 expiresAtMs = Date.now() + durationMs;
|
||||
|
||||
const commands = resolveCommandsForGroup(group);
|
||||
const cfg = api.runtime.config.current() as OpenClawConfig;
|
||||
@@ -496,7 +461,7 @@ export default definePluginEntry({
|
||||
|
||||
await writeArmState(statePath, {
|
||||
version: STATE_VERSION,
|
||||
armedAtMs,
|
||||
armedAtMs: Date.now(),
|
||||
expiresAtMs,
|
||||
group,
|
||||
armedCommands: uniqSorted(commands),
|
||||
|
||||
@@ -98,7 +98,6 @@ export class MessageApi {
|
||||
msgId?: string;
|
||||
messageReference?: string;
|
||||
inlineKeyboard?: InlineKeyboard;
|
||||
forcePlainText?: boolean;
|
||||
},
|
||||
): Promise<MessageResponse> {
|
||||
const token = await this.tokenManager.getAccessToken(creds.appId, creds.clientSecret);
|
||||
@@ -109,7 +108,6 @@ 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 });
|
||||
@@ -121,13 +119,12 @@ 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, opts?.forcePlainText);
|
||||
const body = this.buildProactiveBody(content);
|
||||
const path = messagePath(scope, targetId);
|
||||
return this.sendAndNotify(creds.appId, token, "POST", path, body, { text: content });
|
||||
}
|
||||
@@ -265,17 +262,15 @@ export class MessageApi {
|
||||
msgSeq: number,
|
||||
messageReference?: string,
|
||||
inlineKeyboard?: InlineKeyboard,
|
||||
forcePlainText = false,
|
||||
): Record<string, unknown> {
|
||||
const useMarkdown = this.markdownSupport && !forcePlainText;
|
||||
const body: Record<string, unknown> = useMarkdown
|
||||
const body: Record<string, unknown> = this.markdownSupport
|
||||
? { markdown: { content }, msg_type: 2, msg_seq: msgSeq }
|
||||
: { content, msg_type: 0, msg_seq: msgSeq };
|
||||
|
||||
if (msgId) {
|
||||
body.msg_id = msgId;
|
||||
}
|
||||
if (messageReference && !useMarkdown) {
|
||||
if (messageReference && !this.markdownSupport) {
|
||||
body.message_reference = { message_id: messageReference };
|
||||
}
|
||||
if (inlineKeyboard) {
|
||||
@@ -284,10 +279,8 @@ export class MessageApi {
|
||||
return body;
|
||||
}
|
||||
|
||||
private buildProactiveBody(content: string, forcePlainText = false): Record<string, unknown> {
|
||||
return this.markdownSupport && !forcePlainText
|
||||
? { markdown: { content }, msg_type: 2 }
|
||||
: { content, msg_type: 0 };
|
||||
private buildProactiveBody(content: string): Record<string, unknown> {
|
||||
return this.markdownSupport ? { markdown: { content }, msg_type: 2 } : { content, msg_type: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import type { InboundContext } from "./inbound-context.js";
|
||||
import { dispatchOutbound } from "./outbound-dispatch.js";
|
||||
import type { GatewayAccount, GatewayPluginRuntime } from "./types.js";
|
||||
@@ -10,10 +10,7 @@ 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"));
|
||||
|
||||
@@ -110,7 +107,7 @@ function makeRuntime(params: {
|
||||
isControlCommandMessage?: (text?: string, cfg?: unknown) => boolean;
|
||||
onDeliver?: (
|
||||
deliver: (
|
||||
payload: { text?: string; mediaUrl?: string; mediaUrls?: string[]; audioAsVoice?: boolean },
|
||||
payload: { text?: string; audioAsVoice?: boolean },
|
||||
info: { kind: string },
|
||||
) => Promise<void>,
|
||||
) => Promise<void>;
|
||||
@@ -130,12 +127,7 @@ function makeRuntime(params: {
|
||||
rawParams as {
|
||||
dispatcherOptions: {
|
||||
deliver: (
|
||||
payload: {
|
||||
text?: string;
|
||||
mediaUrl?: string;
|
||||
mediaUrls?: string[];
|
||||
audioAsVoice?: boolean;
|
||||
},
|
||||
payload: { text?: string; audioAsVoice?: boolean },
|
||||
info: { kind: string },
|
||||
) => Promise<void>;
|
||||
};
|
||||
@@ -179,10 +171,6 @@ describe("dispatchOutbound", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("keeps waiting past 300s when a slow provider timeout is configured", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
@@ -269,137 +257,6 @@ 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,7 +15,6 @@ import type { FinalizedMsgContext } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import {
|
||||
parseAndSendMediaTags,
|
||||
sendPlainReply,
|
||||
sendTextOnlyReply,
|
||||
type DeliverDeps,
|
||||
} from "../messaging/outbound-deliver.js";
|
||||
import {
|
||||
@@ -69,34 +68,8 @@ 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 ============
|
||||
|
||||
/**
|
||||
@@ -182,31 +155,6 @@ 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
|
||||
@@ -260,10 +208,6 @@ 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({
|
||||
@@ -331,29 +275,6 @@ 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);
|
||||
}
|
||||
@@ -384,7 +305,22 @@ export async function dispatchOutbound(
|
||||
if (toolFallbackSent) {
|
||||
return;
|
||||
}
|
||||
renewToolOnlyFallback();
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -173,9 +173,8 @@ async function sendTextChunkToTarget(params: {
|
||||
text: string;
|
||||
consumeQuoteRef: ConsumeQuoteRefFn;
|
||||
allowDm: boolean;
|
||||
forcePlainText?: boolean;
|
||||
}): Promise<unknown> {
|
||||
const { account, event, text, consumeQuoteRef, allowDm, forcePlainText } = params;
|
||||
const { account, event, text, consumeQuoteRef, allowDm } = params;
|
||||
const ref = consumeQuoteRef();
|
||||
const target = buildDeliveryTarget(event);
|
||||
if (target.type === "dm" && !allowDm) {
|
||||
@@ -185,7 +184,6 @@ async function sendTextChunkToTarget(params: {
|
||||
return await senderSendText(target, text, creds, {
|
||||
msgId: event.messageId,
|
||||
messageReference: ref,
|
||||
forcePlainText,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -213,35 +211,6 @@ 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;
|
||||
@@ -249,13 +218,11 @@ 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, forcePlainText, log } =
|
||||
params;
|
||||
const { account, event, chunks, sendWithRetry, consumeQuoteRef, allowDm, log } = params;
|
||||
for (const chunk of chunks) {
|
||||
try {
|
||||
await sendWithRetry((token) =>
|
||||
@@ -266,7 +233,6 @@ 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; forcePlainText?: boolean },
|
||||
opts?: { msgId?: string; messageReference?: string },
|
||||
): Promise<MessageResponse> {
|
||||
const api = resolveAccount(creds.appId).messageApi;
|
||||
const c: Credentials = { appId: creds.appId, clientSecret: creds.clientSecret };
|
||||
@@ -394,12 +394,9 @@ 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, {
|
||||
forcePlainText: opts?.forcePlainText,
|
||||
});
|
||||
return api.sendProactiveMessage(scope, target.id, content, c);
|
||||
}
|
||||
|
||||
if (target.type === "dm") {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
signalRpcRequest as signalRpcRequestImpl,
|
||||
detectSignalApiMode,
|
||||
@@ -37,11 +37,6 @@ beforeEach(() => {
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function setApiMode(mode: SignalApiMode) {
|
||||
currentApiMode = mode;
|
||||
}
|
||||
@@ -383,44 +378,6 @@ 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,10 +6,6 @@
|
||||
* only need to change their import path.
|
||||
*/
|
||||
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
containerCheck,
|
||||
containerRpcRequest,
|
||||
@@ -78,33 +74,24 @@ 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) {
|
||||
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);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
if (
|
||||
cached.mode !== "container" ||
|
||||
!options.requireContainerReceive ||
|
||||
(Boolean(options.account?.trim()) && cached.receiveAccount === options.account?.trim())
|
||||
) {
|
||||
return cached.mode;
|
||||
}
|
||||
}
|
||||
const detected = await detectSignalApiMode(baseUrl, timeoutMs, options);
|
||||
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 }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
detectedModeCache.set(baseUrl, {
|
||||
mode: detected,
|
||||
expiresAt: Date.now() + MODE_CACHE_TTL_MS,
|
||||
...(detected === "container" && options.requireContainerReceive && options.account
|
||||
? { receiveAccount: options.account }
|
||||
: {}),
|
||||
});
|
||||
return detected;
|
||||
}
|
||||
|
||||
|
||||
@@ -160,117 +160,6 @@ 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,10 +9,6 @@ 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,
|
||||
@@ -240,33 +236,26 @@ async function resolveSlackChannelMemberIds(
|
||||
"OPENCLAW_SLACK_CHANNEL_MEMBERS_CACHE_TTL_MS",
|
||||
DEFAULT_CHANNEL_MEMBERS_CACHE_TTL_MS,
|
||||
);
|
||||
const rawNowMs = Date.now();
|
||||
const nowMs = asDateTimestampMs(rawNowMs);
|
||||
const nowMs = Date.now();
|
||||
const cached = cache.get(key);
|
||||
if (cached?.members) {
|
||||
if (ttlMs > 0 && nowMs !== undefined && cached.expiresAtMs >= nowMs) {
|
||||
return cached.members;
|
||||
}
|
||||
cache.delete(key);
|
||||
if (ttlMs > 0 && cached?.members && cached.expiresAtMs >= nowMs) {
|
||||
return cached.members;
|
||||
}
|
||||
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: pendingExpiresAtMs ?? 0,
|
||||
expiresAtMs: ttlMs > 0 ? nowMs + ttlMs : 0,
|
||||
pending,
|
||||
});
|
||||
pruneChannelMembersCache(cache);
|
||||
try {
|
||||
const members = await pending;
|
||||
const membersExpiresAtMs = ttlMs > 0 ? resolveExpiresAtMsFromDurationMs(ttlMs) : undefined;
|
||||
if (membersExpiresAtMs !== undefined) {
|
||||
if (ttlMs > 0) {
|
||||
cache.set(key, {
|
||||
expiresAtMs: membersExpiresAtMs,
|
||||
expiresAtMs: Date.now() + ttlMs,
|
||||
members,
|
||||
});
|
||||
pruneChannelMembersCache(cache);
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
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,7 +1,3 @@
|
||||
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;
|
||||
@@ -24,15 +20,10 @@ type SlackExternalArgMenuEntry = {
|
||||
|
||||
function pruneSlackExternalArgMenuStore(
|
||||
store: Map<string, SlackExternalArgMenuEntry>,
|
||||
rawNow: number,
|
||||
now: number,
|
||||
): void {
|
||||
const now = asDateTimestampMs(rawNow);
|
||||
if (now === undefined) {
|
||||
store.clear();
|
||||
return;
|
||||
}
|
||||
for (const [token, entry] of store.entries()) {
|
||||
if (asDateTimestampMs(entry.expiresAt) === undefined || entry.expiresAt <= now) {
|
||||
if (entry.expiresAt <= now) {
|
||||
store.delete(token);
|
||||
}
|
||||
}
|
||||
@@ -56,16 +47,11 @@ export function createSlackExternalArgMenuStore() {
|
||||
): string {
|
||||
pruneSlackExternalArgMenuStore(store, now);
|
||||
const token = createSlackExternalArgMenuToken(store);
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(SLACK_EXTERNAL_ARG_MENU_TTL_MS, {
|
||||
nowMs: now,
|
||||
store.set(token, {
|
||||
choices: params.choices,
|
||||
userId: params.userId,
|
||||
expiresAt: now + SLACK_EXTERNAL_ARG_MENU_TTL_MS,
|
||||
});
|
||||
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 { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const prepareSlackMessageMock =
|
||||
vi.fn<
|
||||
@@ -151,10 +151,6 @@ 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") {
|
||||
@@ -173,43 +169,6 @@ 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,10 +3,6 @@ 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";
|
||||
@@ -127,7 +123,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.
|
||||
rememberExpiringAppMentionKey(appMentionDispatchedKeys, seenMessageKey);
|
||||
appMentionDispatchedKeys.set(seenMessageKey, Date.now() + APP_MENTION_RETRY_TTL_MS);
|
||||
} else if (
|
||||
last.opts.source === "message" &&
|
||||
appMentionDispatchedKeys.has(seenMessageKey)
|
||||
@@ -180,46 +176,28 @@ export function createSlackMessageHandler(params: {
|
||||
const appMentionRetryKeys = new Map<string, number>();
|
||||
const appMentionDispatchedKeys = new Map<string, number>();
|
||||
|
||||
const pruneAppMentionRetryKeys = (rawNow: number): boolean => {
|
||||
const now = asDateTimestampMs(rawNow);
|
||||
if (now === undefined) {
|
||||
appMentionRetryKeys.clear();
|
||||
appMentionDispatchedKeys.clear();
|
||||
return false;
|
||||
}
|
||||
const pruneAppMentionRetryKeys = (now: number) => {
|
||||
for (const [key, expiresAt] of appMentionRetryKeys) {
|
||||
if (asDateTimestampMs(expiresAt) === undefined || expiresAt <= now) {
|
||||
if (expiresAt <= now) {
|
||||
appMentionRetryKeys.delete(key);
|
||||
}
|
||||
}
|
||||
for (const [key, expiresAt] of appMentionDispatchedKeys) {
|
||||
if (asDateTimestampMs(expiresAt) === undefined || expiresAt <= now) {
|
||||
if (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) => {
|
||||
rememberExpiringAppMentionKey(appMentionRetryKeys, key);
|
||||
const now = Date.now();
|
||||
pruneAppMentionRetryKeys(now);
|
||||
appMentionRetryKeys.set(key, now + APP_MENTION_RETRY_TTL_MS);
|
||||
};
|
||||
|
||||
const consumeAppMentionRetryKey = (key: string) => {
|
||||
const now = Date.now();
|
||||
if (!pruneAppMentionRetryKeys(now)) {
|
||||
return false;
|
||||
}
|
||||
pruneAppMentionRetryKeys(now);
|
||||
if (!appMentionRetryKeys.has(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -71,52 +71,6 @@ 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,9 +1,5 @@
|
||||
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;
|
||||
@@ -48,16 +44,8 @@ async function readSlackSubteamUsers(params: {
|
||||
}
|
||||
const cacheKey = `${normalizeSlackId(params.teamId) ?? ""}:${params.subteamId}`;
|
||||
const cached = bySubteam.get(cacheKey);
|
||||
const now = asDateTimestampMs(params.now);
|
||||
if (cached) {
|
||||
if (
|
||||
now !== undefined &&
|
||||
asDateTimestampMs(cached.expiresAt) !== undefined &&
|
||||
cached.expiresAt > now
|
||||
) {
|
||||
return cached.users;
|
||||
}
|
||||
bySubteam.delete(cacheKey);
|
||||
if (cached && cached.expiresAt > params.now) {
|
||||
return cached.users;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -74,15 +62,10 @@ async function readSlackSubteamUsers(params: {
|
||||
const users = new Set(
|
||||
(response.users ?? []).map((userId) => normalizeSlackId(userId)).filter(Boolean) as string[],
|
||||
);
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(SUBTEAM_MEMBER_CACHE_TTL_MS, {
|
||||
nowMs: params.now,
|
||||
bySubteam.set(cacheKey, {
|
||||
expiresAt: params.now + SUBTEAM_MEMBER_CACHE_TTL_MS,
|
||||
users,
|
||||
});
|
||||
if (expiresAt !== undefined) {
|
||||
bySubteam.set(cacheKey, {
|
||||
expiresAt,
|
||||
users,
|
||||
});
|
||||
}
|
||||
return users;
|
||||
} catch (err) {
|
||||
params.log?.(
|
||||
|
||||
@@ -61,45 +61,6 @@ 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,13 +1,8 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { 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",
|
||||
@@ -58,75 +53,25 @@ describe("createSlackThreadTsResolver", () => {
|
||||
|
||||
it("falls back to the default ttl when cacheTtlMs is non-finite", async () => {
|
||||
vi.useFakeTimers();
|
||||
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");
|
||||
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");
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
expect(historyMock).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to the default max size when maxSize is non-finite", async () => {
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import type { WebClient as SlackWebClient } from "@slack/web-api";
|
||||
import { pruneMapToMaxSize } from "openclaw/plugin-sdk/collection-runtime";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
parseFiniteNumber,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { parseFiniteNumber } 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;
|
||||
expiresAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
const DEFAULT_THREAD_TS_CACHE_TTL_MS = 60_000;
|
||||
@@ -68,33 +64,18 @@ export function createSlackThreadTsResolver(params: {
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
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
|
||||
) {
|
||||
if (ttlMs > 0 && now - entry.updatedAt > ttlMs) {
|
||||
cache.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
cache.delete(key);
|
||||
cache.set(key, entry);
|
||||
cache.set(key, { ...entry, updatedAt: now });
|
||||
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, expiresAt });
|
||||
cache.set(key, { threadTs, updatedAt: now });
|
||||
pruneMapToMaxSize(cache, maxSize);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
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";
|
||||
@@ -19,7 +15,7 @@ export type SlackThreadStarter = {
|
||||
|
||||
type SlackThreadStarterCacheEntry = {
|
||||
value: SlackThreadStarter;
|
||||
expiresAt: number;
|
||||
cachedAt: number;
|
||||
};
|
||||
|
||||
const THREAD_STARTER_CACHE = new Map<string, SlackThreadStarterCacheEntry>();
|
||||
@@ -27,13 +23,9 @@ const THREAD_STARTER_CACHE_TTL_MS = 6 * 60 * 60_000;
|
||||
const THREAD_STARTER_CACHE_MAX = 2000;
|
||||
|
||||
function evictThreadStarterCache(): void {
|
||||
const now = asDateTimestampMs(Date.now());
|
||||
if (now === undefined) {
|
||||
THREAD_STARTER_CACHE.clear();
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
for (const [cacheKey, entry] of THREAD_STARTER_CACHE.entries()) {
|
||||
if (asDateTimestampMs(entry.expiresAt) === undefined || entry.expiresAt <= now) {
|
||||
if (now - entry.cachedAt > THREAD_STARTER_CACHE_TTL_MS) {
|
||||
THREAD_STARTER_CACHE.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
@@ -52,11 +44,10 @@ 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 {
|
||||
@@ -87,17 +78,14 @@ export async function resolveSlackThreadStarter(params: {
|
||||
ts: message.ts,
|
||||
files,
|
||||
};
|
||||
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();
|
||||
if (THREAD_STARTER_CACHE.has(cacheKey)) {
|
||||
THREAD_STARTER_CACHE.delete(cacheKey);
|
||||
}
|
||||
THREAD_STARTER_CACHE.set(cacheKey, {
|
||||
value: starter,
|
||||
cachedAt: Date.now(),
|
||||
});
|
||||
evictThreadStarterCache();
|
||||
return starter;
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildTelegramInboundOriginTarget,
|
||||
buildTelegramRoutingTarget,
|
||||
@@ -42,10 +42,6 @@ describe("resolveTelegramForumFlag", () => {
|
||||
resetTelegramForumFlagCacheForTest();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("keeps explicit forum metadata when Telegram already provides it", async () => {
|
||||
const getChat = vi.fn(async () => ({ is_forum: false }));
|
||||
await expect(
|
||||
@@ -130,35 +126,6 @@ describe("resolveTelegramForumFlag", () => {
|
||||
expect(getChat).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("drops cached forum metadata when the current clock is not a valid date timestamp", async () => {
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
||||
const getChat = vi.fn(async () => ({ is_forum: true }));
|
||||
const params = {
|
||||
chatId: -100655,
|
||||
chatType: "supergroup" as const,
|
||||
isGroup: true,
|
||||
getChat,
|
||||
};
|
||||
await expect(resolveTelegramForumFlag(params)).resolves.toBe(true);
|
||||
nowSpy.mockReturnValue(Number.NaN);
|
||||
await expect(resolveTelegramForumFlag(params)).resolves.toBe(true);
|
||||
expect(getChat).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not cache forum metadata when the expiry timestamp would exceed the valid date range", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
|
||||
const getChat = vi.fn(async () => ({ is_forum: true }));
|
||||
const params = {
|
||||
chatId: -100656,
|
||||
chatType: "supergroup" as const,
|
||||
isGroup: true,
|
||||
getChat,
|
||||
};
|
||||
await expect(resolveTelegramForumFlag(params)).resolves.toBe(true);
|
||||
await expect(resolveTelegramForumFlag(params)).resolves.toBe(true);
|
||||
expect(getChat).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("returns false when forum lookup is unavailable", async () => {
|
||||
const getChat = vi.fn(async () => {
|
||||
throw new Error("lookup failed");
|
||||
|
||||
@@ -12,10 +12,6 @@ import type {
|
||||
TelegramTopicConfig,
|
||||
} from "openclaw/plugin-sdk/config-contracts";
|
||||
import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { expandTelegramAllowFromWithAccessGroups } from "../access-groups.js";
|
||||
@@ -68,13 +64,6 @@ export function resetTelegramForumFlagCacheForTest(): void {
|
||||
|
||||
function cacheTelegramForumFlag(chatId: string | number, isForum: boolean, nowMs = Date.now()) {
|
||||
const cacheKey = String(chatId);
|
||||
const expiresAtMs = resolveExpiresAtMsFromDurationMs(TELEGRAM_FORUM_FLAG_CACHE_TTL_MS, {
|
||||
nowMs,
|
||||
});
|
||||
if (expiresAtMs === undefined) {
|
||||
telegramForumFlagByChatId.delete(cacheKey);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!telegramForumFlagByChatId.has(cacheKey) &&
|
||||
telegramForumFlagByChatId.size >= TELEGRAM_FORUM_FLAG_CACHE_MAX_CHATS
|
||||
@@ -85,7 +74,7 @@ function cacheTelegramForumFlag(chatId: string | number, isForum: boolean, nowMs
|
||||
}
|
||||
}
|
||||
telegramForumFlagByChatId.set(cacheKey, {
|
||||
expiresAtMs,
|
||||
expiresAtMs: nowMs + TELEGRAM_FORUM_FLAG_CACHE_TTL_MS,
|
||||
isForum,
|
||||
});
|
||||
}
|
||||
@@ -157,22 +146,17 @@ export async function resolveTelegramForumFlag(params: {
|
||||
return false;
|
||||
}
|
||||
const cacheKey = String(params.chatId);
|
||||
const rawNowMs = Date.now();
|
||||
const nowMs = asDateTimestampMs(rawNowMs);
|
||||
const nowMs = Date.now();
|
||||
const cached = telegramForumFlagByChatId.get(cacheKey);
|
||||
if (cached && cached.expiresAtMs > nowMs) {
|
||||
return cached.isForum;
|
||||
}
|
||||
if (cached) {
|
||||
if (
|
||||
nowMs !== undefined &&
|
||||
asDateTimestampMs(cached.expiresAtMs) !== undefined &&
|
||||
cached.expiresAtMs > nowMs
|
||||
) {
|
||||
return cached.isForum;
|
||||
}
|
||||
telegramForumFlagByChatId.delete(cacheKey);
|
||||
}
|
||||
try {
|
||||
const resolved = extractTelegramForumFlag(await params.getChat(params.chatId)) === true;
|
||||
cacheTelegramForumFlag(params.chatId, resolved, rawNowMs);
|
||||
cacheTelegramForumFlag(params.chatId, resolved, nowMs);
|
||||
return resolved;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@@ -102,24 +102,6 @@
|
||||
"../dist/plugin-sdk/packages/llm-core/src/validation.d.ts"
|
||||
],
|
||||
"@openclaw/llm-core/*": ["../dist/plugin-sdk/packages/llm-core/src/*.d.ts"],
|
||||
"@openclaw/model-catalog-core": [
|
||||
"../dist/plugin-sdk/packages/model-catalog-core/src/index.d.ts"
|
||||
],
|
||||
"@openclaw/model-catalog-core/configured-model-refs": [
|
||||
"../dist/plugin-sdk/packages/model-catalog-core/src/configured-model-refs.d.ts"
|
||||
],
|
||||
"@openclaw/model-catalog-core/provider-id": [
|
||||
"../dist/plugin-sdk/packages/model-catalog-core/src/provider-id.d.ts"
|
||||
],
|
||||
"@openclaw/model-catalog-core/provider-model-id-normalization": [
|
||||
"../dist/plugin-sdk/packages/model-catalog-core/src/provider-model-id-normalization.d.ts"
|
||||
],
|
||||
"@openclaw/model-catalog-core/provider-model-id-normalize": [
|
||||
"../dist/plugin-sdk/packages/model-catalog-core/src/provider-model-id-normalize.d.ts"
|
||||
],
|
||||
"@openclaw/model-catalog-core/*": [
|
||||
"../dist/plugin-sdk/packages/model-catalog-core/src/*.d.ts"
|
||||
],
|
||||
"@openclaw/markdown-core": [
|
||||
"../dist/plugin-sdk/packages/markdown-core/src/index.d.ts"
|
||||
],
|
||||
|
||||
@@ -739,40 +739,6 @@ describe("VoiceCallWebhookServer replay handling", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not cache replay responses when the TTL would exceed the Date range", async () => {
|
||||
const dateNow = vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
|
||||
let parseCount = 0;
|
||||
const parseWebhookEvent = vi.fn(() => ({
|
||||
events: [],
|
||||
statusCode: 200,
|
||||
providerResponseBody: `OK-${++parseCount}`,
|
||||
}));
|
||||
const replayProvider: VoiceCallProvider = {
|
||||
...provider,
|
||||
verifyWebhook: () => ({ ok: true, verifiedRequestKey: "mock:req:overflow-cache" }),
|
||||
parseWebhookEvent,
|
||||
};
|
||||
const { manager } = createManager([]);
|
||||
const config = createConfig({ serve: { port: 0, bind: "127.0.0.1", path: "/voice/webhook" } });
|
||||
const server = new VoiceCallWebhookServer(config, manager, replayProvider);
|
||||
|
||||
try {
|
||||
const baseUrl = await server.start();
|
||||
const first = await postWebhookForm(server, baseUrl, "CallSid=CA123&SpeechResult=hello");
|
||||
expect(first.status).toBe(200);
|
||||
expect(await first.text()).toBe("OK-1");
|
||||
|
||||
dateNow.mockReturnValue(Date.parse("2026-05-29T12:00:00.000Z"));
|
||||
const second = await postWebhookForm(server, baseUrl, "CallSid=CA123&SpeechResult=hello");
|
||||
expect(second.status).toBe(200);
|
||||
expect(await second.text()).toBe("OK-2");
|
||||
expect(parseWebhookEvent).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
dateNow.mockRestore();
|
||||
await server.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns Plivo XML for replayed answer callbacks while skipping event side effects", async () => {
|
||||
const plivoProvider = new PlivoProvider(
|
||||
{
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import http from "node:http";
|
||||
import { URL } from "node:url";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { resolveConfiguredCapabilityProvider } from "openclaw/plugin-sdk/provider-selection-runtime";
|
||||
import type { TalkEvent } from "openclaw/plugin-sdk/realtime-voice";
|
||||
import {
|
||||
@@ -814,13 +810,10 @@ export class VoiceCallWebhookServer {
|
||||
}
|
||||
}
|
||||
|
||||
private pruneReplayResponses(rawNow: number): void {
|
||||
const now = asDateTimestampMs(rawNow);
|
||||
if (now !== undefined) {
|
||||
for (const [key, entry] of this.replayResponses) {
|
||||
if (entry.expiresAt <= now) {
|
||||
this.replayResponses.delete(key);
|
||||
}
|
||||
private pruneReplayResponses(now: number): void {
|
||||
for (const [key, entry] of this.replayResponses) {
|
||||
if (entry.expiresAt <= now) {
|
||||
this.replayResponses.delete(key);
|
||||
}
|
||||
}
|
||||
while (this.replayResponses.size > WEBHOOK_REPLAY_RESPONSE_MAX_ENTRIES) {
|
||||
@@ -833,9 +826,9 @@ export class VoiceCallWebhookServer {
|
||||
}
|
||||
|
||||
private async getCachedReplayResponse(key: string): Promise<WebhookResponsePayload | null> {
|
||||
const now = asDateTimestampMs(Date.now());
|
||||
const now = Date.now();
|
||||
const entry = this.replayResponses.get(key);
|
||||
if (!entry || now === undefined) {
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
if (entry.expiresAt <= now) {
|
||||
@@ -850,9 +843,6 @@ export class VoiceCallWebhookServer {
|
||||
buildResponse: () => Promise<WebhookResponsePayload>,
|
||||
): Promise<WebhookResponsePayload> {
|
||||
const now = Date.now();
|
||||
const expiresAt = resolveExpiresAtMsFromDurationMs(WEBHOOK_REPLAY_RESPONSE_TTL_MS, {
|
||||
nowMs: now,
|
||||
});
|
||||
this.replayResponseCacheCalls += 1;
|
||||
if (this.replayResponseCacheCalls % WEBHOOK_REPLAY_RESPONSE_PRUNE_INTERVAL === 0) {
|
||||
this.pruneReplayResponses(now);
|
||||
@@ -864,12 +854,10 @@ export class VoiceCallWebhookServer {
|
||||
this.replayResponses.delete(key);
|
||||
throw err;
|
||||
});
|
||||
if (expiresAt !== undefined) {
|
||||
this.replayResponses.set(key, {
|
||||
expiresAt,
|
||||
response,
|
||||
});
|
||||
}
|
||||
this.replayResponses.set(key, {
|
||||
expiresAt: now + WEBHOOK_REPLAY_RESPONSE_TTL_MS,
|
||||
response,
|
||||
});
|
||||
if (this.replayResponses.size > WEBHOOK_REPLAY_RESPONSE_MAX_ENTRIES) {
|
||||
this.pruneReplayResponses(now);
|
||||
}
|
||||
|
||||
@@ -10,11 +10,7 @@ import { recordChannelActivity } from "openclaw/plugin-sdk/channel-activity-runt
|
||||
import { formatLocationText } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { createInboundDebouncer } from "openclaw/plugin-sdk/channel-inbound-debounce";
|
||||
import { getChildLogger } from "openclaw/plugin-sdk/logging-core";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
parseStrictFiniteNumber,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import { parseStrictFiniteNumber } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { defaultRuntime } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
@@ -84,13 +80,6 @@ type LocalGroupMetadataCacheEntry = WhatsAppGroupMetadataCacheEntry & {
|
||||
mentionParticipants?: WhatsAppOutboundMentionParticipant[];
|
||||
};
|
||||
|
||||
function resolveGroupMetadataExpiresAt(nowRaw = Date.now()): number | undefined {
|
||||
const now = asDateTimestampMs(nowRaw);
|
||||
return now === undefined
|
||||
? undefined
|
||||
: resolveExpiresAtMsFromDurationMs(GROUP_META_TTL_MS, { nowMs: now });
|
||||
}
|
||||
|
||||
function parseWhatsAppTimestampSeconds(value: unknown): number | undefined {
|
||||
if (value == null) {
|
||||
return undefined;
|
||||
@@ -107,10 +96,6 @@ function rememberGroupMetadataCacheEntry<T extends WhatsAppGroupMetadataCacheEnt
|
||||
jid: string,
|
||||
entry: T,
|
||||
): void {
|
||||
if (asDateTimestampMs(entry.expires) === undefined) {
|
||||
cache.delete(jid);
|
||||
return;
|
||||
}
|
||||
if (cache.has(jid)) {
|
||||
cache.delete(jid);
|
||||
}
|
||||
@@ -133,9 +118,7 @@ function readGroupMetadataCacheEntry<T extends WhatsAppGroupMetadataCacheEntry>(
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
const now = asDateTimestampMs(Date.now());
|
||||
const expires = asDateTimestampMs(entry.expires);
|
||||
if (now === undefined || expires === undefined || expires <= now) {
|
||||
if (entry.expires <= Date.now()) {
|
||||
cache.delete(jid);
|
||||
return null;
|
||||
}
|
||||
@@ -489,7 +472,7 @@ export async function attachWebInboxToSocket(
|
||||
subject: meta.subject,
|
||||
participants,
|
||||
mentionParticipants,
|
||||
expires: resolveGroupMetadataExpiresAt() ?? 0,
|
||||
expires: Date.now() + GROUP_META_TTL_MS,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -497,7 +480,7 @@ export async function attachWebInboxToSocket(
|
||||
meta: GroupMetadata,
|
||||
): WhatsAppGroupMetadataCacheEntry => ({
|
||||
subject: meta.subject,
|
||||
expires: resolveGroupMetadataExpiresAt() ?? Number.NaN,
|
||||
expires: Date.now() + GROUP_META_TTL_MS,
|
||||
});
|
||||
|
||||
const getGroupMeta = async (jid: string) => {
|
||||
@@ -528,7 +511,7 @@ export async function attachWebInboxToSocket(
|
||||
options.verbose,
|
||||
`Failed to fetch group metadata for ${jid}: ${String(err)}`,
|
||||
);
|
||||
return { expires: resolveGroupMetadataExpiresAt() ?? 0 };
|
||||
return { expires: Date.now() + GROUP_META_TTL_MS };
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -427,35 +427,6 @@ describe("web monitor inbox", () => {
|
||||
await listener.close();
|
||||
});
|
||||
|
||||
it("does not keep reconnect group metadata when the expiry would exceed a valid Date", async () => {
|
||||
const groupMetadataCache: NonNullable<InboxMonitorOptions["groupMetadataCache"]> = new Map();
|
||||
const dateNow = vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
|
||||
try {
|
||||
const sock = getSock();
|
||||
sock.groupFetchAllParticipating.mockResolvedValueOnce({
|
||||
"123@g.us": {
|
||||
id: "123@g.us",
|
||||
subject: "Boundary Group",
|
||||
owner: undefined,
|
||||
participants: [],
|
||||
},
|
||||
});
|
||||
|
||||
const { listener } = await startInboxMonitor(vi.fn(async () => {}) as InboxOnMessage, {
|
||||
groupMetadataCache,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(sock.groupFetchAllParticipating).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(groupMetadataCache.has("123@g.us")).toBe(false);
|
||||
|
||||
await listener.close();
|
||||
} finally {
|
||||
dateNow.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not block inbound listeners while group hydration is pending", async () => {
|
||||
let resolveHydration!: () => void;
|
||||
const sock = getSock();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { definePluginEntry } from "./api.js";
|
||||
import { registerWorkboardGatewayMethods } from "./runtime-api.js";
|
||||
import { WorkboardStore } from "./src/store.js";
|
||||
import { WorkboardStore, type PersistedWorkboardCard } from "./src/store.js";
|
||||
import { createWorkboardTools } from "./src/tools.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
@@ -8,7 +8,9 @@ export default definePluginEntry({
|
||||
name: "Workboard",
|
||||
description: "Dashboard workboard for agent-owned issues and sessions.",
|
||||
register(api) {
|
||||
const store = WorkboardStore.open((options) => api.runtime.state.openKeyedStore(options));
|
||||
const store = WorkboardStore.open((options) =>
|
||||
api.runtime.state.openKeyedStore<PersistedWorkboardCard>(options),
|
||||
);
|
||||
registerWorkboardGatewayMethods({ api, store });
|
||||
api.registerTool((context) => createWorkboardTools({ api, context, store }), {
|
||||
names: [
|
||||
@@ -21,16 +23,7 @@ export default definePluginEntry({
|
||||
"workboard_complete",
|
||||
"workboard_block",
|
||||
"workboard_boards",
|
||||
"workboard_board_create",
|
||||
"workboard_board_archive",
|
||||
"workboard_board_delete",
|
||||
"workboard_stats",
|
||||
"workboard_runs",
|
||||
"workboard_specify",
|
||||
"workboard_decompose",
|
||||
"workboard_notify_subscribe",
|
||||
"workboard_notify_list",
|
||||
"workboard_notify_unsubscribe",
|
||||
"workboard_promote",
|
||||
"workboard_reassign",
|
||||
"workboard_reclaim",
|
||||
|
||||
@@ -17,16 +17,7 @@
|
||||
"workboard_complete",
|
||||
"workboard_block",
|
||||
"workboard_boards",
|
||||
"workboard_board_create",
|
||||
"workboard_board_archive",
|
||||
"workboard_board_delete",
|
||||
"workboard_stats",
|
||||
"workboard_runs",
|
||||
"workboard_specify",
|
||||
"workboard_decompose",
|
||||
"workboard_notify_subscribe",
|
||||
"workboard_notify_list",
|
||||
"workboard_notify_unsubscribe",
|
||||
"workboard_promote",
|
||||
"workboard_reassign",
|
||||
"workboard_reclaim",
|
||||
@@ -65,36 +56,9 @@
|
||||
"workboard_boards": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_board_create": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_board_archive": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_board_delete": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_stats": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_runs": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_specify": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_decompose": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_notify_subscribe": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_notify_list": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_notify_unsubscribe": {
|
||||
"optional": true
|
||||
},
|
||||
"workboard_promote": {
|
||||
"optional": true
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user