Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e599c66277 | |||
| 870b0d216a | |||
| 8fa91d283b | |||
| 7a459045de | |||
| 323ae73b30 | |||
| 708cccd43b | |||
| cdc8bac466 | |||
| 1b69d9ee1a | |||
| 9b7cfd0bea | |||
| 02e07a157d | |||
| 3de42e946a | |||
| 7bc8e67d2a | |||
| a35dcf608e | |||
| bcaadc39ea | |||
| 9a34a602bd | |||
| b15462ebaf | |||
| 80bd5ba728 | |||
| 0ea3c4d5d8 | |||
| dd586d59ed | |||
| ffb287e1de | |||
| 848414d7f2 | |||
| 36d6ba55e3 | |||
| f9a7427e8e | |||
| 2592eb0796 | |||
| 3fe2f0a550 | |||
| 921a147196 | |||
| df18063260 | |||
| d1c95c84b5 | |||
| 5b09463bc3 | |||
| b4dd600b37 | |||
| a1df10caac | |||
| d98e3a1ea9 | |||
| c55d4f63eb | |||
| 071de383ff | |||
| d5dc6b6573 | |||
| b0ce53a79c | |||
| 50f6a2f136 | |||
| 041c47419f | |||
| 29ad211e76 | |||
| 83e86482c6 | |||
| 93df5f613e | |||
| 1e5f38a1a8 | |||
| beadd4c553 | |||
| b84a130788 | |||
| 7299b42e2a | |||
| 2615402a2b | |||
| e68cbea5b4 | |||
| 70b235f312 | |||
| 31675d65d4 | |||
| 6872e079e6 | |||
| 8a8ed34716 | |||
| 8ed33c2aff | |||
| e9078b3ff6 | |||
| d2e8ed3632 | |||
| dd132ea77b | |||
| c278e8d7a8 | |||
| fcafd8d4de | |||
| b2bf508dc5 | |||
| 05e31eb9ff | |||
| 3725b38335 | |||
| 4e849ac127 | |||
| 8fdb9194d2 | |||
| b393effba6 | |||
| 949d6be1d1 | |||
| 383c61e305 | |||
| cd7d49b48e | |||
| 41850c3880 | |||
| 76dc854963 | |||
| 4e661d5c4b | |||
| 08b5ba1a12 | |||
| 69b9e44762 | |||
| 9f2330363e | |||
| a0cb443aa3 | |||
| 47db5abece | |||
| 8fd2fa13c6 | |||
| f98811a67c | |||
| b3844d920a | |||
| e84ca730a3 | |||
| 4f92eaad37 | |||
| a6c7ad84ba | |||
| 6a3d4f9fad | |||
| 6bcd9a801a | |||
| d841d02439 | |||
| adf63eba9f | |||
| f182c3a292 | |||
| fc9739313c |
@@ -23,6 +23,7 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
|
||||
- Preferred entrypoint: `pnpm test:parallels:npm-update`
|
||||
- Flow: fresh snapshot -> install npm package baseline -> smoke -> install current main tgz on the same guest -> smoke again.
|
||||
- Same-guest update verification should set the default model explicitly to `openai/gpt-5.4` before the agent turn and use a fresh explicit `--session-id` so old session model state does not leak into the check.
|
||||
- Keep the aggregate npm-update Linux VM name aligned with the default Linux smoke VM (`Ubuntu 24.04.3 ARM64` on Peter's host today). Do not hardcode a different Linux guest in the wrapper unless the per-OS Linux smoke default changed too.
|
||||
- On Windows same-guest update checks, restart the gateway after the npm upgrade before `gateway status` / `agent`; in-place global npm updates can otherwise leave stale hashed `dist/*` module imports alive in the running service.
|
||||
- Linux same-guest update verification should also export `HOME=/root`, pass `OPENAI_API_KEY` via `prlctl exec ... /usr/bin/env`, and use `openclaw agent --local`; the fresh Linux baseline does not rely on persisted gateway credentials.
|
||||
|
||||
@@ -32,6 +33,7 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
|
||||
- Default to the snapshot closest to `macOS 26.3.1 latest`.
|
||||
- On Peter's Tahoe VM, `fresh-latest-march-2026` can hang in `prlctl snapshot-switch`; if restore times out there, rerun with `--snapshot-hint 'macOS 26.3.1 latest'` before blaming auth or the harness.
|
||||
- `prlctl exec` is fine for deterministic repo commands, but use the guest Terminal or `prlctl enter` when installer parity or shell-sensitive behavior matters.
|
||||
- Multi-word `openclaw agent --message ...` checks should go through a guest shell wrapper (`guest_current_user_sh` / `guest_current_user_cli` or `/bin/sh -lc ...`), not raw `prlctl exec ... node openclaw.mjs ...`, or the message can be split into extra argv tokens and Commander reports `too many arguments for 'agent'`.
|
||||
- On the fresh Tahoe snapshot, `brew` exists but `node` may be missing from PATH in noninteractive exec. Use `/opt/homebrew/bin/node` when needed.
|
||||
- Fresh host-served tgz installs should install as guest root with `HOME=/var/root`, then run onboarding as the desktop user via `prlctl exec --current-user`.
|
||||
- Root-installed tgz smoke can log plugin blocks for world-writable `extensions/*`; do not treat that as an onboarding or gateway failure unless plugin loading is the task.
|
||||
|
||||
@@ -11,7 +11,9 @@ Use this skill for release and publish-time workflow. Keep ordinary development
|
||||
|
||||
- Do not change version numbers without explicit operator approval.
|
||||
- Ask permission before any npm publish or release step.
|
||||
- Use the private maintainer release docs for the actual runbook and `docs/reference/RELEASING.md` for public policy.
|
||||
- This skill should be sufficient to drive the normal release flow end-to-end.
|
||||
- Use the private maintainer release docs for credentials, recovery steps, and mac signing/notary specifics, and use `docs/reference/RELEASING.md` for public policy.
|
||||
- Core `openclaw` publish is manual `workflow_dispatch`; creating or pushing a tag does not publish by itself.
|
||||
|
||||
## Keep release channel naming aligned
|
||||
|
||||
@@ -31,8 +33,19 @@ Use this skill for release and publish-time workflow. Keep ordinary development
|
||||
- `apps/macos/Sources/OpenClaw/Resources/Info.plist`
|
||||
- `docs/install/updating.md`
|
||||
- Peekaboo Xcode project and plist version fields
|
||||
- Before creating a release tag, make every version location above match the version encoded by that tag.
|
||||
- For fallback correction tags like `vYYYY.M.D-N`, the repo version locations still stay at `YYYY.M.D`.
|
||||
- “Bump version everywhere” means all version locations above except `appcast.xml`.
|
||||
- Release signing and notary credentials live outside the repo in the private maintainer docs.
|
||||
- Every OpenClaw release ships the npm package and macOS app together.
|
||||
- The production Sparkle feed lives at `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`, and the canonical published file is `appcast.xml` on `main` in the `openclaw` repo.
|
||||
- That shared production Sparkle feed is stable-only. Beta mac releases may
|
||||
upload assets to the GitHub prerelease, but they must not replace the shared
|
||||
`appcast.xml` unless a separate beta feed exists.
|
||||
- For fallback correction tags like `vYYYY.M.D-N`, the repo version still stays
|
||||
at `YYYY.M.D`, but the mac release must use a strictly higher numeric
|
||||
`APP_BUILD` / Sparkle build than the original release so existing installs
|
||||
see it as newer.
|
||||
|
||||
## Build changelog-backed release notes
|
||||
|
||||
@@ -62,13 +75,104 @@ For a non-root smoke path:
|
||||
OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke
|
||||
```
|
||||
|
||||
## Check all relevant release builds
|
||||
|
||||
- Always validate the OpenClaw npm release path before creating the tag.
|
||||
- Default release checks:
|
||||
- `pnpm check`
|
||||
- `pnpm build`
|
||||
- `node --import tsx scripts/release-check.ts`
|
||||
- `pnpm release:check`
|
||||
- `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke`
|
||||
- Check all release-related build surfaces touched by the release, not only the npm package.
|
||||
- Include mac release readiness in preflight by running or inspecting the mac
|
||||
packaging, notarization, and appcast flow for every release.
|
||||
- Treat the `appcast.xml` update on `main` as part of mac release readiness, not an optional follow-up.
|
||||
- The workflows remain tag-based. The agent is responsible for making sure
|
||||
preflight runs complete successfully before any publish run starts.
|
||||
- Any fix after preflight means a new commit. Delete and recreate the tag and
|
||||
matching GitHub release from the fixed commit, then rerun preflight from
|
||||
scratch before publishing.
|
||||
- For stable mac releases, generate the signed `appcast.xml` before uploading
|
||||
public release assets so the updater feed cannot lag the published binaries.
|
||||
- Serialize stable appcast-producing runs across tags so two releases do not
|
||||
generate replacement `appcast.xml` files from the same stale seed.
|
||||
- For stable releases, confirm the latest beta already passed the broader release workflows before cutting stable.
|
||||
- If any required build, packaging step, or release workflow is red, do not say the release is ready.
|
||||
|
||||
## Use the right auth flow
|
||||
|
||||
- Core `openclaw` publish uses GitHub trusted publishing.
|
||||
- Do not use `NPM_TOKEN` or the plugin OTP flow for core releases.
|
||||
- OpenClaw publish uses GitHub trusted publishing.
|
||||
- The publish run must be started manually with `workflow_dispatch`.
|
||||
- Both release workflows accept `preflight_only=true` to run CI
|
||||
validation/build steps without entering the gated publish job.
|
||||
- npm preflight and macOS preflight must both pass before any publish run
|
||||
starts.
|
||||
- The release workflows stay tag-based; rely on the documented release sequence
|
||||
rather than workflow-level SHA pinning.
|
||||
- The `npm-release` environment must be approved by `@openclaw/openclaw-release-managers` before publish continues.
|
||||
- Mac publish uses `.github/workflows/macos-release.yml` for build, signing,
|
||||
notarization, stable-feed `appcast.xml` artifact generation, and release-asset
|
||||
upload.
|
||||
- The agent must download the signed `appcast.xml` artifact from a successful
|
||||
stable mac workflow and then update `appcast.xml` on `main`.
|
||||
- For beta mac releases, do not update the shared production `appcast.xml`
|
||||
unless a separate beta Sparkle feed exists.
|
||||
- `.github/workflows/macos-release.yml` still requires the `mac-release`
|
||||
environment approval.
|
||||
- Do not use `NPM_TOKEN` or the plugin OTP flow for OpenClaw releases.
|
||||
- `@openclaw/*` plugin publishes use a separate maintainer-only flow.
|
||||
- Only publish plugins that already exist on npm; bundled disk-tree-only plugins stay unpublished.
|
||||
|
||||
## Fallback local mac publish
|
||||
|
||||
- Keep the original local macOS publish workflow available as a fallback in case
|
||||
CI/CD mac publishing is unavailable or broken.
|
||||
- Preserve the existing maintainer workflow Peter uses: run it on a real Mac
|
||||
with local signing, notary, and Sparkle credentials already configured.
|
||||
- Follow the private maintainer macOS runbook for the local steps:
|
||||
`scripts/package-mac-dist.sh` to build, sign, notarize, and package the app;
|
||||
manual GitHub release asset upload; then `scripts/make_appcast.sh` plus the
|
||||
`appcast.xml` commit to `main`.
|
||||
- For stable tags, the local fallback may update the shared production
|
||||
`appcast.xml`.
|
||||
- For beta tags, the local fallback still publishes the mac assets but must not
|
||||
update the shared production `appcast.xml` unless a separate beta feed exists.
|
||||
- Treat the local workflow as fallback only. Prefer the CI/CD publish workflow
|
||||
when it is working.
|
||||
|
||||
## Run the release sequence
|
||||
|
||||
1. Confirm the operator explicitly wants to cut a release.
|
||||
2. Choose the exact target version and git tag.
|
||||
3. Make every repo version location match that tag before creating it.
|
||||
4. Update `CHANGELOG.md` and assemble the matching GitHub release notes.
|
||||
5. Run the full preflight for all relevant release builds, including mac readiness.
|
||||
6. Confirm the target npm version is not already published.
|
||||
7. Create and push the git tag.
|
||||
8. Create or refresh the matching GitHub release.
|
||||
9. Start `.github/workflows/openclaw-npm-release.yml` with `preflight_only=true`
|
||||
and wait for it to pass.
|
||||
10. Start `.github/workflows/macos-release.yml` with `preflight_only=true` and
|
||||
wait for it to pass.
|
||||
11. If either preflight fails, fix the issue on a new commit, delete the tag
|
||||
and matching GitHub release, recreate them from the fixed commit, and rerun
|
||||
both preflights from scratch before continuing. Never reuse old preflight
|
||||
results after the commit changes.
|
||||
12. Start `.github/workflows/openclaw-npm-release.yml` with the same tag for
|
||||
the real publish.
|
||||
13. Wait for `npm-release` approval from `@openclaw/openclaw-release-managers`.
|
||||
14. Start `.github/workflows/macos-release.yml` for the real publish and wait
|
||||
for `mac-release` approval and success.
|
||||
15. For stable releases, let the mac workflow generate the signed
|
||||
`appcast.xml` artifact before it uploads the public mac assets, then
|
||||
download that artifact from the successful run, update `appcast.xml` on
|
||||
`main`, and verify the feed.
|
||||
16. For beta releases, publish the mac assets but expect no shared production
|
||||
`appcast.xml` artifact and do not update the shared production feed unless a
|
||||
separate beta feed exists.
|
||||
17. After publish, verify npm and any attached release artifacts.
|
||||
|
||||
## GHSA advisory work
|
||||
|
||||
- Use `openclaw-ghsa-maintainer` for GHSA advisory inspection, patch/publish flow, private-fork validation, and GHSA API-specific publish checks.
|
||||
|
||||
@@ -241,6 +241,10 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/byteplus/**"
|
||||
"extensions: deepseek":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/deepseek/**"
|
||||
"extensions: anthropic":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
@@ -31,6 +31,32 @@ Describe the problem and fix in 2–5 bullets:
|
||||
|
||||
- Closes #
|
||||
- Related #
|
||||
- [ ] This PR fixes a bug or regression
|
||||
|
||||
## Root Cause / Regression History (if applicable)
|
||||
|
||||
For bug fixes or regressions, explain why this happened, not just what changed. Otherwise write `N/A`. If the cause is unclear, write `Unknown`.
|
||||
|
||||
- Root cause:
|
||||
- Missing detection / guardrail:
|
||||
- Prior context (`git blame`, prior PR, issue, or refactor if known):
|
||||
- Why this regressed now:
|
||||
- If unknown, what was ruled out:
|
||||
|
||||
## Regression Test Plan (if applicable)
|
||||
|
||||
For bug fixes or regressions, name the smallest reliable test coverage that should have caught this. Otherwise write `N/A`.
|
||||
|
||||
- Coverage level that should have caught this:
|
||||
- [ ] Unit test
|
||||
- [ ] Seam / integration test
|
||||
- [ ] End-to-end test
|
||||
- [ ] Existing coverage already sufficient
|
||||
- Target test or file:
|
||||
- Scenario the test should lock in:
|
||||
- Why this is the smallest reliable guardrail:
|
||||
- Existing test that already covers this (if any):
|
||||
- If no new test is added, why not:
|
||||
|
||||
## User-visible / Behavior Changes
|
||||
|
||||
|
||||
@@ -187,6 +187,9 @@ jobs:
|
||||
- name: Build dist
|
||||
run: pnpm build
|
||||
|
||||
- name: Build Control UI
|
||||
run: pnpm ui:build
|
||||
|
||||
- name: Upload dist artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -254,13 +257,14 @@ jobs:
|
||||
command: pnpm protocol:check
|
||||
- runtime: bun
|
||||
task: test
|
||||
command: pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts
|
||||
command: pnpm canvas:a2ui:bundle && OPENCLAW_TEST_ISOLATE=1 bunx vitest run --config vitest.unit.config.ts
|
||||
- runtime: node
|
||||
task: compat-node22
|
||||
node_version: "22.x"
|
||||
cache_key_suffix: "node22"
|
||||
command: |
|
||||
pnpm build
|
||||
pnpm ui:build
|
||||
node openclaw.mjs --help
|
||||
node openclaw.mjs status --json --timeout 1
|
||||
pnpm test:build:singleton
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
name: macOS Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Existing release tag to build macOS artifacts for (for example v2026.3.22 or v2026.3.22-beta.1)
|
||||
required: true
|
||||
type: string
|
||||
preflight_only:
|
||||
description: Run validation/build only and skip the gated publish job
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: macos-release-${{ inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.x"
|
||||
PNPM_VERSION: "10.23.0"
|
||||
SPARKLE_FEED_URL: https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
|
||||
|
||||
jobs:
|
||||
preflight_macos_release:
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Validate tag input format
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then
|
||||
echo "Invalid release tag format: ${RELEASE_TAG}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout selected tag
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/${{ inputs.tag }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Ensure matching GitHub release exists
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null
|
||||
|
||||
- name: Validate release tag and package metadata
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
RELEASE_MAIN_REF: origin/main
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RELEASE_SHA=$(git rev-parse HEAD)
|
||||
export RELEASE_SHA RELEASE_TAG RELEASE_MAIN_REF
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
pnpm release:openclaw:npm:check
|
||||
|
||||
- name: Resolve package version
|
||||
id: package_version
|
||||
run: echo "value=$(node -p 'require(\"./package.json\").version')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check
|
||||
run: pnpm check
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Build Control UI
|
||||
run: node scripts/ui.js build
|
||||
|
||||
- name: Verify release contents
|
||||
run: pnpm release:check
|
||||
|
||||
- name: Swift build
|
||||
run: swift build --package-path apps/macos --configuration release
|
||||
|
||||
- name: Swift test
|
||||
run: swift test --package-path apps/macos --parallel
|
||||
|
||||
- name: Package macOS release with ad-hoc signing
|
||||
env:
|
||||
APP_VERSION: ${{ steps.package_version.outputs.value }}
|
||||
BUNDLE_ID: ai.openclaw.mac
|
||||
BUILD_CONFIG: release
|
||||
CODESIGN_TIMESTAMP: "off"
|
||||
SIGN_IDENTITY: "-"
|
||||
SKIP_NOTARIZE: "1"
|
||||
SKIP_PNPM_INSTALL: "1"
|
||||
SKIP_TSC: "1"
|
||||
SKIP_UI_BUILD: "1"
|
||||
SPARKLE_FEED_URL: ${{ env.SPARKLE_FEED_URL }}
|
||||
run: scripts/package-mac-dist.sh
|
||||
|
||||
publish_macos_release:
|
||||
needs: [preflight_macos_release]
|
||||
if: ${{ !inputs.preflight_only }}
|
||||
runs-on: macos-latest
|
||||
environment: mac-release
|
||||
concurrency:
|
||||
# Stable releases all derive the same shared appcast.xml; serialize those
|
||||
# runs so each artifact starts from the latest stable feed snapshot.
|
||||
group: macos-release-publish-${{ contains(inputs.tag, '-beta.') && inputs.tag || 'stable-feed' }}
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Validate tag input format
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then
|
||||
echo "Invalid release tag format: ${RELEASE_TAG}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout selected tag
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/${{ inputs.tag }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Ensure matching GitHub release exists
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null
|
||||
|
||||
- name: Resolve package version
|
||||
id: package_version
|
||||
run: echo "value=$(node -p 'require(\"./package.json\").version')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Determine release channel
|
||||
id: release_channel
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$RELEASE_TAG" == *-beta.* ]]; then
|
||||
echo "is_beta=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "is_beta=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Import Developer ID certificate
|
||||
env:
|
||||
MACOS_DEVELOPER_ID_P12_BASE64: ${{ secrets.MACOS_DEVELOPER_ID_P12_BASE64 }}
|
||||
MACOS_DEVELOPER_ID_P12_PASSWORD: ${{ secrets.MACOS_DEVELOPER_ID_P12_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
CERT_PATH="$RUNNER_TEMP/openclaw-macos-release.p12"
|
||||
KEYCHAIN_PATH="$RUNNER_TEMP/openclaw-release.keychain-db"
|
||||
KEYCHAIN_PASSWORD="$(openssl rand -hex 32)"
|
||||
echo "::add-mask::$KEYCHAIN_PASSWORD"
|
||||
export CERT_PATH MACOS_DEVELOPER_ID_P12_BASE64
|
||||
python3 - <<'PY'
|
||||
import base64
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
Path(os.environ["CERT_PATH"]).write_bytes(
|
||||
base64.b64decode(os.environ["MACOS_DEVELOPER_ID_P12_BASE64"])
|
||||
)
|
||||
PY
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security import "$CERT_PATH" \
|
||||
-k "$KEYCHAIN_PATH" \
|
||||
-P "$MACOS_DEVELOPER_ID_P12_PASSWORD" \
|
||||
-T /usr/bin/codesign \
|
||||
-T /usr/bin/security
|
||||
EXISTING_KEYCHAINS="$(security list-keychains -d user | tr -d '"')"
|
||||
security list-keychains -d user -s "$KEYCHAIN_PATH" $EXISTING_KEYCHAINS
|
||||
security default-keychain -d user -s "$KEYCHAIN_PATH"
|
||||
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Resolve signing identity
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SIGN_IDENTITY="$(security find-identity -p codesigning -v "$KEYCHAIN_PATH" 2>/dev/null | awk -F'\"' '/Developer ID Application/ { print $2; exit }')"
|
||||
if [[ -z "${SIGN_IDENTITY}" ]]; then
|
||||
echo "Developer ID Application identity not found in imported keychain." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "SIGN_IDENTITY=$SIGN_IDENTITY" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Write notary and Sparkle key files
|
||||
env:
|
||||
APP_STORE_CONNECT_API_KEY_P8: ${{ secrets.APP_STORE_CONNECT_API_KEY_P8 }}
|
||||
APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }}
|
||||
APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }}
|
||||
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NOTARYTOOL_KEY_PATH="$RUNNER_TEMP/openclaw-notary.p8"
|
||||
SPARKLE_PRIVATE_KEY_PATH="$RUNNER_TEMP/openclaw-sparkle-ed25519.pem"
|
||||
export NOTARYTOOL_KEY_PATH SPARKLE_PRIVATE_KEY_PATH
|
||||
python3 - <<'PY'
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
def write_secret(path_env: str, value_env: str) -> None:
|
||||
value = os.environ[value_env].replace("\\n", "\n")
|
||||
Path(os.environ[path_env]).write_text(value, encoding="utf-8")
|
||||
|
||||
write_secret("NOTARYTOOL_KEY_PATH", "APP_STORE_CONNECT_API_KEY_P8")
|
||||
write_secret("SPARKLE_PRIVATE_KEY_PATH", "SPARKLE_PRIVATE_KEY")
|
||||
PY
|
||||
echo "NOTARYTOOL_KEY=$NOTARYTOOL_KEY_PATH" >> "$GITHUB_ENV"
|
||||
echo "NOTARYTOOL_KEY_ID=$APP_STORE_CONNECT_KEY_ID" >> "$GITHUB_ENV"
|
||||
echo "NOTARYTOOL_ISSUER=$APP_STORE_CONNECT_ISSUER_ID" >> "$GITHUB_ENV"
|
||||
echo "SPARKLE_PRIVATE_KEY_FILE=$SPARKLE_PRIVATE_KEY_PATH" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build, sign, notarize, and package macOS release
|
||||
env:
|
||||
APP_VERSION: ${{ steps.package_version.outputs.value }}
|
||||
BUNDLE_ID: ai.openclaw.mac
|
||||
BUILD_CONFIG: release
|
||||
SIGN_IDENTITY: ${{ env.SIGN_IDENTITY }}
|
||||
SKIP_PNPM_INSTALL: "1"
|
||||
SPARKLE_FEED_URL: ${{ env.SPARKLE_FEED_URL }}
|
||||
run: scripts/package-mac-dist.sh
|
||||
|
||||
- name: Checkout main branch for appcast seed
|
||||
if: ${{ steps.release_channel.outputs.is_beta != 'true' }}
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
path: openclaw-main
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Seed appcast from main
|
||||
if: ${{ steps.release_channel.outputs.is_beta != 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APPCAST_SOURCE="openclaw-main/appcast.xml"
|
||||
if [[ -f "$APPCAST_SOURCE" ]]; then
|
||||
cp "$APPCAST_SOURCE" appcast.xml
|
||||
else
|
||||
echo "No existing appcast at $APPCAST_SOURCE; generating a fresh feed."
|
||||
fi
|
||||
|
||||
- name: Generate signed appcast artifact
|
||||
if: ${{ steps.release_channel.outputs.is_beta != 'true' }}
|
||||
env:
|
||||
SPARKLE_DOWNLOAD_URL_PREFIX: https://github.com/openclaw/openclaw/releases/download/${{ inputs.tag }}/
|
||||
SPARKLE_RELEASE_VERSION: ${{ steps.package_version.outputs.value }}
|
||||
run: scripts/make_appcast.sh "dist/OpenClaw-${{ steps.package_version.outputs.value }}.zip" "${{ env.SPARKLE_FEED_URL }}"
|
||||
|
||||
- name: Upload stable appcast artifact
|
||||
if: ${{ steps.release_channel.outputs.is_beta != 'true' }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: macos-appcast-${{ inputs.tag }}
|
||||
path: appcast.xml
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Skip shared appcast for beta releases
|
||||
if: ${{ steps.release_channel.outputs.is_beta == 'true' }}
|
||||
run: echo "Beta release detected; skip shared production appcast artifact generation."
|
||||
|
||||
- name: Upload macOS assets to GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
VERSION: ${{ steps.package_version.outputs.value }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh release upload "$RELEASE_TAG" \
|
||||
"dist/OpenClaw-$VERSION.zip" \
|
||||
"dist/OpenClaw-$VERSION.dmg" \
|
||||
"dist/OpenClaw-$VERSION.dSYM.zip" \
|
||||
--clobber \
|
||||
--repo "$GITHUB_REPOSITORY"
|
||||
|
||||
- name: Clean up signing keychain
|
||||
if: always()
|
||||
run: |
|
||||
if [[ -n "${KEYCHAIN_PATH:-}" ]]; then
|
||||
security delete-keychain "$KEYCHAIN_PATH" >/dev/null 2>&1 || true
|
||||
fi
|
||||
@@ -1,15 +1,17 @@
|
||||
name: OpenClaw NPM Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Release tag to publish (for example v2026.3.22, v2026.3.22-beta.1, or fallback v2026.3.22-1)
|
||||
required: true
|
||||
type: string
|
||||
preflight_only:
|
||||
description: Run validation/build only and skip the gated publish job
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: openclaw-npm-release-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
|
||||
@@ -21,117 +23,10 @@ env:
|
||||
PNPM_VERSION: "10.23.0"
|
||||
|
||||
jobs:
|
||||
preview_openclaw_npm:
|
||||
if: github.event_name == 'push'
|
||||
preflight_openclaw_npm:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Print release plan
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RELEASE_SHA=$(git rev-parse HEAD)
|
||||
PACKAGE_VERSION=$(node -p "require('./package.json').version")
|
||||
if [[ "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*-[1-9][0-9]*$ ]]; then
|
||||
TAG_KIND="fallback correction"
|
||||
else
|
||||
TAG_KIND="standard"
|
||||
fi
|
||||
echo "Release plan for ${RELEASE_TAG}:"
|
||||
echo "Resolved release SHA: ${RELEASE_SHA}"
|
||||
echo "Resolved package version: ${PACKAGE_VERSION}"
|
||||
echo "Resolved tag kind: ${TAG_KIND}"
|
||||
if [[ "${TAG_KIND}" == "fallback correction" ]]; then
|
||||
echo "Correction tag note: npm version remains ${PACKAGE_VERSION}"
|
||||
fi
|
||||
echo "Would run: git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main"
|
||||
echo "Would run with env: RELEASE_SHA=${RELEASE_SHA} RELEASE_TAG=${RELEASE_TAG} RELEASE_MAIN_REF=origin/main pnpm release:openclaw:npm:check"
|
||||
echo "Would run: npm view openclaw@${PACKAGE_VERSION} version"
|
||||
echo "Would run: pnpm check"
|
||||
echo "Would run: pnpm build"
|
||||
echo "Would run: pnpm release:check"
|
||||
|
||||
- name: Validate release tag and package metadata
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
RELEASE_MAIN_REF: origin/main
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
RELEASE_SHA=$(git rev-parse HEAD)
|
||||
export RELEASE_SHA RELEASE_TAG RELEASE_MAIN_REF
|
||||
# Fetch the full main ref so merge-base ancestry checks keep working
|
||||
# for older tagged commits that are still contained in main.
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
pnpm release:openclaw:npm:check
|
||||
|
||||
- name: Ensure version is not already published
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
PACKAGE_VERSION=$(node -p "require('./package.json').version")
|
||||
IS_CORRECTION_TAG=0
|
||||
if [[ "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*-[1-9][0-9]*$ ]]; then
|
||||
IS_CORRECTION_TAG=1
|
||||
fi
|
||||
|
||||
if npm view "openclaw@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
|
||||
if [[ "${IS_CORRECTION_TAG}" == "1" ]]; then
|
||||
echo "openclaw@${PACKAGE_VERSION} is already published on npm."
|
||||
echo "Correction tag ${RELEASE_TAG} is allowed as a fallback release tag, so preview will continue without treating this as an error."
|
||||
exit 0
|
||||
fi
|
||||
echo "openclaw@${PACKAGE_VERSION} is already published on npm."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${IS_CORRECTION_TAG}" == "1" ]]; then
|
||||
echo "Previewing fallback correction tag ${RELEASE_TAG} for npm version openclaw@${PACKAGE_VERSION}"
|
||||
else
|
||||
echo "Previewing openclaw@${PACKAGE_VERSION}"
|
||||
fi
|
||||
|
||||
- name: Check
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
pnpm check
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
pnpm build
|
||||
|
||||
- name: Verify release contents
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
pnpm release:check
|
||||
|
||||
- name: Preview publish command
|
||||
run: bash scripts/openclaw-npm-publish.sh --dry-run
|
||||
|
||||
publish_openclaw_npm:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
# npm trusted publishing + provenance requires a GitHub-hosted runner.
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm-release
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Validate tag input format
|
||||
env:
|
||||
@@ -191,5 +86,64 @@ jobs:
|
||||
- name: Verify release contents
|
||||
run: pnpm release:check
|
||||
|
||||
publish_openclaw_npm:
|
||||
# npm trusted publishing + provenance requires a GitHub-hosted runner.
|
||||
needs: [preflight_openclaw_npm]
|
||||
if: ${{ !inputs.preflight_only }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm-release
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Validate tag input format
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then
|
||||
echo "Invalid release tag format: ${RELEASE_TAG}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/${{ inputs.tag }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Validate release tag and package metadata
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
RELEASE_MAIN_REF: origin/main
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RELEASE_SHA=$(git rev-parse HEAD)
|
||||
export RELEASE_SHA RELEASE_TAG RELEASE_MAIN_REF
|
||||
# Fetch the full main ref so merge-base ancestry checks keep working
|
||||
# for older tagged commits that are still contained in main.
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
pnpm release:openclaw:npm:check
|
||||
|
||||
- name: Ensure version is not already published
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE_VERSION=$(node -p "require('./package.json').version")
|
||||
|
||||
if npm view "openclaw@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
|
||||
echo "openclaw@${PACKAGE_VERSION} is already published on npm."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Publishing openclaw@${PACKAGE_VERSION}"
|
||||
|
||||
- name: Publish
|
||||
run: bash scripts/openclaw-npm-publish.sh --publish
|
||||
|
||||
@@ -6,6 +6,35 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Breaking
|
||||
|
||||
### Changes
|
||||
|
||||
### Fixes
|
||||
|
||||
- Plugins/message tool: make Discord `components` and Slack `blocks` optional again, and route Feishu `message(..., media=...)` sends through the outbound media path, so pin/unpin/react flows stop failing schema validation and Feishu file/image attachments actually send. Fixes #52970 and #52962. Thanks @vincentkoc.
|
||||
- Gateway/model pricing: stop `openrouter/auto` pricing refresh from recursing indefinitely during bootstrap, so OpenRouter auto routes can populate cached pricing and `usage.cost` again. Fixes #53035. Thanks @vincentkoc.
|
||||
- Browser/Chrome MCP: wait for existing-session browser tabs to become usable after attach instead of treating the initial Chrome MCP handshake as ready, which reduces user-profile timeouts and repeated consent churn on macOS Chrome attach flows. Fixes #52930. Thanks @vincentkoc.
|
||||
- Browser/CDP: reuse an already-running loopback browser after a short initial reachability miss instead of immediately falling back to relaunch detection, which fixes second-run browser start/open regressions on slower headless Linux setups. Fixes #53004. Thanks @vincentkoc.
|
||||
- ClawHub/skills: resolve the local ClawHub auth token for gateway skill browsing and switch browse-all requests to search so ClawControl stops falling into unauthenticated 429s and empty authenticated skill lists. Fixes #52949. Thanks @vincentkoc.
|
||||
- ClawHub/macOS auth: honor macOS auth config and XDG auth paths for saved ClawHub credentials, so `openclaw skills ...` and gateway skill browsing keep using the signed-in auth state instead of silently falling back to unauthenticated mode. Fixes #53034.
|
||||
- Agents/web_search: use the active runtime `web_search` provider instead of stale/default selection, so agent turns keep hitting the provider you actually configured. Fixes #53020.
|
||||
- Channels/catalog: let external channel catalogs override shipped fallback metadata and honor overridden npm specs during channel setup, so custom channel catalogs no longer fall back to bundled packages when a channel id matches. (#52988)
|
||||
- Gateway/auth: require auth for canvas routes and admin scope for agent session reset, so anonymous canvas access and non-admin reset requests fail closed.
|
||||
- Gateway/probe: stop successful gateway handshakes from timing out as unreachable while post-connect detail RPCs are still loading, so slow devices report a reachable RPC failure instead of a false negative dead gateway. Fixes #52927. Thanks @vincentkoc.
|
||||
- Gateway/supervision: stop lock conflicts from crash-looping under launchd and systemd by keeping the duplicate process in a retry wait instead of exiting as a failure while another healthy gateway still owns the lock. Fixes #52922. Thanks @vincentkoc.
|
||||
- Config/plugins: treat stale unknown `plugins.allow` ids as warnings instead of fatal config errors, so recovery commands like `plugins install`, `doctor --fix`, and `status` still run when a plugin is missing locally. Fixes #52992. Thanks @vincentkoc.
|
||||
- Doctor/WhatsApp: stop auto-enable from appending built-in channel ids like `whatsapp` to `plugins.allow`, so `openclaw doctor --fix` no longer writes schema-invalid plugin allowlist entries when repairing built-in channels. Fixes #52931. Thanks @vincentkoc.
|
||||
- Agents/Anthropic: preserve latest assistant thinking and redacted-thinking block ordering during transcript image sanitization so follow-up turns do not trip Anthropic's unmodified-thinking validation. (#52961) Thanks @vincentkoc.
|
||||
- Voice-call/Plivo: stabilize Plivo v2 replay keys so webhook retries and replay protection stop colliding on valid follow-up deliveries.
|
||||
- Release/install: keep previously released bundled plugins and Control UI assets in published openclaw npm installs, and fail release checks when those shipped artifacts are missing. Thanks @vincentkoc.
|
||||
- Mistral/models: lower bundled Mistral max-token defaults to safe output budgets and teach `openclaw doctor --fix` to repair old persisted Mistral provider configs that still carry context-sized output limits, avoiding deterministic Mistral 422 rejects on fresh and existing setups. Fixes #52599. Thanks @vincentkoc.
|
||||
- Telegram/auto-reply: preserve same-chat inbound debounce order without stranding stale busy-session followups, and keep same-key overflow turns ordered when tracked debounce keys are saturated. (#52998) Thanks @osolmaz.
|
||||
- ClawHub/macOS: read the local ClawHub login from the macOS Application Support path and still honor XDG config on macOS, so skill browsing uses the logged-in token on both default and XDG-style setups. Fixes #52949. Thanks @scoootscooob.
|
||||
- Discord/commands: return an explicit unauthorized reply for privileged native slash commands instead of falling through to Discord's misleading generic completion when auth gates reject the sender. Fixes #53041. Thanks @scoootscooob.
|
||||
|
||||
## 2026.3.22
|
||||
|
||||
### Breaking
|
||||
|
||||
- Plugins/install: bare `openclaw plugins install <package>` now prefers ClawHub before npm for npm-safe names, and only falls back to npm when ClawHub does not have that package or version. Docs: https://docs.openclaw.ai/tools/clawhub
|
||||
- Browser/Chrome MCP: remove the legacy Chrome extension relay path, bundled extension assets, `driver: "extension"`, and `browser.relayBindHost`. Run `openclaw doctor --fix` to migrate host-local browser config to `existing-session` / `user`; Docker, headless, sandbox, and remote browser flows still use raw CDP. Docs: https://docs.openclaw.ai/gateway/doctor and https://docs.openclaw.ai/tools/browser (#47893) Thanks @vincentkoc.
|
||||
- Tools/image generation: standardize the stock image create/edit path on the core `image_generate` tool. The old `nano-banana-pro` docs/examples are gone; if you previously copied that sample-skill config, switch to `agents.defaults.imageGenerationModel` for built-in image generation or install a separate third-party skill explicitly.
|
||||
@@ -24,6 +53,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/media replies: migrate the remaining browser, canvas, and nodes snapshot outputs onto `details.media` so generated media keeps attaching to assistant replies after the collect-then-attach refactor. (#51731) Thanks @christianklotz.
|
||||
- Android/contacts search: escape literal `%` and `_` in contact-name queries so searches like `100%` or `_id` no longer match unrelated contacts through SQL `LIKE` wildcards. (#41891) Thanks @Kaneki-x.
|
||||
- Gateway/usage: include reset and deleted archived session transcripts in usage totals, session discovery, and archived-only session detail fallback so the Usage view no longer undercounts rotated sessions. (#43215) Thanks @rcrick.
|
||||
- Channels/inbound debounce: reserve same-conversation timer-backed flush order so a buffered run cannot be overtaken by a newer message for the same key, preventing Telegram reply desync after long debounced turns. Fixes #52982. Thanks @vincentkoc and @osolmaz.
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -101,11 +131,16 @@ Docs: https://docs.openclaw.ai
|
||||
- Docs/plugins: add the community DingTalk plugin listing to the docs catalog. (#29913) Thanks @sliverp.
|
||||
- Docs/plugins: add the community QQbot plugin listing to the docs catalog. (#29898) Thanks @sliverp.
|
||||
- Docs/plugins: add the community wecom plugin listing to the docs catalog. (#29905) Thanks @sliverp.
|
||||
- Telegram/message tool: add `asDocument` as a user-facing alias for `forceDocument` on image and GIF sends, while preserving explicit `forceDocument` precedence when both flags are present. (#52461) Thanks @bakhtiersizhaev.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Models/OpenAI Codex OAuth and Plugins/MiniMax OAuth: ensure env-configured HTTP/HTTPS proxy dispatchers are initialized before OAuth preflight and token exchange requests so proxy-required environments can complete MiniMax and OpenAI Codex sign-in flows again. (#52228; fixes #51619, #51569) Thanks @openperf.
|
||||
- Plugins/DeepSeek: refactor the bundled DeepSeek provider onto the shared single-provider plugin entry, move its coverage into the extension test lane, and keep bundled auth env-var metadata on the generated manifest path. (#48762) Thanks @07akioni.
|
||||
- Web tools/search provider lists: keep onboarding, configure, and docs provider lists alphabetical while preserving the separate runtime auto-detect precedence used for credential-based provider selection.
|
||||
- Media/Windows security: block remote-host `file://` media URLs and UNC/network paths before local filesystem resolution in core media loading and adjacent prompt/sandbox attachment seams, so the next release no longer allows structured local-media inputs to trigger outbound SMB credential handshakes on Windows. Thanks @RacerZ-fighting for reporting.
|
||||
- Release/npm packaging: keep previously released bundled plugins and Control UI assets in published `openclaw` npm installs, and fail release checks when those shipped artifacts are missing. Thanks @vincentkoc.
|
||||
- Plugins/Matrix: avoid duplicate `resolveMatrixAccountStringValues` runtime-api exports under Jiti so bundled Matrix installs no longer crash at startup with `Cannot redefine property: resolveMatrixAccountStringValues`. Fixes #52909 and #52891. Thanks @vincentkoc.
|
||||
- Gateway/discovery: fail closed on unresolved Bonjour and DNS-SD service endpoints in CLI discovery, onboarding, and `gateway status` so TXT-only hints can no longer steer routing or SSH auto-target selection. Thanks @nexrin for reporting.
|
||||
- Security/pairing: bind iOS setup codes to the intended node profile and reject first-use bootstrap redemption that asks for broader roles or scopes. Thanks @tdjackey.
|
||||
- Memory/core tools: register `memory_search` and `memory_get` independently so one unavailable memory tool no longer suppresses the other in new sessions. (#50198) Thanks @artwalker.
|
||||
@@ -321,6 +356,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Discord/pickers: keep `/codex_resume --browse-projects` picker callbacks alive in Discord by sharing component callback state across duplicate module graphs, preserving callback fallbacks, and acknowledging matched plugin interactions before dispatch. (#51260) Thanks @huntharo.
|
||||
- Memory/core tools: register `memory_search` and `memory_get` independently so one unavailable memory tool no longer suppresses the other in new sessions. (#50198) Thanks @artwalker.
|
||||
- Telegram/Mattermost message tool: keep plugin button schemas optional in isolated and cron sessions so plain sends do not fail validation when no current channel is active. (#52589) Thanks @tylerliu612.
|
||||
- Release/npm publish: fail the npm release check when `dist/control-ui/index.html` is missing from the packed tarball, so broken Control UI asset releases are blocked before publish. Fixes #52808. (#52852) Thanks @kevinheinrichs.
|
||||
|
||||
## 2026.3.13
|
||||
|
||||
|
||||
@@ -65,8 +65,8 @@ android {
|
||||
applicationId = "ai.openclaw.app"
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 2026032200
|
||||
versionName = "2026.3.22"
|
||||
versionCode = 2026032300
|
||||
versionName = "2026.3.23"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Shared iOS version defaults.
|
||||
// Generated overrides live in build/Version.xcconfig (git-ignored).
|
||||
|
||||
OPENCLAW_GATEWAY_VERSION = 2026.3.22
|
||||
OPENCLAW_MARKETING_VERSION = 2026.3.22
|
||||
OPENCLAW_BUILD_VERSION = 202603220
|
||||
OPENCLAW_GATEWAY_VERSION = 2026.3.23-beta.1
|
||||
OPENCLAW_MARKETING_VERSION = 2026.3.23
|
||||
OPENCLAW_BUILD_VERSION = 202603230
|
||||
|
||||
#include? "../build/Version.xcconfig"
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.3.22</string>
|
||||
<string>2026.3.23</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>202603220</string>
|
||||
<string>202603230</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>OpenClaw</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -47450,6 +47450,127 @@
|
||||
"help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.deepseek",
|
||||
"kind": "plugin",
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "@openclaw/deepseek-provider",
|
||||
"help": "OpenClaw DeepSeek provider plugin (plugin: deepseek)",
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.deepseek.config",
|
||||
"kind": "plugin",
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "@openclaw/deepseek-provider Config",
|
||||
"help": "Plugin-defined config payload for deepseek.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.deepseek.enabled",
|
||||
"kind": "plugin",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Enable @openclaw/deepseek-provider",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.deepseek.hooks",
|
||||
"kind": "plugin",
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Plugin Hook Policy",
|
||||
"help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.",
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.deepseek.hooks.allowPromptInjection",
|
||||
"kind": "plugin",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"access"
|
||||
],
|
||||
"label": "Allow Prompt Injection Hooks",
|
||||
"help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.deepseek.subagent",
|
||||
"kind": "plugin",
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Plugin Subagent Policy",
|
||||
"help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.",
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.deepseek.subagent.allowedModels",
|
||||
"kind": "plugin",
|
||||
"type": "array",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"access"
|
||||
],
|
||||
"label": "Plugin Subagent Allowed Models",
|
||||
"help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.",
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.deepseek.subagent.allowedModels.*",
|
||||
"kind": "plugin",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.deepseek.subagent.allowModelOverride",
|
||||
"kind": "plugin",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"access"
|
||||
],
|
||||
"label": "Allow Plugin Subagent Model Override",
|
||||
"help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.device-pair",
|
||||
"kind": "plugin",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5619}
|
||||
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5628}
|
||||
{"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true}
|
||||
{"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true}
|
||||
{"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -4188,6 +4188,15 @@
|
||||
{"recordType":"path","path":"plugins.entries.deepgram.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.deepgram.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.deepgram.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.deepseek","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/deepseek-provider","help":"OpenClaw DeepSeek provider plugin (plugin: deepseek)","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.deepseek.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/deepseek-provider Config","help":"Plugin-defined config payload for deepseek.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.deepseek.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/deepseek-provider","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.deepseek.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.deepseek.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.deepseek.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.deepseek.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.deepseek.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.deepseek.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.device-pair","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Device Pairing","help":"Generate setup codes and approve device pairing requests. (plugin: device-pair)","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.device-pair.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Device Pairing Config","help":"Plugin-defined config payload for device-pair.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.device-pair.config.publicUrl","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Gateway URL","help":"Public WebSocket URL used for /pair setup codes (ws/wss or http/https).","hasChildren":false}
|
||||
|
||||
@@ -86,7 +86,7 @@ Payload:
|
||||
- `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging channel. Defaults to `true`. Responses that are only heartbeat acknowledgments are automatically skipped.
|
||||
- `channel` optional (string): The messaging channel for delivery. Core channels: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `irc`, `googlechat`, `line`. Extension channels (plugins): `msteams`, `mattermost`, and others. Defaults to `last`.
|
||||
- `to` optional (string): The recipient identifier for the channel (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack/Mattermost (plugin), conversation ID for Microsoft Teams). Defaults to the last recipient in the main session.
|
||||
- `model` optional (string): Model override (e.g., `anthropic/claude-3-5-sonnet` or an alias). Must be in the allowed model list if restricted.
|
||||
- `model` optional (string): Model override (e.g., `anthropic/claude-sonnet-4-6` or an alias). Must be in the allowed model list if restricted.
|
||||
- `thinking` optional (string): Thinking level override (e.g., `low`, `medium`, `high`).
|
||||
- `timeoutSeconds` optional (number): Maximum duration for the agent run in seconds.
|
||||
|
||||
|
||||
@@ -26,11 +26,12 @@ Text is supported everywhere; media and reactions vary by channel.
|
||||
- [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately).
|
||||
- [Nostr](/channels/nostr) — Decentralized DMs via NIP-04 (plugin, installed separately).
|
||||
- [Signal](/channels/signal) — signal-cli; privacy-focused.
|
||||
- [Synology Chat](/channels/synology-chat) — Synology NAS Chat via outgoing+incoming webhooks (plugin, installed separately).
|
||||
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
|
||||
- [Synology Chat](/channels/synology-chat) — Synology NAS Chat via outgoing+incoming webhooks (plugin, installed separately).
|
||||
- [Telegram](/channels/telegram) — Bot API via grammY; supports groups.
|
||||
- [Tlon](/channels/tlon) — Urbit-based messenger (plugin, installed separately).
|
||||
- [Twitch](/channels/twitch) — Twitch chat via IRC connection (plugin, installed separately).
|
||||
- [Voice Call](/plugins/voice-call) — Telephony via Plivo or Twilio (plugin, installed separately).
|
||||
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
|
||||
- [WhatsApp](/channels/whatsapp) — Most popular; uses Baileys and requires QR pairing.
|
||||
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
|
||||
|
||||
+9
-2
@@ -113,7 +113,9 @@ openclaw [--dev] [--profile <name>] <command>
|
||||
audit
|
||||
secrets
|
||||
reload
|
||||
migrate
|
||||
audit
|
||||
configure
|
||||
apply
|
||||
reset
|
||||
uninstall
|
||||
update
|
||||
@@ -132,16 +134,21 @@ openclaw [--dev] [--profile <name>] <command>
|
||||
check
|
||||
plugins
|
||||
list
|
||||
info
|
||||
inspect
|
||||
install
|
||||
uninstall
|
||||
update
|
||||
enable
|
||||
disable
|
||||
doctor
|
||||
marketplace list
|
||||
memory
|
||||
status
|
||||
index
|
||||
search
|
||||
message
|
||||
send
|
||||
broadcast
|
||||
agent
|
||||
agents
|
||||
list
|
||||
|
||||
+8
-7
@@ -950,6 +950,7 @@
|
||||
"pages": [
|
||||
"install/updating",
|
||||
"install/migrating",
|
||||
"install/migrating-matrix",
|
||||
"install/uninstall",
|
||||
"install/development-channels"
|
||||
]
|
||||
@@ -979,8 +980,8 @@
|
||||
"channels/nextcloud-talk",
|
||||
"channels/nostr",
|
||||
"channels/signal",
|
||||
"channels/synology-chat",
|
||||
"channels/slack",
|
||||
"channels/synology-chat",
|
||||
"channels/telegram",
|
||||
"channels/tlon",
|
||||
"channels/twitch",
|
||||
@@ -1180,25 +1181,26 @@
|
||||
"pages": [
|
||||
"providers/anthropic",
|
||||
"providers/bedrock",
|
||||
"providers/cloudflare-ai-gateway",
|
||||
"providers/claude-max-api-proxy",
|
||||
"providers/cloudflare-ai-gateway",
|
||||
"providers/deepgram",
|
||||
"providers/deepseek",
|
||||
"providers/github-copilot",
|
||||
"providers/glm",
|
||||
"providers/google",
|
||||
"providers/groq",
|
||||
"providers/huggingface",
|
||||
"providers/kilocode",
|
||||
"providers/litellm",
|
||||
"providers/glm",
|
||||
"providers/minimax",
|
||||
"providers/mistral",
|
||||
"providers/modelstudio",
|
||||
"providers/moonshot",
|
||||
"providers/mistral",
|
||||
"providers/nvidia",
|
||||
"providers/ollama",
|
||||
"providers/openai",
|
||||
"providers/opencode-go",
|
||||
"providers/opencode",
|
||||
"providers/opencode-go",
|
||||
"providers/openrouter",
|
||||
"providers/perplexity-provider",
|
||||
"providers/qianfan",
|
||||
@@ -1206,8 +1208,8 @@
|
||||
"providers/sglang",
|
||||
"providers/synthetic",
|
||||
"providers/together",
|
||||
"providers/vercel-ai-gateway",
|
||||
"providers/venice",
|
||||
"providers/vercel-ai-gateway",
|
||||
"providers/vllm",
|
||||
"providers/volcengine",
|
||||
"providers/xai",
|
||||
@@ -1744,7 +1746,6 @@
|
||||
{
|
||||
"group": "扩展",
|
||||
"pages": [
|
||||
"zh-CN/plugins/architecture",
|
||||
"zh-CN/plugins/voice-call",
|
||||
"zh-CN/plugins/zalouser",
|
||||
"zh-CN/plugins/manifest",
|
||||
|
||||
@@ -27,9 +27,6 @@ Always import from a specific subpath:
|
||||
```typescript
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
|
||||
|
||||
// Deprecated — will be removed in the next major release
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk";
|
||||
```
|
||||
|
||||
Each subpath is a small, self-contained module. This keeps startup fast and
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
summary: "DeepSeek setup (auth + model selection)"
|
||||
read_when:
|
||||
- You want to use DeepSeek with OpenClaw
|
||||
- You need the API key env var or CLI auth choice
|
||||
---
|
||||
|
||||
# DeepSeek
|
||||
|
||||
[DeepSeek](https://www.deepseek.com) provides powerful AI models with an OpenAI-compatible API.
|
||||
|
||||
- Provider: `deepseek`
|
||||
- Auth: `DEEPSEEK_API_KEY`
|
||||
- API: OpenAI-compatible
|
||||
|
||||
## Quick start
|
||||
|
||||
Set the API key (recommended: store it for the Gateway):
|
||||
|
||||
```bash
|
||||
openclaw onboard --auth-choice deepseek-api-key
|
||||
```
|
||||
|
||||
This will prompt for your API key and set `deepseek/deepseek-chat` as the default model.
|
||||
|
||||
## Non-interactive example
|
||||
|
||||
```bash
|
||||
openclaw onboard --non-interactive \
|
||||
--mode local \
|
||||
--auth-choice deepseek-api-key \
|
||||
--deepseek-api-key "$DEEPSEEK_API_KEY" \
|
||||
--skip-health \
|
||||
--accept-risk
|
||||
```
|
||||
|
||||
## Environment note
|
||||
|
||||
If the Gateway runs as a daemon (launchd/systemd), make sure `DEEPSEEK_API_KEY`
|
||||
is available to that process (for example, in `~/.openclaw/.env` or via
|
||||
`env.shellEnv`).
|
||||
|
||||
## Available models
|
||||
|
||||
| Model ID | Name | Type | Context |
|
||||
| ------------------- | ------------------------ | --------- | ------- |
|
||||
| `deepseek-chat` | DeepSeek Chat (V3.2) | General | 128K |
|
||||
| `deepseek-reasoner` | DeepSeek Reasoner (V3.2) | Reasoning | 128K |
|
||||
|
||||
- **deepseek-chat** corresponds to DeepSeek-V3.2 in non-thinking mode.
|
||||
- **deepseek-reasoner** corresponds to DeepSeek-V3.2 in thinking mode with chain-of-thought reasoning.
|
||||
|
||||
Get your API key at [platform.deepseek.com](https://platform.deepseek.com/api_keys).
|
||||
@@ -29,6 +29,8 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
|
||||
- [Amazon Bedrock](/providers/bedrock)
|
||||
- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
|
||||
- [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway)
|
||||
- [DeepSeek](/providers/deepseek)
|
||||
- [GitHub Copilot](/providers/github-copilot)
|
||||
- [GLM models](/providers/glm)
|
||||
- [Google (Gemini)](/providers/google)
|
||||
- [Groq (LPU inference)](/providers/groq)
|
||||
@@ -42,15 +44,17 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
|
||||
- [NVIDIA](/providers/nvidia)
|
||||
- [Ollama (cloud + local models)](/providers/ollama)
|
||||
- [OpenAI (API + Codex)](/providers/openai)
|
||||
- [OpenCode (Zen + Go)](/providers/opencode)
|
||||
- [OpenCode](/providers/opencode)
|
||||
- [OpenCode Go](/providers/opencode-go)
|
||||
- [OpenRouter](/providers/openrouter)
|
||||
- [Perplexity (web search)](/providers/perplexity-provider)
|
||||
- [Qianfan](/providers/qianfan)
|
||||
- [Qwen (OAuth)](/providers/qwen)
|
||||
- [SGLang (local models)](/providers/sglang)
|
||||
- [Synthetic](/providers/synthetic)
|
||||
- [Together AI](/providers/together)
|
||||
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
|
||||
- [Venice (Venice AI, privacy-focused)](/providers/venice)
|
||||
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
|
||||
- [vLLM (local models)](/providers/vllm)
|
||||
- [Volcengine (Doubao)](/providers/volcengine)
|
||||
- [xAI](/providers/xai)
|
||||
|
||||
@@ -23,7 +23,7 @@ OpenClaw has three public release lanes:
|
||||
- Do not zero-pad month or day
|
||||
- `latest` means the current stable npm release
|
||||
- `beta` means the current prerelease npm release
|
||||
- Beta releases may ship before the macOS app catches up
|
||||
- Every OpenClaw release ships the npm package and macOS app together
|
||||
|
||||
## Release cadence
|
||||
|
||||
@@ -32,6 +32,15 @@ OpenClaw has three public release lanes:
|
||||
- Detailed release procedure, approvals, credentials, and recovery notes are
|
||||
maintainer-only
|
||||
|
||||
## Release preflight
|
||||
|
||||
- Run `pnpm release:check` before every tagged release
|
||||
- Run `RELEASE_TAG=vYYYY.M.D node --import tsx scripts/openclaw-npm-release-check.ts`
|
||||
(or the matching beta/correction tag) before approval
|
||||
- npm release preflight fails closed unless the tarball includes both
|
||||
`dist/control-ui/index.html` and a non-empty `dist/control-ui/assets/` payload
|
||||
so we do not ship an empty browser dashboard again
|
||||
|
||||
## Public references
|
||||
|
||||
- [`.github/workflows/openclaw-npm-release.yml`](https://github.com/openclaw/openclaw/blob/main/.github/workflows/openclaw-npm-release.yml)
|
||||
|
||||
+12
-12
@@ -109,18 +109,18 @@ Per-agent override: `agents.list[].tools.profile`.
|
||||
|
||||
Use `group:*` shorthands in allow/deny lists:
|
||||
|
||||
| Group | Tools |
|
||||
| ------------------ | ------------------------------------------------------------------------------ |
|
||||
| `group:runtime` | exec, bash, process |
|
||||
| `group:fs` | read, write, edit, apply_patch |
|
||||
| `group:sessions` | sessions_list, sessions_history, sessions_send, sessions_spawn, session_status |
|
||||
| `group:memory` | memory_search, memory_get |
|
||||
| `group:web` | web_search, web_fetch |
|
||||
| `group:ui` | browser, canvas |
|
||||
| `group:automation` | cron, gateway |
|
||||
| `group:messaging` | message |
|
||||
| `group:nodes` | nodes |
|
||||
| `group:openclaw` | All built-in OpenClaw tools (excludes plugin tools) |
|
||||
| Group | Tools |
|
||||
| ------------------ | --------------------------------------------------------------------------------------------------------- |
|
||||
| `group:runtime` | exec, bash, process |
|
||||
| `group:fs` | read, write, edit, apply_patch |
|
||||
| `group:sessions` | sessions_list, sessions_history, sessions_send, sessions_spawn, sessions_yield, subagents, session_status |
|
||||
| `group:memory` | memory_search, memory_get |
|
||||
| `group:web` | web_search, web_fetch |
|
||||
| `group:ui` | browser, canvas |
|
||||
| `group:automation` | cron, gateway |
|
||||
| `group:messaging` | message |
|
||||
| `group:nodes` | nodes |
|
||||
| `group:openclaw` | All built-in OpenClaw tools (excludes plugin tools) |
|
||||
|
||||
### Provider-specific restrictions
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveProviderPluginChoice } from "../../src/plugins/provider-wizard.js";
|
||||
import { registerSingleProviderPlugin } from "../../test/helpers/extensions/plugin-registration.js";
|
||||
import deepseekPlugin from "./index.js";
|
||||
|
||||
describe("deepseek provider plugin", () => {
|
||||
it("registers DeepSeek with api-key auth wizard metadata", () => {
|
||||
const provider = registerSingleProviderPlugin(deepseekPlugin);
|
||||
const resolved = resolveProviderPluginChoice({
|
||||
providers: [provider],
|
||||
choice: "deepseek-api-key",
|
||||
});
|
||||
|
||||
expect(provider.id).toBe("deepseek");
|
||||
expect(provider.label).toBe("DeepSeek");
|
||||
expect(provider.envVars).toEqual(["DEEPSEEK_API_KEY"]);
|
||||
expect(provider.auth).toHaveLength(1);
|
||||
expect(resolved).not.toBeNull();
|
||||
expect(resolved?.provider.id).toBe("deepseek");
|
||||
expect(resolved?.method.id).toBe("api-key");
|
||||
});
|
||||
|
||||
it("builds the static DeepSeek model catalog", async () => {
|
||||
const provider = registerSingleProviderPlugin(deepseekPlugin);
|
||||
expect(provider.catalog).toBeDefined();
|
||||
|
||||
const catalog = await provider.catalog!.run({
|
||||
config: {},
|
||||
env: {},
|
||||
resolveProviderApiKey: () => ({ apiKey: "test-key" }),
|
||||
resolveProviderAuth: () => ({
|
||||
apiKey: "test-key",
|
||||
mode: "api_key",
|
||||
source: "env",
|
||||
}),
|
||||
} as never);
|
||||
|
||||
expect(catalog && "provider" in catalog).toBe(true);
|
||||
if (!catalog || !("provider" in catalog)) {
|
||||
throw new Error("expected single-provider catalog");
|
||||
}
|
||||
|
||||
expect(catalog.provider.api).toBe("openai-completions");
|
||||
expect(catalog.provider.baseUrl).toBe("https://api.deepseek.com");
|
||||
expect(catalog.provider.models?.map((model) => model.id)).toEqual([
|
||||
"deepseek-chat",
|
||||
"deepseek-reasoner",
|
||||
]);
|
||||
expect(
|
||||
catalog.provider.models?.find((model) => model.id === "deepseek-reasoner")?.reasoning,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
|
||||
import { applyDeepSeekConfig, DEEPSEEK_DEFAULT_MODEL_REF } from "./onboard.js";
|
||||
import { buildDeepSeekProvider } from "./provider-catalog.js";
|
||||
|
||||
const PROVIDER_ID = "deepseek";
|
||||
|
||||
export default defineSingleProviderPluginEntry({
|
||||
id: PROVIDER_ID,
|
||||
name: "DeepSeek Provider",
|
||||
description: "Bundled DeepSeek provider plugin",
|
||||
provider: {
|
||||
label: "DeepSeek",
|
||||
docsPath: "/providers/deepseek",
|
||||
auth: [
|
||||
{
|
||||
methodId: "api-key",
|
||||
label: "DeepSeek API key",
|
||||
hint: "API key",
|
||||
optionKey: "deepseekApiKey",
|
||||
flagName: "--deepseek-api-key",
|
||||
envVar: "DEEPSEEK_API_KEY",
|
||||
promptMessage: "Enter DeepSeek API key",
|
||||
defaultModel: DEEPSEEK_DEFAULT_MODEL_REF,
|
||||
applyConfig: (cfg) => applyDeepSeekConfig(cfg),
|
||||
wizard: {
|
||||
choiceId: "deepseek-api-key",
|
||||
choiceLabel: "DeepSeek API key",
|
||||
groupId: "deepseek",
|
||||
groupLabel: "DeepSeek",
|
||||
groupHint: "API key",
|
||||
},
|
||||
},
|
||||
],
|
||||
catalog: {
|
||||
buildProvider: buildDeepSeekProvider,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
buildDeepSeekModelDefinition,
|
||||
DEEPSEEK_BASE_URL,
|
||||
DEEPSEEK_MODEL_CATALOG,
|
||||
} from "openclaw/plugin-sdk/provider-models";
|
||||
import {
|
||||
applyAgentDefaultModelPrimary,
|
||||
applyProviderConfigWithModelCatalog,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/provider-onboard";
|
||||
|
||||
export const DEEPSEEK_DEFAULT_MODEL_REF = "deepseek/deepseek-chat";
|
||||
|
||||
export function applyDeepSeekProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
|
||||
const models = { ...cfg.agents?.defaults?.models };
|
||||
models[DEEPSEEK_DEFAULT_MODEL_REF] = {
|
||||
...models[DEEPSEEK_DEFAULT_MODEL_REF],
|
||||
alias: models[DEEPSEEK_DEFAULT_MODEL_REF]?.alias ?? "DeepSeek",
|
||||
};
|
||||
|
||||
return applyProviderConfigWithModelCatalog(cfg, {
|
||||
agentModels: models,
|
||||
providerId: "deepseek",
|
||||
api: "openai-completions",
|
||||
baseUrl: DEEPSEEK_BASE_URL,
|
||||
catalogModels: DEEPSEEK_MODEL_CATALOG.map(buildDeepSeekModelDefinition),
|
||||
});
|
||||
}
|
||||
|
||||
export function applyDeepSeekConfig(cfg: OpenClawConfig): OpenClawConfig {
|
||||
return applyAgentDefaultModelPrimary(
|
||||
applyDeepSeekProviderConfig(cfg),
|
||||
DEEPSEEK_DEFAULT_MODEL_REF,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"id": "deepseek",
|
||||
"providers": ["deepseek"],
|
||||
"providerAuthEnvVars": {
|
||||
"deepseek": ["DEEPSEEK_API_KEY"]
|
||||
},
|
||||
"providerAuthChoices": [
|
||||
{
|
||||
"provider": "deepseek",
|
||||
"method": "api-key",
|
||||
"choiceId": "deepseek-api-key",
|
||||
"choiceLabel": "DeepSeek API key",
|
||||
"groupId": "deepseek",
|
||||
"groupLabel": "DeepSeek",
|
||||
"groupHint": "API key",
|
||||
"optionKey": "deepseekApiKey",
|
||||
"cliFlag": "--deepseek-api-key",
|
||||
"cliOption": "--deepseek-api-key <key>",
|
||||
"cliDescription": "DeepSeek API key"
|
||||
}
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/deepseek-provider",
|
||||
"version": "2026.3.14",
|
||||
"private": true,
|
||||
"description": "OpenClaw DeepSeek provider plugin",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import {
|
||||
buildDeepSeekModelDefinition,
|
||||
DEEPSEEK_BASE_URL,
|
||||
DEEPSEEK_MODEL_CATALOG,
|
||||
type ModelProviderConfig,
|
||||
} from "openclaw/plugin-sdk/provider-models";
|
||||
|
||||
export function buildDeepSeekProvider(): ModelProviderConfig {
|
||||
return {
|
||||
baseUrl: DEEPSEEK_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: DEEPSEEK_MODEL_CATALOG.map(buildDeepSeekModelDefinition),
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { withFetchPreconnect } from "../../../test/helpers/extensions/fetch-mock.js";
|
||||
import { fetchDiscord } from "./api.js";
|
||||
import { jsonResponse } from "./test-http-helpers.js";
|
||||
|
||||
describe("fetchDiscord", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("formats rate limit payloads without raw JSON", async () => {
|
||||
const fetcher = withFetchPreconnect(async () =>
|
||||
jsonResponse(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
@@ -56,6 +57,24 @@ describe("discordMessageActions", () => {
|
||||
expect(discovery?.actions).not.toContain("role-add");
|
||||
});
|
||||
|
||||
it("keeps components optional in the message tool schema", () => {
|
||||
const discovery = discordMessageActions.describeMessageTool?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
discord: {
|
||||
token: "Bot token-main",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
});
|
||||
const schema = discovery?.schema;
|
||||
if (!schema || Array.isArray(schema)) {
|
||||
throw new Error("expected discord message-tool schema");
|
||||
}
|
||||
|
||||
expect(Type.Object(schema.properties).required).toBeUndefined();
|
||||
});
|
||||
|
||||
it("extracts send targets for message and thread reply actions", () => {
|
||||
expect(
|
||||
discordMessageActions.extractToolSend?.({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import {
|
||||
createUnionActionGate,
|
||||
listTokenSourcedAccounts,
|
||||
@@ -124,7 +125,7 @@ function describeDiscordMessageTool({
|
||||
capabilities: ["interactive", "components"],
|
||||
schema: {
|
||||
properties: {
|
||||
components: createDiscordMessageToolComponentsSchema(),
|
||||
components: Type.Optional(createDiscordMessageToolComponentsSchema()),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginRuntime } from "../../../src/plugins/runtime/types.js";
|
||||
import { createStartAccountContext } from "../../../test/helpers/extensions/start-account-context.js";
|
||||
import type { ResolvedDiscordAccount } from "./accounts.js";
|
||||
import { discordPlugin } from "./channel.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
import { setDiscordRuntime } from "./runtime.js";
|
||||
let discordPlugin: typeof import("./channel.js").discordPlugin;
|
||||
let setDiscordRuntime: typeof import("./runtime.js").setDiscordRuntime;
|
||||
|
||||
const probeDiscordMock = vi.hoisted(() => vi.fn());
|
||||
const monitorDiscordProviderMock = vi.hoisted(() => vi.fn());
|
||||
@@ -75,6 +75,13 @@ afterEach(() => {
|
||||
auditDiscordChannelPermissionsMock.mockReset();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useRealTimers();
|
||||
vi.resetModules();
|
||||
({ discordPlugin } = await import("./channel.js"));
|
||||
({ setDiscordRuntime } = await import("./runtime.js"));
|
||||
});
|
||||
|
||||
describe("discordPlugin outbound", () => {
|
||||
it("forwards mediaLocalRoots to sendMessageDiscord", async () => {
|
||||
const sendMessageDiscord = vi.fn(async () => ({ messageId: "m1" }));
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import { MessageFlags } from "discord-api-types/v10";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearDiscordComponentEntries,
|
||||
registerDiscordComponentEntries,
|
||||
resolveDiscordComponentEntry,
|
||||
resolveDiscordModalEntry,
|
||||
} from "./components-registry.js";
|
||||
import {
|
||||
buildDiscordComponentMessage,
|
||||
buildDiscordComponentMessageFlags,
|
||||
readDiscordComponentSpec,
|
||||
} from "./components.js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let clearDiscordComponentEntries: typeof import("./components-registry.js").clearDiscordComponentEntries;
|
||||
let registerDiscordComponentEntries: typeof import("./components-registry.js").registerDiscordComponentEntries;
|
||||
let resolveDiscordComponentEntry: typeof import("./components-registry.js").resolveDiscordComponentEntry;
|
||||
let resolveDiscordModalEntry: typeof import("./components-registry.js").resolveDiscordModalEntry;
|
||||
let buildDiscordComponentMessage: typeof import("./components.js").buildDiscordComponentMessage;
|
||||
let buildDiscordComponentMessageFlags: typeof import("./components.js").buildDiscordComponentMessageFlags;
|
||||
let readDiscordComponentSpec: typeof import("./components.js").readDiscordComponentSpec;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({
|
||||
clearDiscordComponentEntries,
|
||||
registerDiscordComponentEntries,
|
||||
resolveDiscordComponentEntry,
|
||||
resolveDiscordModalEntry,
|
||||
} = await import("./components-registry.js"));
|
||||
({ buildDiscordComponentMessage, buildDiscordComponentMessageFlags, readDiscordComponentSpec } =
|
||||
await import("./components.js"));
|
||||
});
|
||||
|
||||
describe("discord components", () => {
|
||||
it("builds v2 containers with modal trigger", () => {
|
||||
|
||||
@@ -26,6 +26,10 @@ import {
|
||||
} from "@buape/carbon";
|
||||
import { ButtonStyle, MessageFlags, TextInputStyle } from "discord-api-types/v10";
|
||||
|
||||
// Some test-only module graphs partially mock `@buape/carbon` and can drop `Modal`.
|
||||
// Keep dynamic form definitions loadable instead of crashing unrelated suites.
|
||||
const ModalBase: typeof Modal = (Modal ?? class {}) as typeof Modal;
|
||||
|
||||
export const DISCORD_COMPONENT_CUSTOM_ID_KEY = "occomp";
|
||||
export const DISCORD_MODAL_CUSTOM_ID_KEY = "ocmodal";
|
||||
export const DISCORD_COMPONENT_ATTACHMENT_PREFIX = "attachment://";
|
||||
@@ -1126,7 +1130,7 @@ export function buildDiscordComponentMessageFlags(
|
||||
return hasV2 ? MessageFlags.IsComponentsV2 : undefined;
|
||||
}
|
||||
|
||||
export class DiscordFormModal extends Modal {
|
||||
export class DiscordFormModal extends ModalBase {
|
||||
title: string;
|
||||
customId: string;
|
||||
components: Array<Label | TextDisplay>;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../../src/globals.js", () => ({
|
||||
vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
|
||||
logVerbose: vi.fn(),
|
||||
}));
|
||||
|
||||
import { logVerbose } from "../../../src/globals.js";
|
||||
import { attachDiscordGatewayLogging } from "./gateway-logging.js";
|
||||
let logVerbose: typeof import("openclaw/plugin-sdk/runtime-env").logVerbose;
|
||||
let attachDiscordGatewayLogging: typeof import("./gateway-logging.js").attachDiscordGatewayLogging;
|
||||
|
||||
const makeRuntime = () => ({
|
||||
log: vi.fn(),
|
||||
@@ -15,6 +15,12 @@ const makeRuntime = () => ({
|
||||
});
|
||||
|
||||
describe("attachDiscordGatewayLogging", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ logVerbose } = await import("openclaw/plugin-sdk/runtime-env"));
|
||||
({ attachDiscordGatewayLogging } = await import("./gateway-logging.js"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -61,6 +61,11 @@ function createAutoThreadMentionContext() {
|
||||
return { guildInfo, channelConfig };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("registerDiscordListener", () => {
|
||||
class FakeListener {}
|
||||
|
||||
@@ -892,6 +897,22 @@ const { enqueueSystemEventSpy, resolveAgentRouteMock } = vi.hoisted(() => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
enqueueSystemEvent: enqueueSystemEventSpy,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/routing", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/routing")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveAgentRoute: resolveAgentRouteMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../src/infra/system-events.js", () => ({
|
||||
enqueueSystemEvent: enqueueSystemEventSpy,
|
||||
}));
|
||||
|
||||
+11
-5
@@ -1,5 +1,5 @@
|
||||
import type { Client } from "@buape/carbon";
|
||||
import { ChannelType, MessageType } from "@buape/carbon";
|
||||
import { MessageType } from "@buape/carbon";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
dispatchMock,
|
||||
@@ -9,13 +9,19 @@ import {
|
||||
updateLastRouteMock,
|
||||
upsertPairingRequestMock,
|
||||
} from "./monitor.tool-result.test-harness.js";
|
||||
import { createDiscordMessageHandler } from "./monitor/message-handler.js";
|
||||
import { __resetDiscordChannelInfoCacheForTest } from "./monitor/message-utils.js";
|
||||
import { createNoopThreadBindingManager } from "./monitor/thread-bindings.js";
|
||||
|
||||
type Config = ReturnType<typeof import("../../../src/config/config.js").loadConfig>;
|
||||
let ChannelType: typeof import("@buape/carbon").ChannelType;
|
||||
let createDiscordMessageHandler: typeof import("./monitor/message-handler.js").createDiscordMessageHandler;
|
||||
let __resetDiscordChannelInfoCacheForTest: typeof import("./monitor/message-utils.js").__resetDiscordChannelInfoCacheForTest;
|
||||
let createNoopThreadBindingManager: typeof import("./monitor/thread-bindings.js").createNoopThreadBindingManager;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ ChannelType } = await import("@buape/carbon"));
|
||||
({ createDiscordMessageHandler } = await import("./monitor/message-handler.js"));
|
||||
({ __resetDiscordChannelInfoCacheForTest } = await import("./monitor/message-utils.js"));
|
||||
({ createNoopThreadBindingManager } = await import("./monitor/thread-bindings.js"));
|
||||
__resetDiscordChannelInfoCacheForTest();
|
||||
sendMock.mockClear().mockResolvedValue(undefined);
|
||||
updateLastRouteMock.mockClear();
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildDiscordComponentCustomId, buildDiscordModalCustomId } from "../components.js";
|
||||
import {
|
||||
createDiscordComponentButton,
|
||||
createDiscordComponentChannelSelect,
|
||||
createDiscordComponentMentionableSelect,
|
||||
createDiscordComponentModal,
|
||||
createDiscordComponentRoleSelect,
|
||||
createDiscordComponentStringSelect,
|
||||
createDiscordComponentUserSelect,
|
||||
} from "./agent-components.js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let buildDiscordComponentCustomId: typeof import("../components.js").buildDiscordComponentCustomId;
|
||||
let buildDiscordModalCustomId: typeof import("../components.js").buildDiscordModalCustomId;
|
||||
let createDiscordComponentButton: typeof import("./agent-components.js").createDiscordComponentButton;
|
||||
let createDiscordComponentChannelSelect: typeof import("./agent-components.js").createDiscordComponentChannelSelect;
|
||||
let createDiscordComponentMentionableSelect: typeof import("./agent-components.js").createDiscordComponentMentionableSelect;
|
||||
let createDiscordComponentModal: typeof import("./agent-components.js").createDiscordComponentModal;
|
||||
let createDiscordComponentRoleSelect: typeof import("./agent-components.js").createDiscordComponentRoleSelect;
|
||||
let createDiscordComponentStringSelect: typeof import("./agent-components.js").createDiscordComponentStringSelect;
|
||||
let createDiscordComponentUserSelect: typeof import("./agent-components.js").createDiscordComponentUserSelect;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ buildDiscordComponentCustomId, buildDiscordModalCustomId } = await import("../components.js"));
|
||||
({
|
||||
createDiscordComponentButton,
|
||||
createDiscordComponentChannelSelect,
|
||||
createDiscordComponentMentionableSelect,
|
||||
createDiscordComponentModal,
|
||||
createDiscordComponentRoleSelect,
|
||||
createDiscordComponentStringSelect,
|
||||
createDiscordComponentUserSelect,
|
||||
} = await import("./agent-components.js"));
|
||||
});
|
||||
|
||||
type WildcardComponent = {
|
||||
customId: string;
|
||||
|
||||
@@ -6,15 +6,6 @@ import { Routes } from "discord-api-types/v10";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { clearSessionStoreCacheForTest } from "../../../../src/config/sessions.js";
|
||||
import type { DiscordExecApprovalConfig } from "../../../../src/config/types.discord.js";
|
||||
import {
|
||||
buildExecApprovalCustomId,
|
||||
extractDiscordChannelId,
|
||||
parseExecApprovalData,
|
||||
type ExecApprovalRequest,
|
||||
DiscordExecApprovalHandler,
|
||||
ExecApprovalButton,
|
||||
type ExecApprovalButtonContext,
|
||||
} from "./exec-approvals.js";
|
||||
|
||||
const STORE_PATH = path.join(os.tmpdir(), "openclaw-exec-approvals-test.json");
|
||||
|
||||
@@ -59,6 +50,7 @@ const gatewayClientRequests = vi.hoisted(() => vi.fn(async () => ({ ok: true }))
|
||||
const gatewayClientParams = vi.hoisted(() => [] as Array<Record<string, unknown>>);
|
||||
const mockGatewayClientCtor = vi.hoisted(() => vi.fn());
|
||||
const mockResolveGatewayConnectionAuth = vi.hoisted(() => vi.fn());
|
||||
const mockCreateOperatorApprovalsGatewayClient = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../send.shared.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../send.shared.js")>();
|
||||
@@ -75,8 +67,12 @@ vi.mock("../send.shared.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../../src/gateway/client.js", () => ({
|
||||
GatewayClient: class {
|
||||
vi.mock("openclaw/plugin-sdk/gateway-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/gateway-runtime")>();
|
||||
type CreateOperatorApprovalsGatewayClientParams = Parameters<
|
||||
typeof actual.createOperatorApprovalsGatewayClient
|
||||
>[0];
|
||||
class MockGatewayClient {
|
||||
private params: Record<string, unknown>;
|
||||
constructor(params: Record<string, unknown>) {
|
||||
this.params = params;
|
||||
@@ -92,18 +88,70 @@ vi.mock("../../../../src/gateway/client.js", () => ({
|
||||
async request() {
|
||||
return gatewayClientRequests();
|
||||
}
|
||||
},
|
||||
}));
|
||||
}
|
||||
return {
|
||||
...actual,
|
||||
GatewayClient: MockGatewayClient,
|
||||
createOperatorApprovalsGatewayClient: async (
|
||||
params: CreateOperatorApprovalsGatewayClientParams,
|
||||
) => {
|
||||
mockCreateOperatorApprovalsGatewayClient(params);
|
||||
const envUrl = process.env.OPENCLAW_GATEWAY_URL?.trim();
|
||||
const gatewayUrl = params.gatewayUrl?.trim() || envUrl || "ws://127.0.0.1:18789";
|
||||
const urlOverrideSource = params.gatewayUrl?.trim() ? "cli" : envUrl ? "env" : undefined;
|
||||
const auth = await mockResolveGatewayConnectionAuth({
|
||||
config: params.config,
|
||||
env: process.env,
|
||||
...(urlOverrideSource
|
||||
? {
|
||||
urlOverride: gatewayUrl,
|
||||
urlOverrideSource,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
return new MockGatewayClient({
|
||||
url: gatewayUrl,
|
||||
token: auth?.token,
|
||||
password: auth?.password,
|
||||
clientName: "gateway-client",
|
||||
clientDisplayName: params.clientDisplayName,
|
||||
mode: "backend",
|
||||
scopes: ["operator.approvals"],
|
||||
onEvent: params.onEvent,
|
||||
onHelloOk: params.onHelloOk,
|
||||
onConnectError: params.onConnectError,
|
||||
onClose: params.onClose,
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../../src/gateway/connection-auth.js", () => ({
|
||||
resolveGatewayConnectionAuth: mockResolveGatewayConnectionAuth,
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/text-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/text-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
logDebug: vi.fn(),
|
||||
logError: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../../src/logger.js", () => ({
|
||||
logDebug: vi.fn(),
|
||||
logError: vi.fn(),
|
||||
}));
|
||||
|
||||
let buildExecApprovalCustomId: typeof import("./exec-approvals.js").buildExecApprovalCustomId;
|
||||
let extractDiscordChannelId: typeof import("./exec-approvals.js").extractDiscordChannelId;
|
||||
let parseExecApprovalData: typeof import("./exec-approvals.js").parseExecApprovalData;
|
||||
let DiscordExecApprovalHandler: typeof import("./exec-approvals.js").DiscordExecApprovalHandler;
|
||||
let ExecApprovalButton: typeof import("./exec-approvals.js").ExecApprovalButton;
|
||||
type DiscordExecApprovalHandlerInstance = InstanceType<
|
||||
typeof import("./exec-approvals.js").DiscordExecApprovalHandler
|
||||
>;
|
||||
|
||||
type ExecApprovalRequest = import("./exec-approvals.js").ExecApprovalRequest;
|
||||
type ExecApprovalButtonContext = import("./exec-approvals.js").ExecApprovalButtonContext;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function createHandler(config: DiscordExecApprovalConfig, accountId = "default") {
|
||||
@@ -143,7 +191,7 @@ function mockSuccessfulDmDelivery(params?: {
|
||||
}
|
||||
|
||||
async function expectGatewayAuthStart(params: {
|
||||
handler: DiscordExecApprovalHandler;
|
||||
handler: DiscordExecApprovalHandlerInstance;
|
||||
expectedUrl: string;
|
||||
expectedSource: "cli" | "env";
|
||||
expectedToken?: string;
|
||||
@@ -181,11 +229,13 @@ type ExecApprovalHandlerInternals = {
|
||||
handleApprovalTimeout: (approvalId: string, source?: "channel" | "dm") => Promise<void>;
|
||||
};
|
||||
|
||||
function getHandlerInternals(handler: DiscordExecApprovalHandler): ExecApprovalHandlerInternals {
|
||||
function getHandlerInternals(
|
||||
handler: DiscordExecApprovalHandlerInstance,
|
||||
): ExecApprovalHandlerInternals {
|
||||
return handler as unknown as ExecApprovalHandlerInternals;
|
||||
}
|
||||
|
||||
function clearPendingTimeouts(handler: DiscordExecApprovalHandler) {
|
||||
function clearPendingTimeouts(handler: DiscordExecApprovalHandlerInstance) {
|
||||
const internals = getHandlerInternals(handler);
|
||||
for (const pending of internals.pending.values()) {
|
||||
clearTimeout(pending.timeoutId);
|
||||
@@ -220,6 +270,18 @@ beforeEach(() => {
|
||||
gatewayClientRequests.mockReset();
|
||||
gatewayClientRequests.mockResolvedValue({ ok: true });
|
||||
gatewayClientParams.length = 0;
|
||||
mockCreateOperatorApprovalsGatewayClient.mockReset();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({
|
||||
buildExecApprovalCustomId,
|
||||
extractDiscordChannelId,
|
||||
parseExecApprovalData,
|
||||
DiscordExecApprovalHandler,
|
||||
ExecApprovalButton,
|
||||
} = await import("./exec-approvals.js"));
|
||||
});
|
||||
|
||||
// ─── buildExecApprovalCustomId ────────────────────────────────────────────────
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DiscordMessageListener } from "./listeners.js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let DiscordMessageListener: typeof import("./listeners.js").DiscordMessageListener;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ DiscordMessageListener } = await import("./listeners.js"));
|
||||
});
|
||||
|
||||
function createLogger() {
|
||||
return {
|
||||
|
||||
@@ -10,11 +10,6 @@ import {
|
||||
__testing as sessionBindingTesting,
|
||||
registerSessionBindingAdapter,
|
||||
} from "../../../../src/infra/outbound/session-binding-service.js";
|
||||
import {
|
||||
preflightDiscordMessage,
|
||||
resolvePreflightMentionRequirement,
|
||||
shouldIgnoreBoundThreadWebhookMessage,
|
||||
} from "./message-handler.preflight.js";
|
||||
import {
|
||||
createDiscordMessage,
|
||||
createDiscordPreflightArgs,
|
||||
@@ -25,10 +20,22 @@ import {
|
||||
type DiscordConfig,
|
||||
type DiscordMessageEvent,
|
||||
} from "./message-handler.preflight.test-helpers.js";
|
||||
import {
|
||||
__testing as threadBindingTesting,
|
||||
createThreadBindingManager,
|
||||
} from "./thread-bindings.js";
|
||||
let preflightDiscordMessage: typeof import("./message-handler.preflight.js").preflightDiscordMessage;
|
||||
let resolvePreflightMentionRequirement: typeof import("./message-handler.preflight.js").resolvePreflightMentionRequirement;
|
||||
let shouldIgnoreBoundThreadWebhookMessage: typeof import("./message-handler.preflight.js").shouldIgnoreBoundThreadWebhookMessage;
|
||||
let threadBindingTesting: typeof import("./thread-bindings.js").__testing;
|
||||
let createThreadBindingManager: typeof import("./thread-bindings.js").createThreadBindingManager;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({
|
||||
preflightDiscordMessage,
|
||||
resolvePreflightMentionRequirement,
|
||||
shouldIgnoreBoundThreadWebhookMessage,
|
||||
} = await import("./message-handler.preflight.js"));
|
||||
({ __testing: threadBindingTesting, createThreadBindingManager } =
|
||||
await import("./thread-bindings.js"));
|
||||
});
|
||||
|
||||
function createThreadBinding(
|
||||
overrides?: Partial<
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_EMOJIS } from "../../../../src/channels/status-reactions.js";
|
||||
import {
|
||||
createBaseDiscordMessageContext,
|
||||
createDiscordDirectMessageContextOverrides,
|
||||
} from "./message-handler.test-harness.js";
|
||||
import {
|
||||
__testing as threadBindingTesting,
|
||||
createThreadBindingManager,
|
||||
} from "./thread-bindings.js";
|
||||
|
||||
const sendMocks = vi.hoisted(() => ({
|
||||
reactMessageDiscord: vi.fn(async () => {}),
|
||||
@@ -66,6 +58,11 @@ const configSessionsMocks = vi.hoisted(() => ({
|
||||
}));
|
||||
const readSessionUpdatedAt = configSessionsMocks.readSessionUpdatedAt;
|
||||
const resolveStorePath = configSessionsMocks.resolveStorePath;
|
||||
let createBaseDiscordMessageContext: typeof import("./message-handler.test-harness.js").createBaseDiscordMessageContext;
|
||||
let createDiscordDirectMessageContextOverrides: typeof import("./message-handler.test-harness.js").createDiscordDirectMessageContextOverrides;
|
||||
let threadBindingTesting: typeof import("./thread-bindings.js").__testing;
|
||||
let createThreadBindingManager: typeof import("./thread-bindings.js").createThreadBindingManager;
|
||||
let processDiscordMessage: typeof import("./message-handler.process.js").processDiscordMessage;
|
||||
|
||||
vi.mock("../send.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../send.js")>();
|
||||
@@ -89,46 +86,52 @@ vi.mock("./reply-delivery.js", () => ({
|
||||
deliverDiscordReply: deliveryMocks.deliverDiscordReply,
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/auto-reply/dispatch.js", () => ({
|
||||
dispatchInboundMessage,
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
dispatchInboundMessage,
|
||||
createReplyDispatcherWithTyping: vi.fn(
|
||||
(opts: { deliver: (payload: unknown, info: { kind: string }) => Promise<void> | void }) => ({
|
||||
dispatcher: {
|
||||
sendToolResult: vi.fn(() => true),
|
||||
sendBlockReply: vi.fn((payload: unknown) => {
|
||||
void opts.deliver(payload as never, { kind: "block" });
|
||||
return true;
|
||||
}),
|
||||
sendFinalReply: vi.fn((payload: unknown) => {
|
||||
void opts.deliver(payload as never, { kind: "final" });
|
||||
return true;
|
||||
}),
|
||||
waitForIdle: vi.fn(async () => {}),
|
||||
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
|
||||
markComplete: vi.fn(),
|
||||
},
|
||||
replyOptions: {},
|
||||
markDispatchIdle: vi.fn(),
|
||||
markRunComplete: vi.fn(),
|
||||
}),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../../src/auto-reply/reply/reply-dispatcher.js", () => ({
|
||||
createReplyDispatcherWithTyping: vi.fn(
|
||||
(opts: { deliver: (payload: unknown, info: { kind: string }) => Promise<void> | void }) => ({
|
||||
dispatcher: {
|
||||
sendToolResult: vi.fn(() => true),
|
||||
sendBlockReply: vi.fn((payload: unknown) => {
|
||||
void opts.deliver(payload as never, { kind: "block" });
|
||||
return true;
|
||||
}),
|
||||
sendFinalReply: vi.fn((payload: unknown) => {
|
||||
void opts.deliver(payload as never, { kind: "final" });
|
||||
return true;
|
||||
}),
|
||||
waitForIdle: vi.fn(async () => {}),
|
||||
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
|
||||
markComplete: vi.fn(),
|
||||
},
|
||||
replyOptions: {},
|
||||
markDispatchIdle: vi.fn(),
|
||||
markRunComplete: vi.fn(),
|
||||
}),
|
||||
),
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
recordInboundSession,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../../src/channels/session.js", () => ({
|
||||
recordInboundSession,
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
readSessionUpdatedAt: configSessionsMocks.readSessionUpdatedAt,
|
||||
resolveStorePath: configSessionsMocks.resolveStorePath,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../../src/config/sessions.js", () => ({
|
||||
readSessionUpdatedAt: configSessionsMocks.readSessionUpdatedAt,
|
||||
resolveStorePath: configSessionsMocks.resolveStorePath,
|
||||
}));
|
||||
|
||||
const { processDiscordMessage } = await import("./message-handler.process.js");
|
||||
|
||||
const createBaseContext = createBaseDiscordMessageContext;
|
||||
const BASE_CHANNEL_ROUTE = {
|
||||
agentId: "main",
|
||||
channel: "discord",
|
||||
@@ -137,6 +140,18 @@ const BASE_CHANNEL_ROUTE = {
|
||||
mainSessionKey: "agent:main:main",
|
||||
} as const;
|
||||
|
||||
async function createBaseContext(
|
||||
...args: Parameters<typeof createBaseDiscordMessageContext>
|
||||
): Promise<Awaited<ReturnType<typeof createBaseDiscordMessageContext>>> {
|
||||
return await createBaseDiscordMessageContext(...args);
|
||||
}
|
||||
|
||||
function createDirectMessageContextOverrides(
|
||||
...args: Parameters<typeof createDiscordDirectMessageContextOverrides>
|
||||
): ReturnType<typeof createDiscordDirectMessageContextOverrides> {
|
||||
return createDiscordDirectMessageContextOverrides(...args);
|
||||
}
|
||||
|
||||
function mockDispatchSingleBlockReply(payload: { text: string; isReasoning?: boolean }) {
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
await params?.dispatcher.sendBlockReply(payload);
|
||||
@@ -154,8 +169,14 @@ async function processStreamOffDiscordMessage() {
|
||||
await processDiscordMessage(ctx as any);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.useRealTimers();
|
||||
({ createBaseDiscordMessageContext, createDiscordDirectMessageContextOverrides } =
|
||||
await import("./message-handler.test-harness.js"));
|
||||
({ __testing: threadBindingTesting, createThreadBindingManager } =
|
||||
await import("./thread-bindings.js"));
|
||||
({ processDiscordMessage } = await import("./message-handler.process.js"));
|
||||
sendMocks.reactMessageDiscord.mockClear();
|
||||
sendMocks.removeReactionDiscord.mockClear();
|
||||
editMessageDiscord.mockClear();
|
||||
@@ -418,7 +439,7 @@ describe("processDiscordMessage ack reactions", () => {
|
||||
describe("processDiscordMessage session routing", () => {
|
||||
it("stores DM lastRoute with user target for direct-session continuity", async () => {
|
||||
const ctx = await createBaseContext({
|
||||
...createDiscordDirectMessageContextOverrides(),
|
||||
...createDirectMessageContextOverrides(),
|
||||
message: {
|
||||
id: "m1",
|
||||
channelId: "dm1",
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createDiscordMessageHandler,
|
||||
preflightDiscordMessageMock,
|
||||
processDiscordMessageMock,
|
||||
} from "./message-handler.module-test-helpers.js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createDiscordHandlerParams,
|
||||
createDiscordPreflightContext,
|
||||
} from "./message-handler.test-helpers.js";
|
||||
let createDiscordMessageHandler: typeof import("./message-handler.module-test-helpers.js").createDiscordMessageHandler;
|
||||
let preflightDiscordMessageMock: typeof import("./message-handler.module-test-helpers.js").preflightDiscordMessageMock;
|
||||
let processDiscordMessageMock: typeof import("./message-handler.module-test-helpers.js").processDiscordMessageMock;
|
||||
|
||||
const eventualReplyDeliveredMock = vi.hoisted(() => vi.fn());
|
||||
type SetStatusFn = (patch: Record<string, unknown>) => void;
|
||||
@@ -86,6 +84,12 @@ async function createLifecycleStopScenario(params: {
|
||||
}
|
||||
|
||||
describe("createDiscordMessageHandler queue behavior", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ createDiscordMessageHandler, preflightDiscordMessageMock, processDiscordMessageMock } =
|
||||
await import("./message-handler.module-test-helpers.js"));
|
||||
});
|
||||
|
||||
it("resets busy counters when the handler is created", () => {
|
||||
preflightDiscordMessageMock.mockReset();
|
||||
processDiscordMessageMock.mockReset();
|
||||
|
||||
@@ -21,14 +21,24 @@ vi.mock("../../../../src/globals.js", () => ({
|
||||
logVerbose: () => {},
|
||||
}));
|
||||
|
||||
const {
|
||||
__resetDiscordChannelInfoCacheForTest,
|
||||
resolveDiscordChannelInfo,
|
||||
resolveDiscordMessageChannelId,
|
||||
resolveDiscordMessageText,
|
||||
resolveForwardedMediaList,
|
||||
resolveMediaList,
|
||||
} = await import("./message-utils.js");
|
||||
let __resetDiscordChannelInfoCacheForTest: typeof import("./message-utils.js").__resetDiscordChannelInfoCacheForTest;
|
||||
let resolveDiscordChannelInfo: typeof import("./message-utils.js").resolveDiscordChannelInfo;
|
||||
let resolveDiscordMessageChannelId: typeof import("./message-utils.js").resolveDiscordMessageChannelId;
|
||||
let resolveDiscordMessageText: typeof import("./message-utils.js").resolveDiscordMessageText;
|
||||
let resolveForwardedMediaList: typeof import("./message-utils.js").resolveForwardedMediaList;
|
||||
let resolveMediaList: typeof import("./message-utils.js").resolveMediaList;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({
|
||||
__resetDiscordChannelInfoCacheForTest,
|
||||
resolveDiscordChannelInfo,
|
||||
resolveDiscordMessageChannelId,
|
||||
resolveDiscordMessageText,
|
||||
resolveForwardedMediaList,
|
||||
resolveMediaList,
|
||||
} = await import("./message-utils.js"));
|
||||
});
|
||||
|
||||
function asMessage(payload: Record<string, unknown>): Message {
|
||||
return payload as unknown as Message;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { listNativeCommandSpecs } from "../../../../src/auto-reply/commands-registry.js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig, loadConfig } from "../../../../src/config/config.js";
|
||||
import { createDiscordNativeCommand } from "./native-command.js";
|
||||
import { createNoopThreadBindingManager } from "./thread-bindings.js";
|
||||
let listNativeCommandSpecs: typeof import("../../../../src/auto-reply/commands-registry.js").listNativeCommandSpecs;
|
||||
let createDiscordNativeCommand: typeof import("./native-command.js").createDiscordNativeCommand;
|
||||
let createNoopThreadBindingManager: typeof import("./thread-bindings.js").createNoopThreadBindingManager;
|
||||
|
||||
function createNativeCommand(name: string): ReturnType<typeof createDiscordNativeCommand> {
|
||||
function createNativeCommand(
|
||||
name: string,
|
||||
): ReturnType<typeof import("./native-command.js").createDiscordNativeCommand> {
|
||||
const command = listNativeCommandSpecs({ provider: "discord" }).find(
|
||||
(entry) => entry.name === name,
|
||||
);
|
||||
@@ -24,17 +26,19 @@ function createNativeCommand(name: string): ReturnType<typeof createDiscordNativ
|
||||
});
|
||||
}
|
||||
|
||||
type CommandOption = NonNullable<ReturnType<typeof createDiscordNativeCommand>["options"]>[number];
|
||||
type CommandOption = NonNullable<
|
||||
ReturnType<typeof import("./native-command.js").createDiscordNativeCommand>["options"]
|
||||
>[number];
|
||||
|
||||
function findOption(
|
||||
command: ReturnType<typeof createDiscordNativeCommand>,
|
||||
command: ReturnType<typeof import("./native-command.js").createDiscordNativeCommand>,
|
||||
name: string,
|
||||
): CommandOption | undefined {
|
||||
return command.options?.find((entry) => entry.name === name);
|
||||
}
|
||||
|
||||
function requireOption(
|
||||
command: ReturnType<typeof createDiscordNativeCommand>,
|
||||
command: ReturnType<typeof import("./native-command.js").createDiscordNativeCommand>,
|
||||
name: string,
|
||||
): CommandOption {
|
||||
const option = findOption(command, name);
|
||||
@@ -60,6 +64,13 @@ function readChoices(option: CommandOption | undefined): unknown[] | undefined {
|
||||
}
|
||||
|
||||
describe("createDiscordNativeCommand option wiring", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ listNativeCommandSpecs } = await import("../../../../src/auto-reply/commands-registry.js"));
|
||||
({ createDiscordNativeCommand } = await import("./native-command.js"));
|
||||
({ createNoopThreadBindingManager } = await import("./thread-bindings.js"));
|
||||
});
|
||||
|
||||
it("uses autocomplete for /acp action so inline action values are accepted", async () => {
|
||||
const command = createNativeCommand("acp");
|
||||
const action = requireOption(command, "action");
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __testing } from "./provider.js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let __testing: typeof import("./provider.js").__testing;
|
||||
|
||||
describe("resolveThreadBindingsEnabled", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ __testing } = await import("./provider.js"));
|
||||
});
|
||||
|
||||
it("defaults to enabled when unset", () => {
|
||||
expect(
|
||||
__testing.resolveThreadBindingsEnabled({
|
||||
|
||||
@@ -34,11 +34,14 @@ vi.mock("../send.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
const { maybeSendBindingMessage, resolveChannelIdForBinding } =
|
||||
await import("./thread-bindings.discord-api.js");
|
||||
let maybeSendBindingMessage: typeof import("./thread-bindings.discord-api.js").maybeSendBindingMessage;
|
||||
let resolveChannelIdForBinding: typeof import("./thread-bindings.discord-api.js").resolveChannelIdForBinding;
|
||||
|
||||
describe("resolveChannelIdForBinding", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ maybeSendBindingMessage, resolveChannelIdForBinding } =
|
||||
await import("./thread-bindings.discord-api.js"));
|
||||
hoisted.restGet.mockClear();
|
||||
hoisted.createDiscordRestClient.mockClear();
|
||||
hoisted.sendMessageDiscord.mockClear().mockResolvedValue({});
|
||||
@@ -124,7 +127,10 @@ describe("resolveChannelIdForBinding", () => {
|
||||
});
|
||||
|
||||
describe("maybeSendBindingMessage", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ maybeSendBindingMessage, resolveChannelIdForBinding } =
|
||||
await import("./thread-bindings.discord-api.js"));
|
||||
hoisted.sendMessageDiscord.mockClear().mockResolvedValue({});
|
||||
hoisted.sendWebhookMessageDiscord.mockClear().mockResolvedValue({});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
const { closeDiscordThreadSessions } = await import("./thread-session-close.js");
|
||||
let closeDiscordThreadSessions: typeof import("./thread-session-close.js").closeDiscordThreadSessions;
|
||||
|
||||
function setupStore(store: Record<string, { updatedAt: number }>) {
|
||||
hoisted.updateSessionStore.mockImplementation(
|
||||
@@ -30,7 +30,9 @@ const MATCHED_KEY = `agent:main:discord:channel:${THREAD_ID}`;
|
||||
const UNMATCHED_KEY = `agent:main:discord:channel:${OTHER_ID}`;
|
||||
|
||||
describe("closeDiscordThreadSessions", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ closeDiscordThreadSessions } = await import("./thread-session-close.js"));
|
||||
hoisted.updateSessionStore.mockClear();
|
||||
hoisted.resolveStorePath.mockClear();
|
||||
hoisted.resolveStorePath.mockReturnValue("/tmp/openclaw-sessions.json");
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { normalizeDiscordOutboundTarget } from "./normalize.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const sendMessageDiscordMock = vi.fn();
|
||||
@@ -37,7 +36,14 @@ vi.mock("./monitor/thread-bindings.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
const { discordOutbound } = await import("./outbound-adapter.js");
|
||||
let normalizeDiscordOutboundTarget: typeof import("./normalize.js").normalizeDiscordOutboundTarget;
|
||||
let discordOutbound: typeof import("./outbound-adapter.js").discordOutbound;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ normalizeDiscordOutboundTarget } = await import("./normalize.js"));
|
||||
({ discordOutbound } = await import("./outbound-adapter.js"));
|
||||
});
|
||||
|
||||
const DEFAULT_DISCORD_SEND_RESULT = {
|
||||
channel: "discord",
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { ChannelType } from "discord-api-types/v10";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerDiscordComponentEntries } from "./components-registry.js";
|
||||
import {
|
||||
editDiscordComponentMessage,
|
||||
registerBuiltDiscordComponentMessage,
|
||||
sendDiscordComponentMessage,
|
||||
} from "./send.components.js";
|
||||
import { makeDiscordRest } from "./send.test-harness.js";
|
||||
|
||||
const loadConfigMock = vi.hoisted(() => vi.fn(() => ({ session: { dmScope: "main" } })));
|
||||
@@ -24,10 +18,23 @@ vi.mock("./components-registry.js", () => ({
|
||||
registerDiscordComponentEntries: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("sendDiscordComponentMessage", () => {
|
||||
const registerMock = vi.mocked(registerDiscordComponentEntries);
|
||||
let registerDiscordComponentEntries: typeof import("./components-registry.js").registerDiscordComponentEntries;
|
||||
let editDiscordComponentMessage: typeof import("./send.components.js").editDiscordComponentMessage;
|
||||
let registerBuiltDiscordComponentMessage: typeof import("./send.components.js").registerBuiltDiscordComponentMessage;
|
||||
let sendDiscordComponentMessage: typeof import("./send.components.js").sendDiscordComponentMessage;
|
||||
|
||||
beforeEach(() => {
|
||||
describe("sendDiscordComponentMessage", () => {
|
||||
let registerMock: ReturnType<typeof vi.mocked<typeof registerDiscordComponentEntries>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ registerDiscordComponentEntries } = await import("./components-registry.js"));
|
||||
({
|
||||
editDiscordComponentMessage,
|
||||
registerBuiltDiscordComponentMessage,
|
||||
sendDiscordComponentMessage,
|
||||
} = await import("./send.components.js"));
|
||||
registerMock = vi.mocked(registerDiscordComponentEntries);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
|
||||
@@ -2,21 +2,6 @@ import { RateLimitError } from "@buape/carbon";
|
||||
import { ChannelType, Routes } from "discord-api-types/v10";
|
||||
import { loadWebMediaRaw } from "openclaw/plugin-sdk/web-media";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
addRoleDiscord,
|
||||
banMemberDiscord,
|
||||
createThreadDiscord,
|
||||
listGuildEmojisDiscord,
|
||||
listThreadsDiscord,
|
||||
reactMessageDiscord,
|
||||
removeRoleDiscord,
|
||||
sendMessageDiscord,
|
||||
sendPollDiscord,
|
||||
sendStickerDiscord,
|
||||
timeoutMemberDiscord,
|
||||
uploadEmojiDiscord,
|
||||
uploadStickerDiscord,
|
||||
} from "./send.js";
|
||||
import { makeDiscordRest } from "./send.test-harness.js";
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/web-media", async () => {
|
||||
@@ -24,11 +9,41 @@ vi.mock("openclaw/plugin-sdk/web-media", async () => {
|
||||
return discordWebMediaMockFactory();
|
||||
});
|
||||
|
||||
describe("sendMessageDiscord", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
let addRoleDiscord: typeof import("./send.js").addRoleDiscord;
|
||||
let banMemberDiscord: typeof import("./send.js").banMemberDiscord;
|
||||
let createThreadDiscord: typeof import("./send.js").createThreadDiscord;
|
||||
let listGuildEmojisDiscord: typeof import("./send.js").listGuildEmojisDiscord;
|
||||
let listThreadsDiscord: typeof import("./send.js").listThreadsDiscord;
|
||||
let reactMessageDiscord: typeof import("./send.js").reactMessageDiscord;
|
||||
let removeRoleDiscord: typeof import("./send.js").removeRoleDiscord;
|
||||
let sendMessageDiscord: typeof import("./send.js").sendMessageDiscord;
|
||||
let sendPollDiscord: typeof import("./send.js").sendPollDiscord;
|
||||
let sendStickerDiscord: typeof import("./send.js").sendStickerDiscord;
|
||||
let timeoutMemberDiscord: typeof import("./send.js").timeoutMemberDiscord;
|
||||
let uploadEmojiDiscord: typeof import("./send.js").uploadEmojiDiscord;
|
||||
let uploadStickerDiscord: typeof import("./send.js").uploadStickerDiscord;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({
|
||||
addRoleDiscord,
|
||||
banMemberDiscord,
|
||||
createThreadDiscord,
|
||||
listGuildEmojisDiscord,
|
||||
listThreadsDiscord,
|
||||
reactMessageDiscord,
|
||||
removeRoleDiscord,
|
||||
sendMessageDiscord,
|
||||
sendPollDiscord,
|
||||
sendStickerDiscord,
|
||||
timeoutMemberDiscord,
|
||||
uploadEmojiDiscord,
|
||||
uploadStickerDiscord,
|
||||
} = await import("./send.js"));
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("sendMessageDiscord", () => {
|
||||
it("creates a thread", async () => {
|
||||
const { rest, getMock, postMock } = makeDiscordRest();
|
||||
postMock.mockResolvedValue({ id: "t1" });
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import type { RequestClient } from "@buape/carbon";
|
||||
import { PermissionFlagsBits, Routes } from "discord-api-types/v10";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
fetchMemberGuildPermissionsDiscord,
|
||||
hasAllGuildPermissionsDiscord,
|
||||
hasAnyGuildPermissionDiscord,
|
||||
} from "./send.permissions.js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockRest = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
@@ -15,6 +10,10 @@ vi.mock("./client.js", () => ({
|
||||
resolveDiscordRest: () => mockRest as unknown as RequestClient,
|
||||
}));
|
||||
|
||||
let fetchMemberGuildPermissionsDiscord: typeof import("./send.permissions.js").fetchMemberGuildPermissionsDiscord;
|
||||
let hasAllGuildPermissionsDiscord: typeof import("./send.permissions.js").hasAllGuildPermissionsDiscord;
|
||||
let hasAnyGuildPermissionDiscord: typeof import("./send.permissions.js").hasAnyGuildPermissionDiscord;
|
||||
|
||||
type RouteMockParams = {
|
||||
guildId?: string;
|
||||
userId?: string;
|
||||
@@ -44,6 +43,16 @@ function mockGuildMemberRoutes(params: RouteMockParams): void {
|
||||
}
|
||||
|
||||
describe("discord guild permission authorization", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({
|
||||
fetchMemberGuildPermissionsDiscord,
|
||||
hasAllGuildPermissionsDiscord,
|
||||
hasAnyGuildPermissionDiscord,
|
||||
} = await import("./send.permissions.js"));
|
||||
mockRest.get.mockReset();
|
||||
});
|
||||
|
||||
describe("fetchMemberGuildPermissionsDiscord", () => {
|
||||
it("returns null when user is not a guild member", async () => {
|
||||
mockRest.get.mockRejectedValueOnce(new Error("404 Member not found"));
|
||||
|
||||
@@ -1,24 +1,5 @@
|
||||
import { ChannelType, PermissionFlagsBits, Routes } from "discord-api-types/v10";
|
||||
import { loadWebMedia } from "openclaw/plugin-sdk/web-media";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetDiscordDirectoryCacheForTest,
|
||||
rememberDiscordDirectoryUser,
|
||||
} from "./directory-cache.js";
|
||||
import {
|
||||
deleteMessageDiscord,
|
||||
editMessageDiscord,
|
||||
fetchChannelPermissionsDiscord,
|
||||
fetchReactionsDiscord,
|
||||
pinMessageDiscord,
|
||||
reactMessageDiscord,
|
||||
readMessagesDiscord,
|
||||
removeOwnReactionsDiscord,
|
||||
removeReactionDiscord,
|
||||
searchMessagesDiscord,
|
||||
sendMessageDiscord,
|
||||
unpinMessageDiscord,
|
||||
} from "./send.js";
|
||||
import { makeDiscordRest } from "./send.test-harness.js";
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/web-media", async () => {
|
||||
@@ -26,6 +7,45 @@ vi.mock("openclaw/plugin-sdk/web-media", async () => {
|
||||
return discordWebMediaMockFactory();
|
||||
});
|
||||
|
||||
let deleteMessageDiscord: typeof import("./send.js").deleteMessageDiscord;
|
||||
let editMessageDiscord: typeof import("./send.js").editMessageDiscord;
|
||||
let fetchChannelPermissionsDiscord: typeof import("./send.js").fetchChannelPermissionsDiscord;
|
||||
let fetchReactionsDiscord: typeof import("./send.js").fetchReactionsDiscord;
|
||||
let pinMessageDiscord: typeof import("./send.js").pinMessageDiscord;
|
||||
let reactMessageDiscord: typeof import("./send.js").reactMessageDiscord;
|
||||
let readMessagesDiscord: typeof import("./send.js").readMessagesDiscord;
|
||||
let removeOwnReactionsDiscord: typeof import("./send.js").removeOwnReactionsDiscord;
|
||||
let removeReactionDiscord: typeof import("./send.js").removeReactionDiscord;
|
||||
let searchMessagesDiscord: typeof import("./send.js").searchMessagesDiscord;
|
||||
let sendMessageDiscord: typeof import("./send.js").sendMessageDiscord;
|
||||
let unpinMessageDiscord: typeof import("./send.js").unpinMessageDiscord;
|
||||
let loadWebMedia: typeof import("openclaw/plugin-sdk/web-media").loadWebMedia;
|
||||
let __resetDiscordDirectoryCacheForTest: typeof import("./directory-cache.js").__resetDiscordDirectoryCacheForTest;
|
||||
let rememberDiscordDirectoryUser: typeof import("./directory-cache.js").rememberDiscordDirectoryUser;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({
|
||||
deleteMessageDiscord,
|
||||
editMessageDiscord,
|
||||
fetchChannelPermissionsDiscord,
|
||||
fetchReactionsDiscord,
|
||||
pinMessageDiscord,
|
||||
reactMessageDiscord,
|
||||
readMessagesDiscord,
|
||||
removeOwnReactionsDiscord,
|
||||
removeReactionDiscord,
|
||||
searchMessagesDiscord,
|
||||
sendMessageDiscord,
|
||||
unpinMessageDiscord,
|
||||
} = await import("./send.js"));
|
||||
({ loadWebMedia } = await import("openclaw/plugin-sdk/web-media"));
|
||||
({ __resetDiscordDirectoryCacheForTest, rememberDiscordDirectoryUser } =
|
||||
await import("./directory-cache.js"));
|
||||
vi.clearAllMocks();
|
||||
__resetDiscordDirectoryCacheForTest();
|
||||
});
|
||||
|
||||
describe("sendMessageDiscord", () => {
|
||||
function expectReplyReference(
|
||||
body: { message_reference?: unknown } | undefined,
|
||||
@@ -65,11 +85,6 @@ describe("sendMessageDiscord", () => {
|
||||
return { rest, postMock };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
__resetDiscordDirectoryCacheForTest();
|
||||
});
|
||||
|
||||
it("sends basic channel messages", async () => {
|
||||
const { rest, postMock, getMock } = makeDiscordRest();
|
||||
// Channel type lookup returns a normal text channel (not a forum).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { RequestClient } from "@buape/carbon";
|
||||
import { Routes } from "discord-api-types/v10";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const resolveDiscordRestMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -8,7 +8,12 @@ vi.mock("./client.js", () => ({
|
||||
resolveDiscordRest: resolveDiscordRestMock,
|
||||
}));
|
||||
|
||||
import { sendTypingDiscord } from "./send.typing.js";
|
||||
let sendTypingDiscord: typeof import("./send.typing.js").sendTypingDiscord;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ sendTypingDiscord } = await import("./send.typing.js"));
|
||||
});
|
||||
|
||||
describe("sendTypingDiscord", () => {
|
||||
it("sends a typing event to the resolved Discord channel route", async () => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { sendWebhookMessageDiscord } from "./send.js";
|
||||
|
||||
const recordChannelActivityMock = vi.hoisted(() => vi.fn());
|
||||
const loadConfigMock = vi.hoisted(() => vi.fn(() => ({ channels: { discord: {} } })));
|
||||
@@ -20,8 +19,12 @@ vi.mock("../../../src/infra/channel-activity.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
let sendWebhookMessageDiscord: typeof import("./send.js").sendWebhookMessageDiscord;
|
||||
|
||||
describe("sendWebhookMessageDiscord activity", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ sendWebhookMessageDiscord } = await import("./send.js"));
|
||||
recordChannelActivityMock.mockClear();
|
||||
loadConfigMock.mockClear();
|
||||
vi.stubGlobal(
|
||||
|
||||
@@ -1,146 +1,91 @@
|
||||
import type { ChildProcess, ExecFileOptions } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type ExecCallback = (
|
||||
error: NodeJS.ErrnoException | null,
|
||||
stdout: string | Buffer,
|
||||
stderr: string | Buffer,
|
||||
) => void;
|
||||
|
||||
type ExecCall = {
|
||||
command: string;
|
||||
args: string[];
|
||||
options?: ExecFileOptions;
|
||||
};
|
||||
|
||||
type MockExecResult = {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
error?: NodeJS.ErrnoException;
|
||||
};
|
||||
|
||||
const execCalls: ExecCall[] = [];
|
||||
const mockExecResults: MockExecResult[] = [];
|
||||
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:child_process")>();
|
||||
const execFileImpl = (
|
||||
file: string,
|
||||
args?: readonly string[] | null,
|
||||
optionsOrCallback?: ExecFileOptions | ExecCallback | null,
|
||||
callbackMaybe?: ExecCallback,
|
||||
) => {
|
||||
const normalizedArgs = Array.isArray(args) ? [...args] : [];
|
||||
const callback =
|
||||
typeof optionsOrCallback === "function" ? optionsOrCallback : (callbackMaybe ?? undefined);
|
||||
const options =
|
||||
typeof optionsOrCallback === "function" ? undefined : (optionsOrCallback ?? undefined);
|
||||
|
||||
execCalls.push({
|
||||
command: file,
|
||||
args: normalizedArgs,
|
||||
options,
|
||||
});
|
||||
|
||||
const next = mockExecResults.shift() ?? { stdout: "", stderr: "" };
|
||||
queueMicrotask(() => {
|
||||
callback?.(next.error ?? null, next.stdout ?? "", next.stderr ?? "");
|
||||
});
|
||||
return {} as ChildProcess;
|
||||
};
|
||||
const execFileWithCustomPromisify = execFileImpl as unknown as typeof actual.execFile & {
|
||||
[promisify.custom]?: (
|
||||
file: string,
|
||||
args?: readonly string[] | null,
|
||||
options?: ExecFileOptions | null,
|
||||
) => Promise<{ stdout: string | Buffer; stderr: string | Buffer }>;
|
||||
};
|
||||
execFileWithCustomPromisify[promisify.custom] = (
|
||||
file: string,
|
||||
args?: readonly string[] | null,
|
||||
options?: ExecFileOptions | null,
|
||||
) =>
|
||||
new Promise<{ stdout: string | Buffer; stderr: string | Buffer }>((resolve, reject) => {
|
||||
execFileImpl(file, args, options, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve({ stdout, stderr });
|
||||
});
|
||||
});
|
||||
const runFfprobeMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<string>>());
|
||||
const runFfmpegMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<void>>());
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
execFile: execFileWithCustomPromisify,
|
||||
resolvePreferredOpenClawTmpDir: () => "/tmp",
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../src/infra/tmp-openclaw-dir.js", () => ({
|
||||
resolvePreferredOpenClawTmpDir: () => "/tmp",
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/media-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
runFfprobe: runFfprobeMock,
|
||||
runFfmpeg: runFfmpegMock,
|
||||
parseFfprobeCodecAndSampleRate: (stdout: string) => {
|
||||
const [codec, sampleRate] = stdout.trim().split(",");
|
||||
return {
|
||||
codec,
|
||||
sampleRateHz: Number(sampleRate),
|
||||
};
|
||||
},
|
||||
MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS: 1200,
|
||||
unlinkIfExists: vi.fn(async () => {}),
|
||||
};
|
||||
});
|
||||
|
||||
const { ensureOggOpus } = await import("./voice-message.js");
|
||||
let ensureOggOpus: typeof import("./voice-message.js").ensureOggOpus;
|
||||
|
||||
describe("ensureOggOpus", () => {
|
||||
beforeEach(() => {
|
||||
execCalls.length = 0;
|
||||
mockExecResults.length = 0;
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
runFfprobeMock.mockReset();
|
||||
runFfmpegMock.mockReset();
|
||||
({ ensureOggOpus } = await import("./voice-message.js"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
execCalls.length = 0;
|
||||
mockExecResults.length = 0;
|
||||
runFfprobeMock.mockReset();
|
||||
runFfmpegMock.mockReset();
|
||||
});
|
||||
|
||||
it("rejects URL/protocol input paths", async () => {
|
||||
await expect(ensureOggOpus("https://example.com/audio.ogg")).rejects.toThrow(
|
||||
/local file path/i,
|
||||
);
|
||||
expect(execCalls).toHaveLength(0);
|
||||
expect(runFfprobeMock).not.toHaveBeenCalled();
|
||||
expect(runFfmpegMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps .ogg only when codec is opus and sample rate is 48kHz", async () => {
|
||||
mockExecResults.push({ stdout: "opus,48000\n" });
|
||||
runFfprobeMock.mockResolvedValueOnce("opus,48000\n");
|
||||
|
||||
const result = await ensureOggOpus("/tmp/input.ogg");
|
||||
|
||||
expect(result).toEqual({ path: "/tmp/input.ogg", cleanup: false });
|
||||
expect(execCalls).toHaveLength(1);
|
||||
expect(execCalls[0].command).toBe("ffprobe");
|
||||
expect(execCalls[0].args).toContain("stream=codec_name,sample_rate");
|
||||
expect(execCalls[0].options?.timeout).toBe(10_000);
|
||||
expect(runFfprobeMock).toHaveBeenCalledWith(
|
||||
expect.arrayContaining(["-show_entries", "stream=codec_name,sample_rate", "/tmp/input.ogg"]),
|
||||
);
|
||||
expect(runFfmpegMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-encodes .ogg opus when sample rate is not 48kHz", async () => {
|
||||
mockExecResults.push({ stdout: "opus,24000\n" });
|
||||
mockExecResults.push({ stdout: "" });
|
||||
runFfprobeMock.mockResolvedValueOnce("opus,24000\n");
|
||||
runFfmpegMock.mockResolvedValueOnce();
|
||||
|
||||
const result = await ensureOggOpus("/tmp/input.ogg");
|
||||
const ffmpegCall = execCalls.find((call) => call.command === "ffmpeg");
|
||||
|
||||
expect(result.cleanup).toBe(true);
|
||||
expect(result.path).toMatch(/^\/tmp\/voice-.*\.ogg$/);
|
||||
expect(ffmpegCall).toBeDefined();
|
||||
expect(ffmpegCall?.args).toContain("-t");
|
||||
expect(ffmpegCall?.args).toContain("1200");
|
||||
expect(ffmpegCall?.args).toContain("-ar");
|
||||
expect(ffmpegCall?.args).toContain("48000");
|
||||
expect(ffmpegCall?.options?.timeout).toBe(45_000);
|
||||
expect(runFfmpegMock).toHaveBeenCalledWith(
|
||||
expect.arrayContaining(["-t", "1200", "-ar", "48000", "/tmp/input.ogg", result.path]),
|
||||
);
|
||||
});
|
||||
|
||||
it("re-encodes non-ogg input with bounded ffmpeg execution", async () => {
|
||||
mockExecResults.push({ stdout: "" });
|
||||
runFfmpegMock.mockResolvedValueOnce();
|
||||
|
||||
const result = await ensureOggOpus("/tmp/input.mp3");
|
||||
const ffprobeCalls = execCalls.filter((call) => call.command === "ffprobe");
|
||||
const ffmpegCalls = execCalls.filter((call) => call.command === "ffmpeg");
|
||||
|
||||
expect(result.cleanup).toBe(true);
|
||||
expect(ffprobeCalls).toHaveLength(0);
|
||||
expect(ffmpegCalls).toHaveLength(1);
|
||||
expect(ffmpegCalls[0].options?.timeout).toBe(45_000);
|
||||
expect(ffmpegCalls[0].args).toEqual(expect.arrayContaining(["-vn", "-sn", "-dn"]));
|
||||
expect(runFfprobeMock).not.toHaveBeenCalled();
|
||||
expect(runFfmpegMock).toHaveBeenCalledWith(
|
||||
expect.arrayContaining(["-vn", "-sn", "-dn", "/tmp/input.mp3", result.path]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const runDuckDuckGoSearch = vi.fn(async (params: Record<string, unknown>) => params);
|
||||
const { runDuckDuckGoSearch } = vi.hoisted(() => ({
|
||||
runDuckDuckGoSearch: vi.fn(async (params: Record<string, unknown>) => params),
|
||||
}));
|
||||
|
||||
vi.mock("./ddg-client.js", () => ({
|
||||
runDuckDuckGoSearch,
|
||||
}));
|
||||
|
||||
describe("duckduckgo web search provider", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
runDuckDuckGoSearch.mockReset();
|
||||
runDuckDuckGoSearch.mockImplementation(async (params: Record<string, unknown>) => params);
|
||||
});
|
||||
|
||||
it("exposes keyless metadata and enables the plugin in config", async () => {
|
||||
const { createDuckDuckGoWebSearchProvider } = await import("./ddg-search-provider.js");
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ const getChatMembersMock = vi.hoisted(() => vi.fn());
|
||||
const getFeishuMemberInfoMock = vi.hoisted(() => vi.fn());
|
||||
const listFeishuDirectoryPeersLiveMock = vi.hoisted(() => vi.fn());
|
||||
const listFeishuDirectoryGroupsLiveMock = vi.hoisted(() => vi.fn());
|
||||
const feishuOutboundSendMediaMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./probe.js", () => ({
|
||||
probeFeishu: probeFeishuMock,
|
||||
@@ -47,7 +48,7 @@ vi.mock("./channel.runtime.js", () => ({
|
||||
sendMessageFeishu: sendMessageFeishuMock,
|
||||
feishuOutbound: {
|
||||
sendText: vi.fn(),
|
||||
sendMedia: vi.fn(),
|
||||
sendMedia: feishuOutboundSendMediaMock,
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -204,6 +205,38 @@ describe("feishuPlugin actions", () => {
|
||||
expect(result?.details).toMatchObject({ ok: true, messageId: "om_card", chatId: "oc_group_1" });
|
||||
});
|
||||
|
||||
it("sends media through the outbound adapter", async () => {
|
||||
feishuOutboundSendMediaMock.mockResolvedValueOnce({
|
||||
channel: "feishu",
|
||||
messageId: "om_media",
|
||||
details: { messageId: "om_media", chatId: "oc_group_1" },
|
||||
});
|
||||
|
||||
const result = await feishuPlugin.actions?.handleAction?.({
|
||||
action: "send",
|
||||
params: {
|
||||
to: "chat:oc_group_1",
|
||||
message: "test",
|
||||
media: "/tmp/image.png",
|
||||
},
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
toolContext: {},
|
||||
mediaLocalRoots: ["/tmp"],
|
||||
} as never);
|
||||
|
||||
expect(feishuOutboundSendMediaMock).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
to: "chat:oc_group_1",
|
||||
text: "test",
|
||||
mediaUrl: "/tmp/image.png",
|
||||
accountId: undefined,
|
||||
mediaLocalRoots: ["/tmp"],
|
||||
replyToId: undefined,
|
||||
});
|
||||
expect(result?.details).toMatchObject({ messageId: "om_media" });
|
||||
});
|
||||
|
||||
it("reads messages", async () => {
|
||||
getMessageFeishuMock.mockResolvedValueOnce({
|
||||
messageId: "om_1",
|
||||
|
||||
@@ -51,6 +51,14 @@ import { feishuSetupWizard } from "./setup-surface.js";
|
||||
import { normalizeFeishuTarget, looksLikeFeishuId, formatFeishuTarget } from "./targets.js";
|
||||
import type { FeishuConfig, FeishuProbeResult, ResolvedFeishuAccount } from "./types.js";
|
||||
|
||||
function readFeishuMediaParam(params: Record<string, unknown>): string | undefined {
|
||||
const media = params.media;
|
||||
if (typeof media !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
return media.trim() ? media : undefined;
|
||||
}
|
||||
|
||||
const meta: ChannelMeta = {
|
||||
id: "feishu",
|
||||
label: "Feishu",
|
||||
@@ -502,27 +510,49 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
|
||||
? (ctx.params.card as Record<string, unknown>)
|
||||
: undefined;
|
||||
const text = readFirstString(ctx.params, ["text", "message"]);
|
||||
if (!card && !text) {
|
||||
throw new Error(`Feishu ${ctx.action} requires text/message or card.`);
|
||||
const mediaUrl = readFeishuMediaParam(ctx.params);
|
||||
if (card && mediaUrl) {
|
||||
throw new Error(`Feishu ${ctx.action} does not support card with media.`);
|
||||
}
|
||||
if (!card && !text && !mediaUrl) {
|
||||
throw new Error(`Feishu ${ctx.action} requires text/message, media, or card.`);
|
||||
}
|
||||
const runtime = await loadFeishuChannelRuntime();
|
||||
const result = card
|
||||
? await runtime.sendCardFeishu({
|
||||
cfg: ctx.cfg,
|
||||
to,
|
||||
card,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
replyToMessageId,
|
||||
replyInThread: ctx.action === "thread-reply",
|
||||
})
|
||||
: await runtime.sendMessageFeishu({
|
||||
cfg: ctx.cfg,
|
||||
to,
|
||||
text: text!,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
replyToMessageId,
|
||||
replyInThread: ctx.action === "thread-reply",
|
||||
});
|
||||
const maybeSendMedia = runtime.feishuOutbound.sendMedia;
|
||||
if (mediaUrl && !maybeSendMedia) {
|
||||
throw new Error("Feishu media sending is not available.");
|
||||
}
|
||||
const sendMedia = maybeSendMedia;
|
||||
let result;
|
||||
if (card) {
|
||||
result = await runtime.sendCardFeishu({
|
||||
cfg: ctx.cfg,
|
||||
to,
|
||||
card,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
replyToMessageId,
|
||||
replyInThread: ctx.action === "thread-reply",
|
||||
});
|
||||
} else if (mediaUrl) {
|
||||
result = await sendMedia!({
|
||||
cfg: ctx.cfg,
|
||||
to,
|
||||
text: text ?? "",
|
||||
mediaUrl,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
mediaLocalRoots: ctx.mediaLocalRoots,
|
||||
replyToId: replyToMessageId,
|
||||
});
|
||||
} else {
|
||||
result = await runtime.sendMessageFeishu({
|
||||
cfg: ctx.cfg,
|
||||
to,
|
||||
text: text!,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
replyToMessageId,
|
||||
replyInThread: ctx.action === "thread-reply",
|
||||
});
|
||||
}
|
||||
return jsonActionResult({
|
||||
ok: true,
|
||||
channel: "feishu",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const runFirecrawlScrape = vi.fn(async (params: Record<string, unknown>) => ({
|
||||
ok: true,
|
||||
params,
|
||||
const { runFirecrawlScrape } = vi.hoisted(() => ({
|
||||
runFirecrawlScrape: vi.fn(async (params: Record<string, unknown>) => ({
|
||||
ok: true,
|
||||
params,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("./firecrawl-client.js", () => ({
|
||||
@@ -10,6 +12,15 @@ vi.mock("./firecrawl-client.js", () => ({
|
||||
}));
|
||||
|
||||
describe("firecrawl scrape tool", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
runFirecrawlScrape.mockReset();
|
||||
runFirecrawlScrape.mockImplementation(async (params: Record<string, unknown>) => ({
|
||||
ok: true,
|
||||
params,
|
||||
}));
|
||||
});
|
||||
|
||||
it("maps scrape params and defaults extract mode to markdown", async () => {
|
||||
const { createFirecrawlScrapeTool } = await import("./firecrawl-scrape-tool.js");
|
||||
const tool = createFirecrawlScrapeTool({
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const runFirecrawlSearch = vi.fn(async (params: Record<string, unknown>) => params);
|
||||
const { runFirecrawlSearch } = vi.hoisted(() => ({
|
||||
runFirecrawlSearch: vi.fn(async (params: Record<string, unknown>) => params),
|
||||
}));
|
||||
|
||||
vi.mock("./firecrawl-client.js", () => ({
|
||||
runFirecrawlSearch,
|
||||
}));
|
||||
|
||||
describe("firecrawl web search provider", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
runFirecrawlSearch.mockReset();
|
||||
runFirecrawlSearch.mockImplementation(async (params: Record<string, unknown>) => params);
|
||||
});
|
||||
|
||||
it("exposes selection metadata and enables the plugin in config", async () => {
|
||||
const { createFirecrawlWebSearchProvider } = await import("./firecrawl-search-provider.js");
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const runFirecrawlSearch = vi.fn(async (params: Record<string, unknown>) => ({
|
||||
ok: true,
|
||||
params,
|
||||
const { runFirecrawlSearch } = vi.hoisted(() => ({
|
||||
runFirecrawlSearch: vi.fn(async (params: Record<string, unknown>) => ({
|
||||
ok: true,
|
||||
params,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("./firecrawl-client.js", () => ({
|
||||
@@ -10,6 +12,15 @@ vi.mock("./firecrawl-client.js", () => ({
|
||||
}));
|
||||
|
||||
describe("firecrawl search tool", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
runFirecrawlSearch.mockReset();
|
||||
runFirecrawlSearch.mockImplementation(async (params: Record<string, unknown>) => ({
|
||||
ok: true,
|
||||
params,
|
||||
}));
|
||||
});
|
||||
|
||||
it("normalizes optional search parameters before invoking Firecrawl", async () => {
|
||||
const { createFirecrawlSearchTool } = await import("./firecrawl-search-tool.js");
|
||||
const tool = createFirecrawlSearchTool({
|
||||
|
||||
@@ -14,32 +14,43 @@ vi.mock("../send.js", () => ({
|
||||
sendMessageIMessageMock(to, message, opts),
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/auto-reply/chunk.js", () => ({
|
||||
chunkTextWithMode: (text: string) => chunkTextWithModeMock(text),
|
||||
resolveChunkMode: () => resolveChunkModeMock(),
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => ({}),
|
||||
resolveMarkdownTableMode: () => resolveMarkdownTableModeMock(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../../src/config/config.js", () => ({
|
||||
loadConfig: () => ({}),
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
chunkTextWithMode: (text: string) => chunkTextWithModeMock(text),
|
||||
resolveChunkMode: () => resolveChunkModeMock(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../../src/config/markdown-tables.js", () => ({
|
||||
resolveMarkdownTableMode: () => resolveMarkdownTableModeMock(),
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/text-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/text-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
convertMarkdownTables: (text: string) => convertMarkdownTablesMock(text),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../../src/markdown/tables.js", () => ({
|
||||
convertMarkdownTables: (text: string) => convertMarkdownTablesMock(text),
|
||||
}));
|
||||
|
||||
import { deliverReplies } from "./deliver.js";
|
||||
let deliverReplies: typeof import("./deliver.js").deliverReplies;
|
||||
|
||||
describe("deliverReplies", () => {
|
||||
const runtime = { log: vi.fn(), error: vi.fn() } as unknown as RuntimeEnv;
|
||||
const client = {} as Awaited<ReturnType<typeof import("../client.js").createIMessageRpcClient>>;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
chunkTextWithModeMock.mockImplementation((text: string) => [text]);
|
||||
({ deliverReplies } = await import("./deliver.js"));
|
||||
});
|
||||
|
||||
it("propagates payload replyToId through all text chunks", async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { MessageEvent, PostbackEvent } from "@line/bot-sdk";
|
||||
import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { LineAccountConfig } from "./types.js";
|
||||
|
||||
// Avoid pulling in globals/pairing/media dependencies; this suite only asserts
|
||||
@@ -74,6 +74,16 @@ type LineWebhookContext = Parameters<typeof import("./bot-handlers.js").handleLi
|
||||
|
||||
const createRuntime = () => ({ log: vi.fn(), error: vi.fn(), exit: vi.fn() });
|
||||
|
||||
function buildDefaultLineMessageContext() {
|
||||
return {
|
||||
ctxPayload: { From: "line:group:group-1" },
|
||||
replyToken: "reply-token",
|
||||
route: { agentId: "default" },
|
||||
isGroup: true,
|
||||
accountId: "default",
|
||||
};
|
||||
}
|
||||
|
||||
function createReplayMessageEvent(params: {
|
||||
messageId: string;
|
||||
groupId: string;
|
||||
@@ -199,17 +209,24 @@ async function startInflightReplayDuplicate(params: {
|
||||
}
|
||||
|
||||
describe("handleLineWebhookEvents", () => {
|
||||
beforeAll(async () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
buildLineMessageContextMock.mockReset();
|
||||
buildLineMessageContextMock.mockImplementation(async () => ({
|
||||
ctxPayload: { From: "line:group:group-1" },
|
||||
replyToken: "reply-token",
|
||||
route: { agentId: "default" },
|
||||
isGroup: true,
|
||||
accountId: "default",
|
||||
}));
|
||||
buildLinePostbackContextMock.mockReset();
|
||||
buildLinePostbackContextMock.mockImplementation(async () => null as unknown);
|
||||
readAllowFromStoreMock.mockReset();
|
||||
readAllowFromStoreMock.mockImplementation(async () => [] as string[]);
|
||||
upsertPairingRequestMock.mockReset();
|
||||
upsertPairingRequestMock.mockImplementation(async () => ({ code: "CODE", created: true }));
|
||||
({ handleLineWebhookEvents, createLineWebhookReplayCache } = await import("./bot-handlers.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
buildLineMessageContextMock.mockClear();
|
||||
buildLinePostbackContextMock.mockClear();
|
||||
readAllowFromStoreMock.mockClear();
|
||||
upsertPairingRequestMock.mockClear();
|
||||
});
|
||||
|
||||
it("blocks group messages when groupPolicy is disabled", async () => {
|
||||
const processMessage = vi.fn();
|
||||
const event = {
|
||||
@@ -573,10 +590,11 @@ describe("handleLineWebhookEvents", () => {
|
||||
isRedelivery: true,
|
||||
});
|
||||
const { firstRun, secondRun } = await startInflightReplayDuplicate({ event, processMessage });
|
||||
const firstFailure = expect(firstRun).rejects.toThrow("transient inflight failure");
|
||||
const secondFailure = expect(secondRun).rejects.toThrow("transient inflight failure");
|
||||
rejectFirst?.(new Error("transient inflight failure"));
|
||||
|
||||
await expect(firstRun).rejects.toThrow("transient inflight failure");
|
||||
await expect(secondRun).rejects.toThrow("transient inflight failure");
|
||||
await Promise.all([firstFailure, secondFailure]);
|
||||
expect(processMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
|
||||
logVerbose: () => {},
|
||||
}));
|
||||
|
||||
import { downloadLineMedia } from "./download.js";
|
||||
let downloadLineMedia: typeof import("./download.js").downloadLineMedia;
|
||||
|
||||
async function* chunks(parts: Buffer[]): AsyncGenerator<Buffer> {
|
||||
for (const part of parts) {
|
||||
@@ -38,8 +38,11 @@ async function* chunks(parts: Buffer[]): AsyncGenerator<Buffer> {
|
||||
}
|
||||
|
||||
describe("downloadLineMedia", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
beforeEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
getMessageContentMock.mockReset();
|
||||
({ downloadLineMedia } = await import("./download.js"));
|
||||
});
|
||||
|
||||
it("does not derive temp file path from external messageId", async () => {
|
||||
|
||||
@@ -11,6 +11,8 @@ const { createLineBotMock, registerPluginHttpRouteMock, unregisterHttpMock } = v
|
||||
unregisterHttpMock: vi.fn(),
|
||||
}));
|
||||
|
||||
let monitorLineProvider: typeof import("./monitor.js").monitorLineProvider;
|
||||
|
||||
vi.mock("./bot.js", () => ({
|
||||
createLineBot: createLineBotMock,
|
||||
}));
|
||||
@@ -74,14 +76,19 @@ vi.mock("./template-messages.js", () => ({
|
||||
}));
|
||||
|
||||
describe("monitorLineProvider lifecycle", () => {
|
||||
beforeEach(() => {
|
||||
createLineBotMock.mockClear();
|
||||
unregisterHttpMock.mockClear();
|
||||
registerPluginHttpRouteMock.mockClear().mockReturnValue(unregisterHttpMock);
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
createLineBotMock.mockReset();
|
||||
createLineBotMock.mockReturnValue({
|
||||
account: { accountId: "default" },
|
||||
handleWebhook: vi.fn(),
|
||||
});
|
||||
unregisterHttpMock.mockReset();
|
||||
registerPluginHttpRouteMock.mockReset().mockReturnValue(unregisterHttpMock);
|
||||
({ monitorLineProvider } = await import("./monitor.js"));
|
||||
});
|
||||
|
||||
it("waits for abort before resolving", async () => {
|
||||
const { monitorLineProvider } = await import("./monitor.js");
|
||||
const abort = new AbortController();
|
||||
let resolved = false;
|
||||
|
||||
@@ -108,7 +115,6 @@ describe("monitorLineProvider lifecycle", () => {
|
||||
});
|
||||
|
||||
it("stops immediately when signal is already aborted", async () => {
|
||||
const { monitorLineProvider } = await import("./monitor.js");
|
||||
const abort = new AbortController();
|
||||
abort.abort();
|
||||
|
||||
@@ -124,8 +130,6 @@ describe("monitorLineProvider lifecycle", () => {
|
||||
});
|
||||
|
||||
it("returns immediately without abort signal and stop is idempotent", async () => {
|
||||
const { monitorLineProvider } = await import("./monitor.js");
|
||||
|
||||
const monitor = await monitorLineProvider({
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret", // pragma: allowlist secret
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const { getBotInfoMock, MessagingApiClientMock } = vi.hoisted(() => {
|
||||
const getBotInfoMock = vi.fn();
|
||||
const MessagingApiClientMock = vi.fn(function () {
|
||||
@@ -19,7 +19,13 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("probeLineBot", () => {
|
||||
beforeAll(async () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
getBotInfoMock.mockReset();
|
||||
MessagingApiClientMock.mockReset();
|
||||
MessagingApiClientMock.mockImplementation(function () {
|
||||
return { getBotInfo: getBotInfoMock };
|
||||
});
|
||||
({ probeLineBot } = await import("./probe.js"));
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
pushMessageMock,
|
||||
@@ -74,28 +74,34 @@ vi.mock("openclaw/plugin-sdk/runtime-env", async (importOriginal) => {
|
||||
let sendModule: typeof import("./send.js");
|
||||
|
||||
describe("LINE send helpers", () => {
|
||||
beforeAll(async () => {
|
||||
sendModule = await import("./send.js");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
pushMessageMock.mockReset();
|
||||
replyMessageMock.mockReset();
|
||||
showLoadingAnimationMock.mockReset();
|
||||
getProfileMock.mockReset();
|
||||
MessagingApiClientMock.mockClear();
|
||||
MessagingApiClientMock.mockReset();
|
||||
loadConfigMock.mockReset();
|
||||
resolveLineAccountMock.mockReset();
|
||||
resolveLineChannelAccessTokenMock.mockReset();
|
||||
recordChannelActivityMock.mockReset();
|
||||
logVerboseMock.mockReset();
|
||||
|
||||
MessagingApiClientMock.mockImplementation(function () {
|
||||
return {
|
||||
pushMessage: pushMessageMock,
|
||||
replyMessage: replyMessageMock,
|
||||
showLoadingAnimation: showLoadingAnimationMock,
|
||||
getProfile: getProfileMock,
|
||||
};
|
||||
});
|
||||
loadConfigMock.mockReturnValue({});
|
||||
resolveLineAccountMock.mockReturnValue({ accountId: "default" });
|
||||
resolveLineChannelAccessTokenMock.mockReturnValue("line-token");
|
||||
pushMessageMock.mockResolvedValue({});
|
||||
replyMessageMock.mockResolvedValue({});
|
||||
showLoadingAnimationMock.mockResolvedValue({});
|
||||
sendModule = await import("./send.js");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -35,6 +35,26 @@ describe("matrix plugin registration", () => {
|
||||
});
|
||||
}, 240_000);
|
||||
|
||||
it("loads the matrix src runtime api through Jiti without duplicate export errors", () => {
|
||||
const runtimeApiPath = path.join(
|
||||
process.cwd(),
|
||||
"extensions",
|
||||
"matrix",
|
||||
"src",
|
||||
"runtime-api.ts",
|
||||
);
|
||||
const jiti = createJiti(import.meta.url, {
|
||||
...buildPluginLoaderJitiOptions(
|
||||
resolvePluginSdkScopedAliasMap({ modulePath: runtimeApiPath }),
|
||||
),
|
||||
tryNative: false,
|
||||
});
|
||||
|
||||
expect(jiti(runtimeApiPath)).toMatchObject({
|
||||
resolveMatrixAccountStringValues: expect.any(Function),
|
||||
});
|
||||
}, 240_000);
|
||||
|
||||
it("registers the channel without bootstrapping crypto runtime", () => {
|
||||
const runtime = {} as never;
|
||||
matrixPlugin.register({
|
||||
|
||||
@@ -14,6 +14,5 @@ export {
|
||||
maybeCreateMatrixMigrationSnapshot,
|
||||
resolveConfiguredAcpBindingRecord,
|
||||
} from "openclaw/plugin-sdk/matrix-runtime-heavy";
|
||||
// Keep auth-precedence available internally without re-exporting helper-api
|
||||
// twice through both plugin-sdk/matrix and ../runtime-api.js.
|
||||
export * from "./auth-precedence.js";
|
||||
// resolveMatrixAccountStringValues already comes from plugin-sdk/matrix.
|
||||
// Re-exporting auth-precedence here makes Jiti try to define the same export twice.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"description": "OpenClaw Mattermost channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "0.34.48",
|
||||
"ws": "^8.20.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { ensureGlobalUndiciEnvProxyDispatcher } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import { generatePkceVerifierChallenge, toFormUrlEncoded } from "openclaw/plugin-sdk/provider-auth";
|
||||
|
||||
export type MiniMaxRegion = "cn" | "global";
|
||||
@@ -184,6 +185,9 @@ export async function loginMiniMaxPortalOAuth(params: {
|
||||
progress: { update: (message: string) => void; stop: (message?: string) => void };
|
||||
region?: MiniMaxRegion;
|
||||
}): Promise<MiniMaxOAuthToken> {
|
||||
// Ensure env-based proxy dispatcher is active before any outbound fetch calls.
|
||||
// Without this, HTTP_PROXY/HTTPS_PROXY env vars are silently ignored (#51619).
|
||||
ensureGlobalUndiciEnvProxyDispatcher();
|
||||
const region = params.region ?? "global";
|
||||
const { verifier, challenge, state } = generatePkce();
|
||||
const oauth = await requestOAuthCode({ challenge, state, region });
|
||||
|
||||
@@ -39,13 +39,13 @@ describe("mistral model definitions", () => {
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
contextWindow: 128000,
|
||||
maxTokens: 128000,
|
||||
maxTokens: 40000,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "pixtral-large-latest",
|
||||
input: ["text", "image"],
|
||||
contextWindow: 128000,
|
||||
maxTokens: 128000,
|
||||
maxTokens: 32768,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ export const MISTRAL_BASE_URL = "https://api.mistral.ai/v1";
|
||||
export const MISTRAL_DEFAULT_MODEL_ID = "mistral-large-latest";
|
||||
export const MISTRAL_DEFAULT_MODEL_REF = `mistral/${MISTRAL_DEFAULT_MODEL_ID}`;
|
||||
export const MISTRAL_DEFAULT_CONTEXT_WINDOW = 262144;
|
||||
export const MISTRAL_DEFAULT_MAX_TOKENS = 262144;
|
||||
export const MISTRAL_DEFAULT_MAX_TOKENS = 16384;
|
||||
export const MISTRAL_DEFAULT_COST = {
|
||||
input: 0.5,
|
||||
output: 1.5,
|
||||
@@ -29,7 +29,7 @@ const MISTRAL_MODEL_CATALOG = [
|
||||
input: ["text"],
|
||||
cost: { input: 0.4, output: 2, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 262144,
|
||||
maxTokens: 262144,
|
||||
maxTokens: 32768,
|
||||
},
|
||||
{
|
||||
id: "magistral-small",
|
||||
@@ -38,7 +38,7 @@ const MISTRAL_MODEL_CATALOG = [
|
||||
input: ["text"],
|
||||
cost: { input: 0.5, output: 1.5, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 128000,
|
||||
maxTokens: 40000,
|
||||
},
|
||||
{
|
||||
id: "mistral-large-latest",
|
||||
@@ -56,7 +56,7 @@ const MISTRAL_MODEL_CATALOG = [
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0.4, output: 2, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 262144,
|
||||
maxTokens: 262144,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
{
|
||||
id: "mistral-small-latest",
|
||||
@@ -74,7 +74,7 @@ const MISTRAL_MODEL_CATALOG = [
|
||||
input: ["text", "image"],
|
||||
cost: { input: 2, output: 6, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 128000,
|
||||
maxTokens: 32768,
|
||||
},
|
||||
] as const satisfies readonly ModelDefinitionConfig[];
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MSTeamsConfig } from "../runtime-api.js";
|
||||
|
||||
const hostMockState = vi.hoisted(() => ({
|
||||
@@ -20,6 +20,17 @@ vi.mock("@microsoft/agents-hosting", () => ({
|
||||
import { probeMSTeams } from "./probe.js";
|
||||
|
||||
describe("msteams probe", () => {
|
||||
beforeEach(() => {
|
||||
hostMockState.tokenError = null;
|
||||
vi.stubEnv("MSTEAMS_APP_ID", "");
|
||||
vi.stubEnv("MSTEAMS_APP_PASSWORD", "");
|
||||
vi.stubEnv("MSTEAMS_TENANT_ID", "");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("returns an error when credentials are missing", async () => {
|
||||
const cfg = { enabled: true } as unknown as MSTeamsConfig;
|
||||
await expect(probeMSTeams(cfg)).resolves.toMatchObject({
|
||||
@@ -28,7 +39,6 @@ describe("msteams probe", () => {
|
||||
});
|
||||
|
||||
it("validates credentials by acquiring a token", async () => {
|
||||
hostMockState.tokenError = null;
|
||||
const cfg = {
|
||||
enabled: true,
|
||||
appId: "app",
|
||||
|
||||
@@ -3,19 +3,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const fetchWithTimeoutMock = vi.fn();
|
||||
const resolveFetchMock = vi.fn();
|
||||
|
||||
vi.mock("../../../src/infra/fetch.js", () => ({
|
||||
vi.mock("openclaw/plugin-sdk/infra-runtime", () => ({
|
||||
resolveFetch: (...args: unknown[]) => resolveFetchMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../../src/infra/secure-random.js", () => ({
|
||||
generateSecureUuid: () => "test-id",
|
||||
}));
|
||||
|
||||
vi.mock("../../../src/utils/fetch-timeout.js", () => ({
|
||||
vi.mock("openclaw/plugin-sdk/text-runtime", () => ({
|
||||
fetchWithTimeout: (...args: unknown[]) => fetchWithTimeoutMock(...args),
|
||||
}));
|
||||
|
||||
import { signalRpcRequest } from "./client.js";
|
||||
let signalRpcRequest: typeof import("./client.js").signalRpcRequest;
|
||||
|
||||
function rpcResponse(body: unknown, status = 200): Response {
|
||||
if (typeof body === "string") {
|
||||
@@ -25,9 +22,11 @@ function rpcResponse(body: unknown, status = 200): Response {
|
||||
}
|
||||
|
||||
describe("signalRpcRequest", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
resolveFetchMock.mockReturnValue(vi.fn());
|
||||
({ signalRpcRequest } = await import("./client.js"));
|
||||
});
|
||||
|
||||
it("returns parsed RPC result", async () => {
|
||||
|
||||
+7
-4
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
config,
|
||||
flush,
|
||||
@@ -8,9 +8,7 @@ import {
|
||||
} from "./monitor.tool-result.test-harness.js";
|
||||
|
||||
installSignalToolResultTestHooks();
|
||||
|
||||
// Import after the harness registers `vi.mock(...)` for Signal internals.
|
||||
const { monitorSignalProvider } = await import("./monitor.js");
|
||||
let monitorSignalProvider: typeof import("./monitor.js").monitorSignalProvider;
|
||||
|
||||
const { replyMock, sendMock, streamMock, upsertPairingRequestMock } =
|
||||
getSignalToolResultTestMocks();
|
||||
@@ -21,6 +19,11 @@ async function runMonitorWithMocks(opts: MonitorSignalProviderOptions) {
|
||||
return monitorSignalProvider(opts);
|
||||
}
|
||||
describe("monitorSignalProvider tool results", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ monitorSignalProvider } = await import("./monitor.js"));
|
||||
});
|
||||
|
||||
it("pairs uuid-only senders with a uuid allowlist entry", async () => {
|
||||
const baseChannels = (config.channels ?? {}) as Record<string, unknown>;
|
||||
const baseSignal = (baseChannels.signal ?? {}) as Record<string, unknown>;
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MsgContext } from "../../../../src/auto-reply/templating.js";
|
||||
import { expectChannelInboundContextContract as expectInboundContextContract } from "../../../../src/channels/plugins/contracts/suites.js";
|
||||
import { createSignalEventHandler } from "./event-handler.js";
|
||||
import {
|
||||
createBaseSignalEventHandlerDeps,
|
||||
createSignalReceiveEvent,
|
||||
} from "./event-handler.test-harness.js";
|
||||
let expectInboundContextContract: typeof import("../../../../src/channels/plugins/contracts/suites.js").expectChannelInboundContextContract;
|
||||
let createBaseSignalEventHandlerDeps: typeof import("./event-handler.test-harness.js").createBaseSignalEventHandlerDeps;
|
||||
let createSignalReceiveEvent: typeof import("./event-handler.test-harness.js").createSignalReceiveEvent;
|
||||
|
||||
const { sendTypingMock, sendReadReceiptMock, dispatchInboundMessageMock, capture } = vi.hoisted(
|
||||
() => {
|
||||
@@ -49,8 +46,17 @@ vi.mock("../../../../src/pairing/pairing-store.js", () => ({
|
||||
upsertChannelPairingRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
let createSignalEventHandler: typeof import("./event-handler.js").createSignalEventHandler;
|
||||
|
||||
describe("signal createSignalEventHandler inbound context", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.useRealTimers();
|
||||
vi.resetModules();
|
||||
({ expectChannelInboundContextContract: expectInboundContextContract } =
|
||||
await import("../../../../src/channels/plugins/contracts/suites.js"));
|
||||
({ createBaseSignalEventHandlerDeps, createSignalReceiveEvent } =
|
||||
await import("./event-handler.test-harness.js"));
|
||||
({ createSignalEventHandler } = await import("./event-handler.js"));
|
||||
capture.ctx = undefined;
|
||||
sendTypingMock.mockReset().mockResolvedValue(true);
|
||||
sendReadReceiptMock.mockReset().mockResolvedValue(true);
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { MsgContext } from "../../../../src/auto-reply/templating.js";
|
||||
import { buildDispatchInboundCaptureMock } from "../../../../src/channels/plugins/contracts/inbound-testkit.js";
|
||||
import type { OpenClawConfig } from "../../../../src/config/types.js";
|
||||
import {
|
||||
createBaseSignalEventHandlerDeps,
|
||||
createSignalReceiveEvent,
|
||||
} from "./event-handler.test-harness.js";
|
||||
|
||||
type SignalMsgContext = Pick<MsgContext, "Body" | "WasMentioned"> & {
|
||||
Body?: string;
|
||||
@@ -18,15 +14,17 @@ function getCapturedCtx() {
|
||||
return capturedCtx as SignalMsgContext;
|
||||
}
|
||||
|
||||
vi.mock("../../../../src/auto-reply/dispatch.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../../src/auto-reply/dispatch.js")>();
|
||||
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
|
||||
return buildDispatchInboundCaptureMock(actual, (ctx) => {
|
||||
capturedCtx = ctx as SignalMsgContext;
|
||||
});
|
||||
});
|
||||
|
||||
import { createSignalEventHandler } from "./event-handler.js";
|
||||
import { renderSignalMentions } from "./mentions.js";
|
||||
let createBaseSignalEventHandlerDeps: typeof import("./event-handler.test-harness.js").createBaseSignalEventHandlerDeps;
|
||||
let createSignalReceiveEvent: typeof import("./event-handler.test-harness.js").createSignalReceiveEvent;
|
||||
let createSignalEventHandler: typeof import("./event-handler.js").createSignalEventHandler;
|
||||
let renderSignalMentions: typeof import("./mentions.js").renderSignalMentions;
|
||||
|
||||
type GroupEventOpts = {
|
||||
message?: string;
|
||||
@@ -102,8 +100,16 @@ async function expectSkippedGroupHistory(opts: GroupEventOpts, expectedBody: str
|
||||
}
|
||||
|
||||
describe("signal mention gating", () => {
|
||||
it("drops group messages without mention when requireMention is configured", async () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
capturedCtx = undefined;
|
||||
({ createBaseSignalEventHandlerDeps, createSignalReceiveEvent } =
|
||||
await import("./event-handler.test-harness.js"));
|
||||
({ createSignalEventHandler } = await import("./event-handler.js"));
|
||||
({ renderSignalMentions } = await import("./mentions.js"));
|
||||
});
|
||||
|
||||
it("drops group messages without mention when requireMention is configured", async () => {
|
||||
const handler = createMentionHandler({ requireMention: true });
|
||||
|
||||
await handler(makeGroupEvent({ message: "hello everyone" }));
|
||||
@@ -111,7 +117,6 @@ describe("signal mention gating", () => {
|
||||
});
|
||||
|
||||
it("allows group messages with mention when requireMention is configured", async () => {
|
||||
capturedCtx = undefined;
|
||||
const handler = createMentionHandler({ requireMention: true });
|
||||
|
||||
await handler(makeGroupEvent({ message: "hey @bot what's up" }));
|
||||
@@ -120,7 +125,6 @@ describe("signal mention gating", () => {
|
||||
});
|
||||
|
||||
it("sets WasMentioned=false for group messages without mention when requireMention is off", async () => {
|
||||
capturedCtx = undefined;
|
||||
const handler = createMentionHandler({ requireMention: false });
|
||||
|
||||
await handler(makeGroupEvent({ message: "hello everyone" }));
|
||||
@@ -129,7 +133,6 @@ describe("signal mention gating", () => {
|
||||
});
|
||||
|
||||
it("records pending history for skipped group messages", async () => {
|
||||
capturedCtx = undefined;
|
||||
const { handler, groupHistories } = createMentionGatedHistoryHandler();
|
||||
await handler(makeGroupEvent({ message: "hello from alice" }));
|
||||
expect(capturedCtx).toBeUndefined();
|
||||
@@ -147,7 +150,6 @@ describe("signal mention gating", () => {
|
||||
});
|
||||
|
||||
it("normalizes mixed-case parameterized attachment MIME in skipped pending history", async () => {
|
||||
capturedCtx = undefined;
|
||||
const groupHistories = new Map();
|
||||
const handler = createSignalEventHandler(
|
||||
createBaseSignalEventHandlerDeps({
|
||||
@@ -172,7 +174,6 @@ describe("signal mention gating", () => {
|
||||
});
|
||||
|
||||
it("summarizes multiple skipped attachments with stable file count wording", async () => {
|
||||
capturedCtx = undefined;
|
||||
const groupHistories = new Map();
|
||||
const handler = createSignalEventHandler(
|
||||
createBaseSignalEventHandlerDeps({
|
||||
@@ -204,7 +205,6 @@ describe("signal mention gating", () => {
|
||||
});
|
||||
|
||||
it("bypasses mention gating for authorized control commands", async () => {
|
||||
capturedCtx = undefined;
|
||||
const handler = createMentionHandler({ requireMention: true });
|
||||
|
||||
await handler(makeGroupEvent({ message: "/help" }));
|
||||
@@ -212,7 +212,6 @@ describe("signal mention gating", () => {
|
||||
});
|
||||
|
||||
it("hydrates mention placeholders before trimming so offsets stay aligned", async () => {
|
||||
capturedCtx = undefined;
|
||||
const handler = createMentionHandler({ requireMention: false });
|
||||
|
||||
const placeholder = "\uFFFC";
|
||||
@@ -237,7 +236,6 @@ describe("signal mention gating", () => {
|
||||
});
|
||||
|
||||
it("counts mention metadata replacements toward requireMention gating", async () => {
|
||||
capturedCtx = undefined;
|
||||
const handler = createMentionHandler({
|
||||
requireMention: true,
|
||||
mentionPattern: "@123e4567",
|
||||
|
||||
@@ -7,7 +7,7 @@ vi.mock("./monitor/media.js", () => ({
|
||||
resolveSlackMedia: (...args: Parameters<typeof resolveSlackMedia>) => resolveSlackMedia(...args),
|
||||
}));
|
||||
|
||||
const { downloadSlackFile } = await import("./actions.js");
|
||||
let downloadSlackFile: typeof import("./actions.js").downloadSlackFile;
|
||||
|
||||
function createClient() {
|
||||
return {
|
||||
@@ -68,8 +68,10 @@ function mockSuccessfulMediaDownload(client: ReturnType<typeof createClient>) {
|
||||
}
|
||||
|
||||
describe("downloadSlackFile", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
resolveSlackMedia.mockReset();
|
||||
({ downloadSlackFile } = await import("./actions.js"));
|
||||
});
|
||||
|
||||
it("returns null when files.info has no private download URL", async () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import {
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelMessageToolDiscovery,
|
||||
@@ -40,7 +41,7 @@ export function createSlackActions(
|
||||
schema: actions.includes("send")
|
||||
? {
|
||||
properties: {
|
||||
blocks: createSlackMessageToolBlocksSchema(),
|
||||
blocks: Type.Optional(createSlackMessageToolBlocksSchema()),
|
||||
},
|
||||
}
|
||||
: null,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
|
||||
import { slackOutbound } from "./outbound-adapter.js";
|
||||
@@ -96,6 +97,25 @@ describe("slackPlugin actions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps blocks optional in the message tool schema", () => {
|
||||
const discovery = slackPlugin.actions?.describeMessageTool({
|
||||
cfg: {
|
||||
channels: {
|
||||
slack: {
|
||||
botToken: "xoxb-test",
|
||||
appToken: "xapp-test",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
});
|
||||
const schema = discovery?.schema;
|
||||
if (!schema || Array.isArray(schema)) {
|
||||
throw new Error("expected slack message-tool schema");
|
||||
}
|
||||
|
||||
expect(Type.Object(schema.properties).required).toBeUndefined();
|
||||
});
|
||||
|
||||
it("forwards read threadId to Slack action handler", async () => {
|
||||
handleSlackActionMock.mockResolvedValueOnce({ messages: [], hasMore: false });
|
||||
const handleAction = requireSlackHandleAction();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@slack/web-api", () => {
|
||||
const WebClient = vi.fn(function WebClientMock(
|
||||
@@ -12,11 +12,18 @@ vi.mock("@slack/web-api", () => {
|
||||
return { WebClient };
|
||||
});
|
||||
|
||||
const slackWebApi = await import("@slack/web-api");
|
||||
const { createSlackWebClient, resolveSlackWebClientOptions, SLACK_DEFAULT_RETRY_OPTIONS } =
|
||||
await import("./client.js");
|
||||
let createSlackWebClient: typeof import("./client.js").createSlackWebClient;
|
||||
let resolveSlackWebClientOptions: typeof import("./client.js").resolveSlackWebClientOptions;
|
||||
let SLACK_DEFAULT_RETRY_OPTIONS: typeof import("./client.js").SLACK_DEFAULT_RETRY_OPTIONS;
|
||||
let WebClient: ReturnType<typeof vi.fn>;
|
||||
|
||||
const WebClient = slackWebApi.WebClient as unknown as ReturnType<typeof vi.fn>;
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
const slackWebApi = await import("@slack/web-api");
|
||||
({ createSlackWebClient, resolveSlackWebClientOptions, SLACK_DEFAULT_RETRY_OPTIONS } =
|
||||
await import("./client.js"));
|
||||
WebClient = slackWebApi.WebClient as unknown as ReturnType<typeof vi.fn>;
|
||||
});
|
||||
|
||||
describe("slack web client config", () => {
|
||||
it("applies the default retry config when none is provided", () => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
stopSlackMonitor,
|
||||
} from "./monitor.test-helpers.js";
|
||||
|
||||
const { monitorSlackProvider } = await import("./monitor.js");
|
||||
let monitorSlackProvider: typeof import("./monitor.js").monitorSlackProvider;
|
||||
|
||||
const slackTestState = getSlackTestState();
|
||||
|
||||
@@ -69,6 +69,12 @@ async function runMissingThreadScenario(params: {
|
||||
|
||||
beforeEach(() => {
|
||||
resetInboundDedupe();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ monitorSlackProvider } = await import("./monitor.js"));
|
||||
resetInboundDedupe();
|
||||
resetSlackTestState({
|
||||
messages: { responsePrefix: "PFX" },
|
||||
channels: {
|
||||
|
||||
@@ -12,15 +12,23 @@ import {
|
||||
stopSlackMonitor,
|
||||
} from "./monitor.test-helpers.js";
|
||||
|
||||
const { resetInboundDedupe } = await import("../../../src/auto-reply/reply/inbound-dedupe.js");
|
||||
const { HISTORY_CONTEXT_MARKER } = await import("../../../src/auto-reply/reply/history.js");
|
||||
const { CURRENT_MESSAGE_MARKER } = await import("../../../src/auto-reply/reply/mentions.js");
|
||||
const { monitorSlackProvider } = await import("./monitor.js");
|
||||
let resetInboundDedupe: typeof import("../../../src/auto-reply/reply/inbound-dedupe.js").resetInboundDedupe;
|
||||
let HISTORY_CONTEXT_MARKER: typeof import("../../../src/auto-reply/reply/history.js").HISTORY_CONTEXT_MARKER;
|
||||
let CURRENT_MESSAGE_MARKER: typeof import("../../../src/auto-reply/reply/mentions.js").CURRENT_MESSAGE_MARKER;
|
||||
let monitorSlackProvider: typeof import("./monitor.js").monitorSlackProvider;
|
||||
|
||||
const slackTestState = getSlackTestState();
|
||||
const { sendMock, replyMock, reactMock, upsertPairingRequestMock } = slackTestState;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
({ resetInboundDedupe } = await import("../../../src/auto-reply/reply/inbound-dedupe.js"));
|
||||
({ HISTORY_CONTEXT_MARKER } = await import("../../../src/auto-reply/reply/history.js"));
|
||||
({ CURRENT_MESSAGE_MARKER } = await import("../../../src/auto-reply/reply/mentions.js"));
|
||||
({ monitorSlackProvider } = await import("./monitor.js"));
|
||||
resetInboundDedupe();
|
||||
resetSlackTestState(defaultSlackTestConfig());
|
||||
});
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SlackMonitorContext } from "./context.js";
|
||||
|
||||
const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn());
|
||||
const readStoreAllowFromForDmPolicyMock = vi.hoisted(() => vi.fn());
|
||||
let clearSlackAllowFromCacheForTest: typeof import("./auth.js").clearSlackAllowFromCacheForTest;
|
||||
let resolveSlackEffectiveAllowFrom: typeof import("./auth.js").resolveSlackEffectiveAllowFrom;
|
||||
|
||||
vi.mock("../../../../src/pairing/pairing-store.js", () => ({
|
||||
readChannelAllowFromStore: (...args: unknown[]) => readChannelAllowFromStoreMock(...args),
|
||||
}));
|
||||
|
||||
import { clearSlackAllowFromCacheForTest, resolveSlackEffectiveAllowFrom } from "./auth.js";
|
||||
vi.mock("openclaw/plugin-sdk/security-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/security-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
readStoreAllowFromForDmPolicy: (...args: unknown[]) =>
|
||||
readStoreAllowFromForDmPolicyMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
function makeSlackCtx(allowFrom: string[]): SlackMonitorContext {
|
||||
return {
|
||||
@@ -20,8 +25,11 @@ function makeSlackCtx(allowFrom: string[]): SlackMonitorContext {
|
||||
describe("resolveSlackEffectiveAllowFrom", () => {
|
||||
const prevTtl = process.env.OPENCLAW_SLACK_PAIRING_ALLOWFROM_CACHE_TTL_MS;
|
||||
|
||||
beforeEach(() => {
|
||||
readChannelAllowFromStoreMock.mockReset();
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ clearSlackAllowFromCacheForTest, resolveSlackEffectiveAllowFrom } =
|
||||
await import("./auth.js"));
|
||||
readStoreAllowFromForDmPolicyMock.mockReset();
|
||||
clearSlackAllowFromCacheForTest();
|
||||
if (prevTtl === undefined) {
|
||||
delete process.env.OPENCLAW_SLACK_PAIRING_ALLOWFROM_CACHE_TTL_MS;
|
||||
@@ -31,7 +39,7 @@ describe("resolveSlackEffectiveAllowFrom", () => {
|
||||
});
|
||||
|
||||
it("falls back to channel config allowFrom when pairing store throws", async () => {
|
||||
readChannelAllowFromStoreMock.mockRejectedValueOnce(new Error("boom"));
|
||||
readStoreAllowFromForDmPolicyMock.mockRejectedValueOnce(new Error("boom"));
|
||||
|
||||
const effective = await resolveSlackEffectiveAllowFrom(makeSlackCtx(["u1"]));
|
||||
|
||||
@@ -40,7 +48,7 @@ describe("resolveSlackEffectiveAllowFrom", () => {
|
||||
});
|
||||
|
||||
it("treats malformed non-array pairing-store responses as empty", async () => {
|
||||
readChannelAllowFromStoreMock.mockReturnValueOnce(undefined);
|
||||
readStoreAllowFromForDmPolicyMock.mockReturnValueOnce(undefined);
|
||||
|
||||
const effective = await resolveSlackEffectiveAllowFrom(makeSlackCtx(["u1"]));
|
||||
|
||||
@@ -49,7 +57,7 @@ describe("resolveSlackEffectiveAllowFrom", () => {
|
||||
});
|
||||
|
||||
it("memoizes pairing-store allowFrom reads within TTL", async () => {
|
||||
readChannelAllowFromStoreMock.mockResolvedValue(["u2"]);
|
||||
readStoreAllowFromForDmPolicyMock.mockResolvedValue(["u2"]);
|
||||
const ctx = makeSlackCtx(["u1"]);
|
||||
|
||||
const first = await resolveSlackEffectiveAllowFrom(ctx, { includePairingStore: true });
|
||||
@@ -57,17 +65,17 @@ describe("resolveSlackEffectiveAllowFrom", () => {
|
||||
|
||||
expect(first.allowFrom).toEqual(["u1", "u2"]);
|
||||
expect(second.allowFrom).toEqual(["u1", "u2"]);
|
||||
expect(readChannelAllowFromStoreMock).toHaveBeenCalledTimes(1);
|
||||
expect(readStoreAllowFromForDmPolicyMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refreshes pairing-store allowFrom when cache TTL is zero", async () => {
|
||||
process.env.OPENCLAW_SLACK_PAIRING_ALLOWFROM_CACHE_TTL_MS = "0";
|
||||
readChannelAllowFromStoreMock.mockResolvedValue(["u2"]);
|
||||
readStoreAllowFromForDmPolicyMock.mockResolvedValue(["u2"]);
|
||||
const ctx = makeSlackCtx(["u1"]);
|
||||
|
||||
await resolveSlackEffectiveAllowFrom(ctx, { includePairingStore: true });
|
||||
await resolveSlackEffectiveAllowFrom(ctx, { includePairingStore: true });
|
||||
|
||||
expect(readChannelAllowFromStoreMock).toHaveBeenCalledTimes(2);
|
||||
expect(readStoreAllowFromForDmPolicyMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { registerSlackChannelEvents } from "./channels.js";
|
||||
import { createSlackSystemEventTestHarness } from "./system-event-test-harness.js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const enqueueSystemEventMock = vi.fn();
|
||||
const enqueueSystemEventMock = vi.hoisted(() => vi.fn());
|
||||
let registerSlackChannelEvents: typeof import("./channels.js").registerSlackChannelEvents;
|
||||
let createSlackSystemEventTestHarness: typeof import("./system-event-test-harness.js").createSlackSystemEventTestHarness;
|
||||
|
||||
vi.mock("../../../../../src/infra/system-events.js", () => ({
|
||||
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
type SlackChannelHandler = (args: {
|
||||
event: Record<string, unknown>;
|
||||
@@ -28,6 +32,13 @@ function createChannelContext(params?: {
|
||||
}
|
||||
|
||||
describe("registerSlackChannelEvents", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
enqueueSystemEventMock.mockClear();
|
||||
({ registerSlackChannelEvents } = await import("./channels.js"));
|
||||
({ createSlackSystemEventTestHarness } = await import("./system-event-test-harness.js"));
|
||||
});
|
||||
|
||||
it("does not track mismatched events", async () => {
|
||||
const trackEvent = vi.fn();
|
||||
const { getCreatedHandler } = createChannelContext({
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerSlackInteractionEvents } from "./interactions.js";
|
||||
|
||||
const enqueueSystemEventMock = vi.fn();
|
||||
const dispatchPluginInteractiveHandlerMock = vi.fn(async () => ({
|
||||
@@ -10,20 +9,28 @@ const dispatchPluginInteractiveHandlerMock = vi.fn(async () => ({
|
||||
const resolvePluginConversationBindingApprovalMock = vi.fn();
|
||||
const buildPluginBindingResolvedTextMock = vi.fn(() => "Binding updated.");
|
||||
|
||||
vi.mock("../../../../../src/infra/system-events.js", () => ({
|
||||
enqueueSystemEvent: (...args: unknown[]) =>
|
||||
(enqueueSystemEventMock as (...innerArgs: unknown[]) => unknown)(...args),
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
enqueueSystemEvent: (...args: unknown[]) =>
|
||||
(enqueueSystemEventMock as (...innerArgs: unknown[]) => unknown)(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../../../src/plugins/interactive.js", () => ({
|
||||
dispatchPluginInteractiveHandler: (...args: unknown[]) =>
|
||||
(dispatchPluginInteractiveHandlerMock as (...innerArgs: unknown[]) => unknown)(...args),
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/plugin-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/plugin-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
dispatchPluginInteractiveHandler: (...args: unknown[]) =>
|
||||
(dispatchPluginInteractiveHandlerMock as (...innerArgs: unknown[]) => unknown)(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../../../src/plugins/conversation-binding.js", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("../../../../../src/plugins/conversation-binding.js")
|
||||
>("../../../../../src/plugins/conversation-binding.js");
|
||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/conversation-runtime")>(
|
||||
"openclaw/plugin-sdk/conversation-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
resolvePluginConversationBindingApproval: (...args: unknown[]) =>
|
||||
@@ -35,6 +42,13 @@ vi.mock("../../../../../src/plugins/conversation-binding.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
let registerSlackInteractionEvents: typeof import("./interactions.js").registerSlackInteractionEvents;
|
||||
|
||||
vi.mock("../../../../../src/infra/system-events.js", () => ({
|
||||
enqueueSystemEvent: (...args: unknown[]) =>
|
||||
(enqueueSystemEventMock as (...innerArgs: unknown[]) => unknown)(...args),
|
||||
}));
|
||||
|
||||
type RegisteredHandler = (args: {
|
||||
ack: () => Promise<void>;
|
||||
body: {
|
||||
@@ -183,7 +197,9 @@ function createContext(overrides?: {
|
||||
}
|
||||
|
||||
describe("registerSlackInteractionEvents", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ registerSlackInteractionEvents } = await import("./interactions.js"));
|
||||
enqueueSystemEventMock.mockClear();
|
||||
dispatchPluginInteractiveHandlerMock.mockClear();
|
||||
resolvePluginConversationBindingApprovalMock.mockClear();
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { registerSlackMemberEvents } from "./members.js";
|
||||
import {
|
||||
createSlackSystemEventTestHarness as initSlackHarness,
|
||||
type SlackSystemEventTestOverrides as MemberOverrides,
|
||||
} from "./system-event-test-harness.js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const memberMocks = vi.hoisted(() => ({
|
||||
enqueue: vi.fn(),
|
||||
readAllow: vi.fn(),
|
||||
}));
|
||||
let registerSlackMemberEvents: typeof import("./members.js").registerSlackMemberEvents;
|
||||
let initSlackHarness: typeof import("./system-event-test-harness.js").createSlackSystemEventTestHarness;
|
||||
type MemberOverrides = import("./system-event-test-harness.js").SlackSystemEventTestOverrides;
|
||||
|
||||
vi.mock("../../../../../src/infra/system-events.js", () => ({
|
||||
enqueueSystemEvent: memberMocks.enqueue,
|
||||
}));
|
||||
|
||||
vi.mock("../../../../../src/pairing/pairing-store.js", () => ({
|
||||
readChannelAllowFromStore: memberMocks.readAllow,
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
|
||||
return { ...actual, enqueueSystemEvent: memberMocks.enqueue };
|
||||
});
|
||||
|
||||
type MemberHandler = (args: { event: Record<string, unknown>; body: unknown }) => Promise<void>;
|
||||
|
||||
@@ -56,7 +50,6 @@ function getMemberHandlers(params: {
|
||||
|
||||
async function runMemberCase(args: MemberCaseArgs = {}): Promise<void> {
|
||||
memberMocks.enqueue.mockClear();
|
||||
memberMocks.readAllow.mockReset().mockResolvedValue([]);
|
||||
const handlers = getMemberHandlers({
|
||||
overrides: args.overrides,
|
||||
trackEvent: args.trackEvent,
|
||||
@@ -72,6 +65,14 @@ async function runMemberCase(args: MemberCaseArgs = {}): Promise<void> {
|
||||
}
|
||||
|
||||
describe("registerSlackMemberEvents", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
memberMocks.enqueue.mockClear();
|
||||
({ registerSlackMemberEvents } = await import("./members.js"));
|
||||
({ createSlackSystemEventTestHarness: initSlackHarness } =
|
||||
await import("./system-event-test-harness.js"));
|
||||
});
|
||||
|
||||
const cases: Array<{ name: string; args: MemberCaseArgs; calls: number }> = [
|
||||
{
|
||||
name: "enqueues DM member events when dmPolicy is open",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { registerSlackMessageEvents } from "./messages.js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createSlackSystemEventTestHarness,
|
||||
type SlackSystemEventTestOverrides,
|
||||
@@ -8,13 +7,23 @@ import {
|
||||
const messageQueueMock = vi.fn();
|
||||
const messageAllowMock = vi.fn();
|
||||
|
||||
vi.mock("../../../../../src/infra/system-events.js", () => ({
|
||||
enqueueSystemEvent: (...args: unknown[]) => messageQueueMock(...args),
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
enqueueSystemEvent: (...args: unknown[]) => messageQueueMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../../../src/pairing/pairing-store.js", () => ({
|
||||
readChannelAllowFromStore: (...args: unknown[]) => messageAllowMock(...args),
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
readChannelAllowFromStore: (...args: unknown[]) => messageAllowMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
let registerSlackMessageEvents: typeof import("./messages.js").registerSlackMessageEvents;
|
||||
|
||||
type MessageHandler = (args: { event: Record<string, unknown>; body: unknown }) => Promise<void>;
|
||||
type RegisteredEventName = "message" | "app_mention";
|
||||
@@ -43,6 +52,11 @@ function resetMessageMocks(): void {
|
||||
messageAllowMock.mockReset().mockResolvedValue([]);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ registerSlackMessageEvents } = await import("./messages.js"));
|
||||
});
|
||||
|
||||
function makeChangedEvent(overrides?: { channel?: string; user?: string }) {
|
||||
const user = overrides?.user ?? "U1";
|
||||
return {
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { registerSlackPinEvents } from "./pins.js";
|
||||
import {
|
||||
createSlackSystemEventTestHarness as buildPinHarness,
|
||||
type SlackSystemEventTestOverrides as PinOverrides,
|
||||
} from "./system-event-test-harness.js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const pinEnqueueMock = vi.hoisted(() => vi.fn());
|
||||
const pinAllowMock = vi.hoisted(() => vi.fn());
|
||||
let registerSlackPinEvents: typeof import("./pins.js").registerSlackPinEvents;
|
||||
let buildPinHarness: typeof import("./system-event-test-harness.js").createSlackSystemEventTestHarness;
|
||||
type PinOverrides = import("./system-event-test-harness.js").SlackSystemEventTestOverrides;
|
||||
|
||||
vi.mock("../../../../../src/infra/system-events.js", () => {
|
||||
return { enqueueSystemEvent: pinEnqueueMock };
|
||||
vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
|
||||
return { ...actual, enqueueSystemEvent: pinEnqueueMock };
|
||||
});
|
||||
vi.mock("../../../../../src/pairing/pairing-store.js", () => ({
|
||||
readChannelAllowFromStore: pinAllowMock,
|
||||
}));
|
||||
|
||||
type PinHandler = (args: { event: Record<string, unknown>; body: unknown }) => Promise<void>;
|
||||
|
||||
@@ -57,7 +52,6 @@ function installPinHandlers(args: {
|
||||
|
||||
async function runPinCase(input: PinCase = {}): Promise<void> {
|
||||
pinEnqueueMock.mockClear();
|
||||
pinAllowMock.mockReset().mockResolvedValue([]);
|
||||
const { added, removed } = installPinHandlers({
|
||||
overrides: input.overrides,
|
||||
trackEvent: input.trackEvent,
|
||||
@@ -75,6 +69,14 @@ async function runPinCase(input: PinCase = {}): Promise<void> {
|
||||
}
|
||||
|
||||
describe("registerSlackPinEvents", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
pinEnqueueMock.mockClear();
|
||||
({ registerSlackPinEvents } = await import("./pins.js"));
|
||||
({ createSlackSystemEventTestHarness: buildPinHarness } =
|
||||
await import("./system-event-test-harness.js"));
|
||||
});
|
||||
|
||||
const cases: Array<{ name: string; args: PinCase; expectedCalls: number }> = [
|
||||
{
|
||||
name: "enqueues DM pin system events when dmPolicy is open",
|
||||
|
||||
@@ -1,25 +1,19 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { registerSlackReactionEvents } from "./reactions.js";
|
||||
import {
|
||||
createSlackSystemEventTestHarness,
|
||||
type SlackSystemEventTestOverrides,
|
||||
} from "./system-event-test-harness.js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const reactionQueueMock = vi.fn();
|
||||
const reactionAllowMock = vi.fn();
|
||||
const reactionQueueMock = vi.hoisted(() => vi.fn());
|
||||
let registerSlackReactionEvents: typeof import("./reactions.js").registerSlackReactionEvents;
|
||||
let createSlackSystemEventTestHarness: typeof import("./system-event-test-harness.js").createSlackSystemEventTestHarness;
|
||||
type SlackSystemEventTestOverrides =
|
||||
import("./system-event-test-harness.js").SlackSystemEventTestOverrides;
|
||||
|
||||
vi.mock("../../../../../src/infra/system-events.js", () => {
|
||||
vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
enqueueSystemEvent: (...args: unknown[]) => reactionQueueMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../../../src/pairing/pairing-store.js", () => {
|
||||
return {
|
||||
readChannelAllowFromStore: (...args: unknown[]) => reactionAllowMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
type ReactionHandler = (args: { event: Record<string, unknown>; body: unknown }) => Promise<void>;
|
||||
|
||||
type ReactionRunInput = {
|
||||
@@ -63,7 +57,6 @@ function createReactionHandlers(params: {
|
||||
|
||||
async function executeReactionCase(input: ReactionRunInput = {}) {
|
||||
reactionQueueMock.mockClear();
|
||||
reactionAllowMock.mockReset().mockResolvedValue([]);
|
||||
const handlers = createReactionHandlers({
|
||||
overrides: input.overrides,
|
||||
trackEvent: input.trackEvent,
|
||||
@@ -78,6 +71,13 @@ async function executeReactionCase(input: ReactionRunInput = {}) {
|
||||
}
|
||||
|
||||
describe("registerSlackReactionEvents", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
reactionQueueMock.mockClear();
|
||||
({ registerSlackReactionEvents } = await import("./reactions.js"));
|
||||
({ createSlackSystemEventTestHarness } = await import("./system-event-test-harness.js"));
|
||||
});
|
||||
|
||||
const cases: Array<{ name: string; input: ReactionRunInput; expectedCalls: number }> = [
|
||||
{
|
||||
name: "enqueues DM reaction system events when dmPolicy is open",
|
||||
@@ -156,7 +156,6 @@ describe("registerSlackReactionEvents", () => {
|
||||
|
||||
it("passes sender context when resolving reaction session keys", async () => {
|
||||
reactionQueueMock.mockClear();
|
||||
reactionAllowMock.mockReset().mockResolvedValue([]);
|
||||
const harness = createSlackSystemEventTestHarness();
|
||||
const resolveSessionKey = vi.fn().mockReturnValue("agent:ops:main");
|
||||
harness.ctx.resolveSlackSystemEventSessionKey = resolveSessionKey;
|
||||
|
||||
@@ -50,7 +50,7 @@ vi.mock("./message-handler/dispatch.js", () => ({
|
||||
dispatchPreparedSlackMessageMock(prepared),
|
||||
}));
|
||||
|
||||
import { createSlackMessageHandler } from "./message-handler.js";
|
||||
let createSlackMessageHandler: typeof import("./message-handler.js").createSlackMessageHandler;
|
||||
|
||||
function createMarkMessageSeen() {
|
||||
const seen = new Set<string>();
|
||||
@@ -117,7 +117,9 @@ async function createInFlightMessageScenario(ts: string) {
|
||||
}
|
||||
|
||||
describe("createSlackMessageHandler app_mention race handling", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ createSlackMessageHandler } = await import("./message-handler.js"));
|
||||
prepareSlackMessageMock.mockReset();
|
||||
dispatchPreparedSlackMessageMock.mockReset();
|
||||
});
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createSlackMessageHandler } from "./message-handler.js";
|
||||
|
||||
const enqueueMock = vi.fn(async (_entry: unknown) => {});
|
||||
const flushKeyMock = vi.fn(async (_key: string) => {});
|
||||
const resolveThreadTsMock = vi.fn(async ({ message }: { message: Record<string, unknown> }) => ({
|
||||
...message,
|
||||
}));
|
||||
let createSlackMessageHandler: typeof import("./message-handler.js").createSlackMessageHandler;
|
||||
|
||||
vi.mock("../../../../src/auto-reply/inbound-debounce.js", () => ({
|
||||
resolveInboundDebounceMs: () => 10,
|
||||
createInboundDebouncer: () => ({
|
||||
enqueue: (entry: unknown) => enqueueMock(entry),
|
||||
flushKey: (key: string) => flushKeyMock(key),
|
||||
}),
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/channel-inbound", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-inbound")>();
|
||||
return {
|
||||
...actual,
|
||||
createChannelInboundDebouncer: () => ({
|
||||
debounceMs: 10,
|
||||
debouncer: {
|
||||
enqueue: (entry: unknown) => enqueueMock(entry),
|
||||
flushKey: (key: string) => flushKeyMock(key),
|
||||
},
|
||||
}),
|
||||
shouldDebounceTextInbound: ({ hasMedia }: { hasMedia?: boolean }) => !hasMedia,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./thread-resolution.js", () => ({
|
||||
createSlackThreadTsResolver: () => ({
|
||||
@@ -63,10 +70,12 @@ async function handleDirectMessage(
|
||||
}
|
||||
|
||||
describe("createSlackMessageHandler", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
enqueueMock.mockClear();
|
||||
flushKeyMock.mockClear();
|
||||
resolveThreadTsMock.mockClear();
|
||||
({ createSlackMessageHandler } = await import("./message-handler.js"));
|
||||
});
|
||||
|
||||
it("does not track invalid non-message events from the message stream", async () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ vi.mock("../send.js", () => ({
|
||||
sendMessageSlack: (...args: unknown[]) => sendMock(...args),
|
||||
}));
|
||||
|
||||
import { deliverReplies } from "./replies.js";
|
||||
let deliverReplies: typeof import("./replies.js").deliverReplies;
|
||||
|
||||
function baseParams(overrides?: Record<string, unknown>) {
|
||||
return {
|
||||
@@ -20,7 +20,9 @@ function baseParams(overrides?: Record<string, unknown>) {
|
||||
}
|
||||
|
||||
describe("deliverReplies identity passthrough", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ deliverReplies } = await import("./replies.js"));
|
||||
sendMock.mockReset();
|
||||
});
|
||||
it("passes identity to sendMessageSlack for text replies", async () => {
|
||||
|
||||
@@ -194,7 +194,9 @@ async function loadRegisterSlackMonitorSlashCommands(): Promise<RegisterFn> {
|
||||
|
||||
const { dispatchMock } = getSlackSlashMocks();
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
registerSlackMonitorSlashCommandsPromise = undefined;
|
||||
resetSlackSlashMocks();
|
||||
});
|
||||
|
||||
|
||||
@@ -8,14 +8,15 @@ vi.mock("./client.js", () => ({
|
||||
createSlackWebClient: createSlackWebClientMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../../src/utils/with-timeout.js", () => ({
|
||||
vi.mock("openclaw/plugin-sdk/text-runtime", () => ({
|
||||
withTimeout: withTimeoutMock,
|
||||
}));
|
||||
|
||||
const { probeSlack } = await import("./probe.js");
|
||||
let probeSlack: typeof import("./probe.js").probeSlack;
|
||||
|
||||
describe("probeSlack", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
authTestMock.mockReset();
|
||||
createSlackWebClientMock.mockReset();
|
||||
withTimeoutMock.mockReset();
|
||||
@@ -26,6 +27,7 @@ describe("probeSlack", () => {
|
||||
},
|
||||
});
|
||||
withTimeoutMock.mockImplementation(async (promise: Promise<unknown>) => await promise);
|
||||
({ probeSlack } = await import("./probe.js"));
|
||||
});
|
||||
|
||||
it("maps Slack auth metadata on success", async () => {
|
||||
|
||||
@@ -31,7 +31,7 @@ vi.mock("openclaw/plugin-sdk/web-media", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
const { sendMessageSlack } = await import("./send.js");
|
||||
let sendMessageSlack: typeof import("./send.js").sendMessageSlack;
|
||||
|
||||
type UploadTestClient = WebClient & {
|
||||
conversations: { open: ReturnType<typeof vi.fn> };
|
||||
@@ -64,7 +64,9 @@ function createUploadTestClient(): UploadTestClient {
|
||||
describe("sendMessageSlack file upload with user IDs", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ sendMessageSlack } = await import("./send.js"));
|
||||
globalThis.fetch = vi.fn(
|
||||
async () => new Response("ok", { status: 200 }),
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./tavily-client.js", () => ({
|
||||
const { runTavilyExtract } = vi.hoisted(() => ({
|
||||
runTavilyExtract: vi.fn(async (params: unknown) => ({ ok: true, params })),
|
||||
}));
|
||||
|
||||
import { runTavilyExtract } from "./tavily-client.js";
|
||||
import { createTavilyExtractTool } from "./tavily-extract-tool.js";
|
||||
vi.mock("./tavily-client.js", () => ({
|
||||
runTavilyExtract,
|
||||
}));
|
||||
|
||||
let createTavilyExtractTool: typeof import("./tavily-extract-tool.js").createTavilyExtractTool;
|
||||
|
||||
function fakeApi(): OpenClawPluginApi {
|
||||
return {
|
||||
@@ -15,8 +18,11 @@ function fakeApi(): OpenClawPluginApi {
|
||||
}
|
||||
|
||||
describe("tavily_extract", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
runTavilyExtract.mockReset();
|
||||
runTavilyExtract.mockImplementation(async (params: unknown) => ({ ok: true, params }));
|
||||
({ createTavilyExtractTool } = await import("./tavily-extract-tool.js"));
|
||||
});
|
||||
|
||||
it("rejects chunks_per_source without query", async () => {
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const runTavilySearch = vi.fn(async (params: Record<string, unknown>) => params);
|
||||
const { runTavilySearch } = vi.hoisted(() => ({
|
||||
runTavilySearch: vi.fn(async (params: Record<string, unknown>) => params),
|
||||
}));
|
||||
|
||||
vi.mock("./tavily-client.js", () => ({
|
||||
runTavilySearch,
|
||||
}));
|
||||
|
||||
describe("tavily web search provider", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
runTavilySearch.mockReset();
|
||||
runTavilySearch.mockImplementation(async (params: Record<string, unknown>) => params);
|
||||
});
|
||||
|
||||
it("exposes the expected metadata and selection wiring", async () => {
|
||||
const { createTavilyWebSearchProvider } = await import("./tavily-search-provider.js");
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user