Compare commits
134 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ccfeecb688 | |||
| a921b5bdff | |||
| 725a2cc2ca | |||
| 67dbb1ad42 | |||
| d67efbfbd3 | |||
| ae336d1602 | |||
| 03231c0633 | |||
| 47bdc36831 | |||
| 19295994f3 | |||
| 6f5df14308 | |||
| 7b03502175 | |||
| e87a64f8d0 | |||
| dad6018230 | |||
| e808f8d6ef | |||
| 6ab6e7a493 | |||
| fb6588cb99 | |||
| 6e8d5cd578 | |||
| ade0182ae0 | |||
| 64f4df1886 | |||
| 3814f956d1 | |||
| dbe7da7684 | |||
| a53715e9d0 | |||
| c1131ba7e0 | |||
| 1c7e98de16 | |||
| 34c57487b4 | |||
| bc95436a43 | |||
| f698774324 | |||
| b98f3634c4 | |||
| fd5496d1d3 | |||
| 13e81870bb | |||
| 6c58277577 | |||
| 3e2b3bd2c5 | |||
| 99c84294f3 | |||
| 01c1fc797f | |||
| 8670f2cead | |||
| cdd797f943 | |||
| 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 | |||
| e7d11f6c33 | |||
| 6f048f59cb | |||
| b9efba1faf | |||
| 4dcc39c25c | |||
| 6e012d7feb | |||
| 2df10e81c8 | |||
| 9d3d7f9e65 | |||
| a0ad47440a | |||
| 3ff2f85bad | |||
| 203eebec2f | |||
| 399fae33ca | |||
| 8067ae50fa |
@@ -16,13 +16,25 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
|
||||
- Pass `--json` for machine-readable summaries.
|
||||
- Per-phase logs land under `/tmp/openclaw-parallels-*`.
|
||||
- Do not run local and gateway agent turns in parallel on the same fresh workspace or session.
|
||||
- For `prlctl exec`, pass the VM name before `--current-user` (`prlctl exec "$VM" --current-user ...`), not the other way around.
|
||||
|
||||
## npm install then update
|
||||
|
||||
- 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.
|
||||
|
||||
## macOS flow
|
||||
|
||||
- Preferred entrypoint: `pnpm test:parallels:macos`
|
||||
- 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.
|
||||
- The macOS smoke should include a dashboard load phase after gateway health: resolve the tokenized URL with `openclaw dashboard --no-open`, verify the served HTML contains the Control UI title/root shell, then open Safari and require an established localhost TCP connection from Safari to the gateway port.
|
||||
- `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,139 @@ 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 the public validation
|
||||
workflow in `openclaw/openclaw` and the real mac preflight in
|
||||
`openclaw/releases-private` 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`.
|
||||
- The npm workflow and the private mac publish workflow accept
|
||||
`preflight_only=true` to run validation/build/package steps without uploading
|
||||
public release assets.
|
||||
- The private mac workflow also accepts `smoke_test_only=true` for branch-safe
|
||||
workflow smoke tests that use ad-hoc signing, skip notarization, skip shared
|
||||
appcast generation, and do not prove release readiness.
|
||||
- `preflight_only=true` on the npm workflow is also the right way to validate an
|
||||
existing tag after publish; it should keep running the build checks even when
|
||||
the npm version is already published.
|
||||
- Validation-only runs may be dispatched from a branch when you are testing a
|
||||
workflow change before merge.
|
||||
- `.github/workflows/macos-release.yml` in `openclaw/openclaw` is now a
|
||||
public validation-only handoff. It validates the tag/release state and points
|
||||
operators to the private repo; it does not build or publish macOS artifacts.
|
||||
- Real mac preflight and real mac publish both use
|
||||
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`.
|
||||
- The private mac workflow runs on GitHub's xlarge macOS runner and uses a
|
||||
SwiftPM cache because the Swift build/test/package path is CPU-heavy.
|
||||
- Private mac preflight uploads notarized build artifacts as workflow artifacts
|
||||
instead of uploading public GitHub release assets.
|
||||
- Private smoke-test runs upload ad-hoc, non-notarized build artifacts as
|
||||
workflow artifacts and intentionally skip stable `appcast.xml` generation.
|
||||
- npm preflight, public mac validation, and private mac preflight must all pass
|
||||
before any real publish run starts.
|
||||
- Real publish runs must be dispatched from `main`; branch-dispatched publish
|
||||
attempts should fail before the protected environment is reached.
|
||||
- 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
|
||||
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml` for
|
||||
build, signing, notarization, packaged mac artifact generation, and
|
||||
stable-feed `appcast.xml` artifact generation.
|
||||
- After a successful real private mac publish, the agent must download
|
||||
`macos-release-<tag>` from that run and upload the packaged mac assets to the
|
||||
existing GitHub release in `openclaw/openclaw`.
|
||||
- For stable releases, the agent must also download the signed
|
||||
`macos-appcast-<tag>` artifact from the successful private 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.
|
||||
- The private repo targets a dedicated `mac-release` environment. If the GitHub
|
||||
plan does not yet support required reviewers there, do not assume the
|
||||
environment alone is the approval boundary; rely on private repo access and
|
||||
CODEOWNERS until those settings can be enabled.
|
||||
- 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` in `openclaw/openclaw` and wait
|
||||
for the public validation-only run to pass.
|
||||
11. Start
|
||||
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`
|
||||
with `preflight_only=true` and wait for it to pass.
|
||||
12. If any preflight or validation run fails, fix the issue on a new commit,
|
||||
delete the tag and matching GitHub release, recreate them from the fixed
|
||||
commit, and rerun all relevant preflights from scratch before continuing.
|
||||
Never reuse old preflight results after the commit changes.
|
||||
13. Start `.github/workflows/openclaw-npm-release.yml` with the same tag for
|
||||
the real publish.
|
||||
14. Wait for `npm-release` approval from `@openclaw/openclaw-release-managers`.
|
||||
15. Start
|
||||
`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`
|
||||
for the real publish and wait for success.
|
||||
16. Download `macos-release-<tag>` from the successful private mac run and
|
||||
upload the `.zip`, `.dmg`, and `.dSYM.zip` artifacts to the existing
|
||||
GitHub release in `openclaw/openclaw`.
|
||||
17. For stable releases, download `macos-appcast-<tag>` from the successful
|
||||
private mac run, update `appcast.xml` on `main`, and verify the feed.
|
||||
18. 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.
|
||||
19. After publish, verify npm and the 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,21 @@ jobs:
|
||||
command: pnpm protocol:check
|
||||
- runtime: bun
|
||||
task: test
|
||||
command: pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts
|
||||
shard_index: 1
|
||||
shard_count: 2
|
||||
command: pnpm canvas:a2ui:bundle && OPENCLAW_TEST_ISOLATE=1 bunx vitest run --config vitest.unit.config.ts --shard 1/2
|
||||
- runtime: bun
|
||||
task: test
|
||||
shard_index: 2
|
||||
shard_count: 2
|
||||
command: pnpm canvas:a2ui:bundle && OPENCLAW_TEST_ISOLATE=1 bunx vitest run --config vitest.unit.config.ts --shard 2/2
|
||||
- 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,86 @@
|
||||
name: macOS Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Existing release tag to validate for macOS release handoff (for example v2026.3.22 or v2026.3.22-beta.1)
|
||||
required: true
|
||||
type: string
|
||||
preflight_only:
|
||||
description: Retained for operator compatibility; this public workflow is validation-only
|
||||
required: true
|
||||
default: true
|
||||
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"
|
||||
|
||||
jobs:
|
||||
validate_macos_release_request:
|
||||
runs-on: ubuntu-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: Summarize next step
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
{
|
||||
echo "## Public macOS validation only"
|
||||
echo
|
||||
echo "This workflow no longer builds, signs, notarizes, or uploads macOS assets."
|
||||
echo
|
||||
echo "Next step:"
|
||||
echo "- Run \`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml\` with tag \`${RELEASE_TAG}\`."
|
||||
echo "- Use \`preflight_only=true\` there for the full private mac preflight."
|
||||
echo "- For the real publish path, the private run uploads \`macos-release-${RELEASE_TAG}\` as a workflow artifact. Download it and upload the packaged assets to the existing GitHub release in \`openclaw/openclaw\`."
|
||||
echo "- For stable releases, also download \`macos-appcast-${RELEASE_TAG}\` from the successful private run and commit \`appcast.xml\` back to \`main\` in \`openclaw/openclaw\`."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -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,15 +23,25 @@ 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: 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
|
||||
@@ -40,38 +52,12 @@ jobs:
|
||||
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_TAG: ${{ inputs.tag }}
|
||||
RELEASE_MAIN_REF: origin/main
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
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
|
||||
@@ -81,52 +67,51 @@ jobs:
|
||||
|
||||
- name: Ensure version is not already published
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
PREFLIGHT_ONLY: ${{ inputs.preflight_only }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
set -euo 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."
|
||||
if [[ "${PREFLIGHT_ONLY}" == "true" ]]; then
|
||||
echo "openclaw@${PACKAGE_VERSION} is already published on npm; continuing because preflight_only=true."
|
||||
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
|
||||
echo "Publishing openclaw@${PACKAGE_VERSION}"
|
||||
|
||||
- name: Check
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
pnpm check
|
||||
run: pnpm check
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
pnpm build
|
||||
run: pnpm build
|
||||
|
||||
- name: Verify release contents
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
pnpm release:check
|
||||
run: pnpm release:check
|
||||
|
||||
- name: Preview publish command
|
||||
run: bash scripts/openclaw-npm-publish.sh --dry-run
|
||||
validate_publish_dispatch_ref:
|
||||
if: ${{ !inputs.preflight_only }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Require main workflow ref for publish
|
||||
env:
|
||||
WORKFLOW_REF: ${{ github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]]; then
|
||||
echo "Real publish runs must be dispatched from main. Use preflight_only=true for branch validation."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
publish_openclaw_npm:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
# npm trusted publishing + provenance requires a GitHub-hosted runner.
|
||||
needs: [preflight_openclaw_npm, validate_publish_dispatch_ref]
|
||||
if: ${{ !inputs.preflight_only }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm-release
|
||||
permissions:
|
||||
@@ -182,14 +167,5 @@ jobs:
|
||||
|
||||
echo "Publishing openclaw@${PACKAGE_VERSION}"
|
||||
|
||||
- name: Check
|
||||
run: pnpm check
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Verify release contents
|
||||
run: pnpm release:check
|
||||
|
||||
- name: Publish
|
||||
run: bash scripts/openclaw-npm-publish.sh --publish
|
||||
|
||||
@@ -6,6 +6,56 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Breaking
|
||||
|
||||
### Changes
|
||||
|
||||
- ModelStudio/Qwen: add standard (pay-as-you-go) DashScope endpoints for China and global Qwen API keys alongside the existing Coding Plan endpoints, and relabel the provider group to `Qwen (Alibaba Cloud Model Studio)`. (#43878)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Control UI/auth: preserve operator scopes through the device-auth bypass path, ignore cached under-scoped operator tokens, and show a clear `operator.read` fallback message when a connection really lacks read scope, so operator sessions stop failing or blanking on read-backed pages. (#53110) Thanks @BunsDev.
|
||||
- Plugins/uninstall: accept installed `clawhub:` specs and versionless ClawHub package names as uninstall targets, so `openclaw plugins uninstall clawhub:<package>` works again even when the recorded install was pinned to a version.
|
||||
- Auth/OpenAI tokens: stop live gateway auth-profile writes from reverting freshly saved credentials back to stale in-memory values, and make `models auth paste-token` write to the resolved agent store, so Configure, Onboard, and token-paste flows stop snapping back to expired OpenAI tokens. Fixes #53207. Related to #45516.
|
||||
- Agents/failover: classify generic `api_error` payloads as retryable only when they include transient failure signals, so MiniMax-style backend failures still trigger model fallback without misclassifying billing, auth, or format/context errors. (#49611) Thanks @ayushozha.
|
||||
- Diagnostics/cache trace: strip credential fields from cache-trace JSONL output while preserving non-sensitive diagnostic fields and image redaction metadata.
|
||||
- Docs/Feishu: replace `botName` with `name` in the channel config examples so the docs match the strict account schema for per-account display names. (#52753) Thanks @haroldfabla2-hue.
|
||||
|
||||
## 2026.3.23
|
||||
|
||||
### Breaking
|
||||
|
||||
### Changes
|
||||
|
||||
### Fixes
|
||||
|
||||
- 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/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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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. Thanks @jzakirov.
|
||||
- Models/OpenAI Codex OAuth: bootstrap the env-configured HTTP/HTTPS proxy dispatcher on the stored-credential refresh path before token renewal runs, so expired Codex OAuth profiles can refresh successfully in proxy-required environments instead of locking users out after the first token expiry.
|
||||
- Plugins/memory-lancedb: bootstrap LanceDB into plugin runtime state on first use when the bundled npm install does not already have it, so `plugins.slots.memory="memory-lancedb"` works again after global npm installs without moving LanceDB into OpenClaw core dependencies. Fixes #26100.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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)
|
||||
- Voice-call/Plivo: stabilize Plivo v2 replay keys so webhook retries and replay protection stop colliding on valid follow-up deliveries.
|
||||
- Agents/skills: prefer the active resolved runtime snapshot for embedded skill config and env injection, so `skills.entries.<skill>.apiKey` SecretRefs resolve correctly during embedded startup instead of failing on raw source config. Fixes #53098. Thanks @vincentkoc.
|
||||
- Agents/subagents: recheck timed-out worker waits against the latest runtime snapshot before sending completion events, so fast-finishing workers stop being reported as timed out when they actually succeeded. Fixes #53106. 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## 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 +74,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 +152,17 @@ 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
|
||||
|
||||
- Security/exec approvals: keep shell-wrapper positional-argv allowlist matching on real direct carriers only by rejecting single-quoted `$0`/`$n` tokens, disallowing newline-separated `exec`, and still accepting `exec --` carrier forms. Thanks @vincentkoc.
|
||||
- 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 +378,9 @@ 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.
|
||||
- Slack/embedded delivery: suppress transcript-only `delivery-mirror` assistant messages before embedded re-delivery and raise the default Slack chunk fallback so messages just over 4000 characters stay in a single post. (#45489) Thanks @theo674.
|
||||
- Slack/embedded delivery: suppress transcript-only `delivery-mirror` assistant messages before embedded re-delivery and raise the default Slack chunk fallback so messages just over 4000 characters stay in a single post. (#45489) Thanks @theo674.
|
||||
|
||||
## 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
|
||||
OPENCLAW_MARKETING_VERSION = 2026.3.23
|
||||
OPENCLAW_BUILD_VERSION = 202603230
|
||||
|
||||
#include? "../build/Version.xcconfig"
|
||||
|
||||
@@ -99,7 +99,7 @@ def normalize_release_version(raw_value)
|
||||
version = raw_value.to_s.strip.sub(/\Av/, "")
|
||||
UI.user_error!("Missing root package.json version.") unless env_present?(version)
|
||||
unless version.match?(/\A\d+\.\d+\.\d+(?:[.-]?beta[.-]\d+)?\z/i)
|
||||
UI.user_error!("Invalid package.json version '#{raw_value}'. Expected 2026.3.22 or 2026.3.22-beta.1.")
|
||||
UI.user_error!("Invalid package.json version '#{raw_value}'. Expected YYYY.M.D or YYYY.M.D-beta.N.")
|
||||
end
|
||||
|
||||
version
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ Edit `~/.openclaw/openclaw.json`:
|
||||
main: {
|
||||
appId: "cli_xxx",
|
||||
appSecret: "xxx",
|
||||
botName: "My AI assistant",
|
||||
name: "My AI assistant",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -494,12 +494,12 @@ openclaw pairing list feishu
|
||||
main: {
|
||||
appId: "cli_xxx",
|
||||
appSecret: "xxx",
|
||||
botName: "Primary bot",
|
||||
name: "Primary bot",
|
||||
},
|
||||
backup: {
|
||||
appId: "cli_yyy",
|
||||
appSecret: "yyy",
|
||||
botName: "Backup bot",
|
||||
name: "Backup bot",
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -190,7 +190,7 @@ openclaw channels add
|
||||
main: {
|
||||
appId: "cli_xxx",
|
||||
appSecret: "xxx",
|
||||
botName: "My AI assistant",
|
||||
name: "My AI assistant",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -499,12 +499,12 @@ openclaw pairing list feishu
|
||||
main: {
|
||||
appId: "cli_xxx",
|
||||
appSecret: "xxx",
|
||||
botName: "Primary bot",
|
||||
name: "Primary bot",
|
||||
},
|
||||
backup: {
|
||||
appId: "cli_yyy",
|
||||
appSecret: "yyy",
|
||||
botName: "Backup bot",
|
||||
name: "Backup bot",
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -148,14 +148,7 @@ describe("memory plugin e2e", () => {
|
||||
const toArray = vi.fn(async () => []);
|
||||
const limit = vi.fn(() => ({ toArray }));
|
||||
const vectorSearch = vi.fn(() => ({ limit }));
|
||||
|
||||
vi.resetModules();
|
||||
vi.doMock("openai", () => ({
|
||||
default: class MockOpenAI {
|
||||
embeddings = { create: embeddingsCreate };
|
||||
},
|
||||
}));
|
||||
vi.doMock("@lancedb/lancedb", () => ({
|
||||
const loadLanceDbModule = vi.fn(async () => ({
|
||||
connect: vi.fn(async () => ({
|
||||
tableNames: vi.fn(async () => ["memories"]),
|
||||
openTable: vi.fn(async () => ({
|
||||
@@ -167,6 +160,16 @@ describe("memory plugin e2e", () => {
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.resetModules();
|
||||
vi.doMock("openai", () => ({
|
||||
default: class MockOpenAI {
|
||||
embeddings = { create: embeddingsCreate };
|
||||
},
|
||||
}));
|
||||
vi.doMock("./lancedb-runtime.js", () => ({
|
||||
loadLanceDbModule,
|
||||
}));
|
||||
|
||||
try {
|
||||
const { default: memoryPlugin } = await import("./index.js");
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
@@ -214,6 +217,7 @@ describe("memory plugin e2e", () => {
|
||||
}
|
||||
await recallTool.execute("test-call-dims", { query: "hello dimensions" });
|
||||
|
||||
expect(loadLanceDbModule).toHaveBeenCalledTimes(1);
|
||||
expect(embeddingsCreate).toHaveBeenCalledWith({
|
||||
model: "text-embedding-3-small",
|
||||
input: "hello dimensions",
|
||||
@@ -221,7 +225,7 @@ describe("memory plugin e2e", () => {
|
||||
});
|
||||
} finally {
|
||||
vi.doUnmock("openai");
|
||||
vi.doUnmock("@lancedb/lancedb");
|
||||
vi.doUnmock("./lancedb-runtime.js");
|
||||
vi.resetModules();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -18,24 +18,12 @@ import {
|
||||
memoryConfigSchema,
|
||||
vectorDimsForModel,
|
||||
} from "./config.js";
|
||||
import { loadLanceDbModule } from "./lancedb-runtime.js";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
let lancedbImportPromise: Promise<typeof import("@lancedb/lancedb")> | null = null;
|
||||
const loadLanceDB = async (): Promise<typeof import("@lancedb/lancedb")> => {
|
||||
if (!lancedbImportPromise) {
|
||||
lancedbImportPromise = import("@lancedb/lancedb");
|
||||
}
|
||||
try {
|
||||
return await lancedbImportPromise;
|
||||
} catch (err) {
|
||||
// Common on macOS today: upstream package may not ship darwin native bindings.
|
||||
throw new Error(`memory-lancedb: failed to load LanceDB. ${String(err)}`, { cause: err });
|
||||
}
|
||||
};
|
||||
|
||||
type MemoryEntry = {
|
||||
id: string;
|
||||
text: string;
|
||||
@@ -79,7 +67,7 @@ class MemoryDB {
|
||||
}
|
||||
|
||||
private async doInitialize(): Promise<void> {
|
||||
const lancedb = await loadLanceDB();
|
||||
const lancedb = await loadLanceDbModule();
|
||||
this.db = await lancedb.connect(this.dbPath);
|
||||
const tables = await this.db.tableNames();
|
||||
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createLanceDbRuntimeLoader, type LanceDbRuntimeLogger } from "./lancedb-runtime.js";
|
||||
|
||||
const TEST_RUNTIME_MANIFEST = {
|
||||
name: "openclaw-memory-lancedb-runtime",
|
||||
private: true as const,
|
||||
type: "module" as const,
|
||||
dependencies: {
|
||||
"@lancedb/lancedb": "^0.27.1",
|
||||
},
|
||||
};
|
||||
|
||||
type LanceDbModule = typeof import("@lancedb/lancedb");
|
||||
type RuntimeManifest = {
|
||||
name: string;
|
||||
private: true;
|
||||
type: "module";
|
||||
dependencies: Record<string, string>;
|
||||
};
|
||||
|
||||
function createMockModule(): LanceDbModule {
|
||||
return {
|
||||
connect: vi.fn(),
|
||||
} as unknown as LanceDbModule;
|
||||
}
|
||||
|
||||
function createLoader(
|
||||
overrides: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
importBundled?: () => Promise<LanceDbModule>;
|
||||
importResolved?: (resolvedPath: string) => Promise<LanceDbModule>;
|
||||
resolveRuntimeEntry?: (params: {
|
||||
runtimeDir: string;
|
||||
manifest: RuntimeManifest;
|
||||
}) => string | null;
|
||||
installRuntime?: (params: {
|
||||
runtimeDir: string;
|
||||
manifest: RuntimeManifest;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger?: LanceDbRuntimeLogger;
|
||||
}) => Promise<string>;
|
||||
} = {},
|
||||
) {
|
||||
return createLanceDbRuntimeLoader({
|
||||
env: overrides.env ?? ({} as NodeJS.ProcessEnv),
|
||||
resolveStateDir: () => "/tmp/openclaw-state",
|
||||
runtimeManifest: TEST_RUNTIME_MANIFEST,
|
||||
importBundled:
|
||||
overrides.importBundled ??
|
||||
(async () => {
|
||||
throw new Error("Cannot find package '@lancedb/lancedb'");
|
||||
}),
|
||||
importResolved: overrides.importResolved ?? (async () => createMockModule()),
|
||||
resolveRuntimeEntry: overrides.resolveRuntimeEntry ?? (() => null),
|
||||
installRuntime:
|
||||
overrides.installRuntime ??
|
||||
(async ({ runtimeDir }: { runtimeDir: string }) =>
|
||||
`${runtimeDir}/node_modules/@lancedb/lancedb/index.js`),
|
||||
});
|
||||
}
|
||||
|
||||
describe("lancedb runtime loader", () => {
|
||||
it("uses the bundled module when it is already available", async () => {
|
||||
const bundledModule = createMockModule();
|
||||
const importBundled = vi.fn(async () => bundledModule);
|
||||
const importResolved = vi.fn(async () => createMockModule());
|
||||
const resolveRuntimeEntry = vi.fn(() => null);
|
||||
const installRuntime = vi.fn(async () => "/tmp/openclaw-state/plugin-runtimes/lancedb.js");
|
||||
const loader = createLoader({
|
||||
importBundled,
|
||||
importResolved,
|
||||
resolveRuntimeEntry,
|
||||
installRuntime,
|
||||
});
|
||||
|
||||
await expect(loader.load()).resolves.toBe(bundledModule);
|
||||
|
||||
expect(resolveRuntimeEntry).not.toHaveBeenCalled();
|
||||
expect(installRuntime).not.toHaveBeenCalled();
|
||||
expect(importResolved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reuses an existing user runtime install before attempting a reinstall", async () => {
|
||||
const runtimeModule = createMockModule();
|
||||
const importResolved = vi.fn(async () => runtimeModule);
|
||||
const resolveRuntimeEntry = vi.fn(
|
||||
() => "/tmp/openclaw-state/plugin-runtimes/memory-lancedb/runtime-entry.js",
|
||||
);
|
||||
const installRuntime = vi.fn(
|
||||
async () => "/tmp/openclaw-state/plugin-runtimes/memory-lancedb/runtime-entry.js",
|
||||
);
|
||||
const loader = createLoader({
|
||||
importResolved,
|
||||
resolveRuntimeEntry,
|
||||
installRuntime,
|
||||
});
|
||||
|
||||
await expect(loader.load()).resolves.toBe(runtimeModule);
|
||||
|
||||
expect(resolveRuntimeEntry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runtimeDir: "/tmp/openclaw-state/plugin-runtimes/memory-lancedb/lancedb",
|
||||
}),
|
||||
);
|
||||
expect(installRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("installs LanceDB into user state when the bundled runtime is unavailable", async () => {
|
||||
const runtimeModule = createMockModule();
|
||||
const logger: LanceDbRuntimeLogger = {
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
};
|
||||
const importResolved = vi.fn(async () => runtimeModule);
|
||||
const resolveRuntimeEntry = vi.fn(() => null);
|
||||
const installRuntime = vi.fn(
|
||||
async ({ runtimeDir }: { runtimeDir: string }) =>
|
||||
`${runtimeDir}/node_modules/@lancedb/lancedb/index.js`,
|
||||
);
|
||||
const loader = createLoader({
|
||||
importResolved,
|
||||
resolveRuntimeEntry,
|
||||
installRuntime,
|
||||
});
|
||||
|
||||
await expect(loader.load(logger)).resolves.toBe(runtimeModule);
|
||||
|
||||
expect(installRuntime).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runtimeDir: "/tmp/openclaw-state/plugin-runtimes/memory-lancedb/lancedb",
|
||||
manifest: TEST_RUNTIME_MANIFEST,
|
||||
}),
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"installing runtime deps under /tmp/openclaw-state/plugin-runtimes/memory-lancedb/lancedb",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails fast in nix mode instead of attempting auto-install", async () => {
|
||||
const installRuntime = vi.fn(
|
||||
async ({ runtimeDir }: { runtimeDir: string }) =>
|
||||
`${runtimeDir}/node_modules/@lancedb/lancedb/index.js`,
|
||||
);
|
||||
const loader = createLoader({
|
||||
env: { OPENCLAW_NIX_MODE: "1" } as NodeJS.ProcessEnv,
|
||||
installRuntime,
|
||||
});
|
||||
|
||||
await expect(loader.load()).rejects.toThrow(
|
||||
"memory-lancedb: failed to load LanceDB and Nix mode disables auto-install.",
|
||||
);
|
||||
expect(installRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears the cached failure so later calls can retry the install", async () => {
|
||||
const runtimeModule = createMockModule();
|
||||
const installRuntime = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("network down"))
|
||||
.mockResolvedValueOnce(
|
||||
"/tmp/openclaw-state/plugin-runtimes/memory-lancedb/lancedb/node_modules/@lancedb/lancedb/index.js",
|
||||
);
|
||||
const importResolved = vi.fn(async () => runtimeModule);
|
||||
const loader = createLoader({
|
||||
installRuntime,
|
||||
importResolved,
|
||||
});
|
||||
|
||||
await expect(loader.load()).rejects.toThrow("network down");
|
||||
await expect(loader.load()).resolves.toBe(runtimeModule);
|
||||
|
||||
expect(installRuntime).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { resolveStateDir } from "./api.js";
|
||||
|
||||
type LanceDbModule = typeof import("@lancedb/lancedb");
|
||||
|
||||
export type LanceDbRuntimeLogger = {
|
||||
info?: (message: string) => void;
|
||||
warn?: (message: string) => void;
|
||||
};
|
||||
|
||||
type RuntimeManifest = {
|
||||
name: string;
|
||||
private: true;
|
||||
type: "module";
|
||||
dependencies: Record<string, string>;
|
||||
};
|
||||
|
||||
type LanceDbRuntimeLoaderDeps = {
|
||||
env: NodeJS.ProcessEnv;
|
||||
resolveStateDir: (env?: NodeJS.ProcessEnv, homedir?: () => string) => string;
|
||||
runtimeManifest: RuntimeManifest;
|
||||
importBundled: () => Promise<LanceDbModule>;
|
||||
importResolved: (resolvedPath: string) => Promise<LanceDbModule>;
|
||||
resolveRuntimeEntry: (params: { runtimeDir: string; manifest: RuntimeManifest }) => string | null;
|
||||
installRuntime: (params: {
|
||||
runtimeDir: string;
|
||||
manifest: RuntimeManifest;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger?: LanceDbRuntimeLogger;
|
||||
}) => Promise<string>;
|
||||
};
|
||||
|
||||
const MEMORY_LANCEDB_RUNTIME_MANIFEST: RuntimeManifest = (() => {
|
||||
const packageJson = JSON.parse(
|
||||
fs.readFileSync(new URL("./package.json", import.meta.url), "utf8"),
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
};
|
||||
const lanceDbSpec = packageJson.dependencies?.["@lancedb/lancedb"];
|
||||
if (!lanceDbSpec) {
|
||||
throw new Error('memory-lancedb package.json is missing "@lancedb/lancedb"');
|
||||
}
|
||||
return {
|
||||
name: "openclaw-memory-lancedb-runtime",
|
||||
private: true,
|
||||
type: "module",
|
||||
dependencies: {
|
||||
"@lancedb/lancedb": lanceDbSpec,
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
function resolveRuntimeDir(stateDir: string): string {
|
||||
return path.join(stateDir, "plugin-runtimes", "memory-lancedb", "lancedb");
|
||||
}
|
||||
|
||||
function readRuntimeManifest(filePath: string): RuntimeManifest | null {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8")) as RuntimeManifest;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function manifestsMatch(actual: RuntimeManifest | null, expected: RuntimeManifest): boolean {
|
||||
if (!actual) {
|
||||
return false;
|
||||
}
|
||||
return JSON.stringify(actual) === JSON.stringify(expected);
|
||||
}
|
||||
|
||||
function defaultResolveRuntimeEntry(params: {
|
||||
runtimeDir: string;
|
||||
manifest: RuntimeManifest;
|
||||
}): string | null {
|
||||
const runtimePackagePath = path.join(params.runtimeDir, "package.json");
|
||||
if (!manifestsMatch(readRuntimeManifest(runtimePackagePath), params.manifest)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const runtimeRequire = createRequire(runtimePackagePath);
|
||||
return runtimeRequire.resolve("@lancedb/lancedb");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function collectSpawnOutput(params: {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}): Promise<{ code: number | null; stdout: string; stderr: string; error?: Error }> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(params.command, params.args, {
|
||||
cwd: params.cwd,
|
||||
env: params.env,
|
||||
shell: process.platform === "win32",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk: Buffer | string) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk: Buffer | string) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
resolve({ code: null, stdout, stderr, error });
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
resolve({ code, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function defaultInstallRuntime(params: {
|
||||
runtimeDir: string;
|
||||
manifest: RuntimeManifest;
|
||||
env: NodeJS.ProcessEnv;
|
||||
logger?: LanceDbRuntimeLogger;
|
||||
}): Promise<string> {
|
||||
const runtimePackagePath = path.join(params.runtimeDir, "package.json");
|
||||
const currentManifest = readRuntimeManifest(runtimePackagePath);
|
||||
if (!manifestsMatch(currentManifest, params.manifest)) {
|
||||
await fs.promises.rm(path.join(params.runtimeDir, "node_modules"), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
await fs.promises.rm(path.join(params.runtimeDir, "package-lock.json"), { force: true });
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(params.runtimeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
runtimePackagePath,
|
||||
`${JSON.stringify(params.manifest, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const install = await collectSpawnOutput({
|
||||
command: "npm",
|
||||
args: ["install", "--omit=dev", "--silent", "--ignore-scripts", "--package-lock=false"],
|
||||
cwd: params.runtimeDir,
|
||||
env: params.env,
|
||||
});
|
||||
if (install.error) {
|
||||
const spawnError = install.error as NodeJS.ErrnoException;
|
||||
throw new Error(
|
||||
spawnError.code === "ENOENT"
|
||||
? "npm is required to install the LanceDB runtime but was not found on PATH"
|
||||
: install.error.message,
|
||||
);
|
||||
}
|
||||
if ((install.code ?? 0) !== 0) {
|
||||
const detail = install.stderr.trim() || install.stdout.trim();
|
||||
throw new Error(detail || `npm exited with code ${install.code ?? "unknown"}`);
|
||||
}
|
||||
|
||||
const resolved = defaultResolveRuntimeEntry({
|
||||
runtimeDir: params.runtimeDir,
|
||||
manifest: params.manifest,
|
||||
});
|
||||
if (!resolved) {
|
||||
throw new Error("installed LanceDB runtime is missing the @lancedb/lancedb entry");
|
||||
}
|
||||
params.logger?.info?.(`memory-lancedb: installed LanceDB runtime under ${params.runtimeDir}`);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function defaultImportResolved(resolvedPath: string): Promise<LanceDbModule> {
|
||||
return import(pathToFileURL(resolvedPath).href);
|
||||
}
|
||||
|
||||
function buildLoadFailureMessage(prefix: string, error: unknown): string {
|
||||
return `memory-lancedb: ${prefix}. ${String(error)}`;
|
||||
}
|
||||
|
||||
export function createLanceDbRuntimeLoader(overrides: Partial<LanceDbRuntimeLoaderDeps> = {}): {
|
||||
load: (logger?: LanceDbRuntimeLogger) => Promise<LanceDbModule>;
|
||||
} {
|
||||
const deps: LanceDbRuntimeLoaderDeps = {
|
||||
env: overrides.env ?? process.env,
|
||||
resolveStateDir: overrides.resolveStateDir ?? resolveStateDir,
|
||||
runtimeManifest: overrides.runtimeManifest ?? MEMORY_LANCEDB_RUNTIME_MANIFEST,
|
||||
importBundled: overrides.importBundled ?? (() => import("@lancedb/lancedb")),
|
||||
importResolved: overrides.importResolved ?? defaultImportResolved,
|
||||
resolveRuntimeEntry: overrides.resolveRuntimeEntry ?? defaultResolveRuntimeEntry,
|
||||
installRuntime: overrides.installRuntime ?? defaultInstallRuntime,
|
||||
};
|
||||
|
||||
let loadPromise: Promise<LanceDbModule> | null = null;
|
||||
|
||||
return {
|
||||
async load(logger?: LanceDbRuntimeLogger): Promise<LanceDbModule> {
|
||||
if (!loadPromise) {
|
||||
loadPromise = (async () => {
|
||||
try {
|
||||
return await deps.importBundled();
|
||||
} catch (bundledError) {
|
||||
const runtimeDir = resolveRuntimeDir(
|
||||
deps.resolveStateDir(deps.env, () =>
|
||||
deps.env.HOME?.trim() ? deps.env.HOME : os.homedir(),
|
||||
),
|
||||
);
|
||||
const existingRuntime = deps.resolveRuntimeEntry({
|
||||
runtimeDir,
|
||||
manifest: deps.runtimeManifest,
|
||||
});
|
||||
if (existingRuntime) {
|
||||
try {
|
||||
return await deps.importResolved(existingRuntime);
|
||||
} catch {
|
||||
// Reinstall below when the cached runtime is incomplete or stale.
|
||||
}
|
||||
}
|
||||
if (deps.env.OPENCLAW_NIX_MODE === "1") {
|
||||
throw new Error(
|
||||
buildLoadFailureMessage(
|
||||
"failed to load LanceDB and Nix mode disables auto-install",
|
||||
bundledError,
|
||||
),
|
||||
{ cause: bundledError },
|
||||
);
|
||||
}
|
||||
logger?.warn?.(
|
||||
`memory-lancedb: bundled LanceDB runtime unavailable (${String(bundledError)}); installing runtime deps under ${runtimeDir}`,
|
||||
);
|
||||
const installedEntry = await deps.installRuntime({
|
||||
runtimeDir,
|
||||
manifest: deps.runtimeManifest,
|
||||
env: deps.env,
|
||||
logger,
|
||||
});
|
||||
try {
|
||||
return await deps.importResolved(installedEntry);
|
||||
} catch (runtimeError) {
|
||||
throw new Error(
|
||||
buildLoadFailureMessage(
|
||||
"failed to load LanceDB after installing runtime deps",
|
||||
runtimeError,
|
||||
),
|
||||
{ cause: runtimeError },
|
||||
);
|
||||
}
|
||||
}
|
||||
})().catch((error) => {
|
||||
loadPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return await loadPromise;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const defaultLoader = createLanceDbRuntimeLoader();
|
||||
|
||||
export async function loadLanceDbModule(logger?: LanceDbRuntimeLogger): Promise<LanceDbModule> {
|
||||
return await defaultLoader.load(logger);
|
||||
}
|
||||
@@ -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[];
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-en
|
||||
import {
|
||||
applyModelStudioConfig,
|
||||
applyModelStudioConfigCn,
|
||||
applyModelStudioStandardConfig,
|
||||
applyModelStudioStandardConfigCn,
|
||||
MODELSTUDIO_DEFAULT_MODEL_REF,
|
||||
} from "./onboard.js";
|
||||
import { buildModelStudioProvider } from "./provider-catalog.js";
|
||||
@@ -16,6 +18,50 @@ export default defineSingleProviderPluginEntry({
|
||||
label: "Model Studio",
|
||||
docsPath: "/providers/models",
|
||||
auth: [
|
||||
{
|
||||
methodId: "standard-api-key-cn",
|
||||
label: "Standard API Key for China (pay-as-you-go)",
|
||||
hint: "Endpoint: dashscope.aliyuncs.com",
|
||||
optionKey: "modelstudioStandardApiKeyCn",
|
||||
flagName: "--modelstudio-standard-api-key-cn",
|
||||
envVar: "MODELSTUDIO_API_KEY",
|
||||
promptMessage: "Enter Alibaba Cloud Model Studio API key (China)",
|
||||
defaultModel: MODELSTUDIO_DEFAULT_MODEL_REF,
|
||||
applyConfig: (cfg) => applyModelStudioStandardConfigCn(cfg),
|
||||
noteMessage: [
|
||||
"Get your API key at: https://bailian.console.aliyun.com/",
|
||||
"Endpoint: dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"Models: qwen3.5-plus, qwen3-coder-plus, qwen3-coder-next, etc.",
|
||||
].join("\n"),
|
||||
noteTitle: "Alibaba Cloud Model Studio Standard (China)",
|
||||
wizard: {
|
||||
choiceHint: "Endpoint: dashscope.aliyuncs.com",
|
||||
groupLabel: "Qwen (Alibaba Cloud Model Studio)",
|
||||
groupHint: "Standard / Coding Plan (CN / Global)",
|
||||
},
|
||||
},
|
||||
{
|
||||
methodId: "standard-api-key",
|
||||
label: "Standard API Key for Global/Intl (pay-as-you-go)",
|
||||
hint: "Endpoint: dashscope-intl.aliyuncs.com",
|
||||
optionKey: "modelstudioStandardApiKey",
|
||||
flagName: "--modelstudio-standard-api-key",
|
||||
envVar: "MODELSTUDIO_API_KEY",
|
||||
promptMessage: "Enter Alibaba Cloud Model Studio API key (Global/Intl)",
|
||||
defaultModel: MODELSTUDIO_DEFAULT_MODEL_REF,
|
||||
applyConfig: (cfg) => applyModelStudioStandardConfig(cfg),
|
||||
noteMessage: [
|
||||
"Get your API key at: https://modelstudio.console.alibabacloud.com/",
|
||||
"Endpoint: dashscope-intl.aliyuncs.com/compatible-mode/v1",
|
||||
"Models: qwen3.5-plus, qwen3-coder-plus, qwen3-coder-next, etc.",
|
||||
].join("\n"),
|
||||
noteTitle: "Alibaba Cloud Model Studio Standard (Global/Intl)",
|
||||
wizard: {
|
||||
choiceHint: "Endpoint: dashscope-intl.aliyuncs.com",
|
||||
groupLabel: "Qwen (Alibaba Cloud Model Studio)",
|
||||
groupHint: "Standard / Coding Plan (CN / Global)",
|
||||
},
|
||||
},
|
||||
{
|
||||
methodId: "api-key-cn",
|
||||
label: "Coding Plan API Key for China (subscription)",
|
||||
@@ -29,13 +75,13 @@ export default defineSingleProviderPluginEntry({
|
||||
noteMessage: [
|
||||
"Get your API key at: https://bailian.console.aliyun.com/",
|
||||
"Endpoint: coding.dashscope.aliyuncs.com",
|
||||
"Models: qwen3.5-plus, glm-4.7, kimi-k2.5, MiniMax-M2.5, etc.",
|
||||
"Models: qwen3.5-plus, glm-5, kimi-k2.5, MiniMax-M2.5, etc.",
|
||||
].join("\n"),
|
||||
noteTitle: "Alibaba Cloud Model Studio Coding Plan (China)",
|
||||
wizard: {
|
||||
choiceHint: "Endpoint: coding.dashscope.aliyuncs.com",
|
||||
groupLabel: "Alibaba Cloud Model Studio",
|
||||
groupHint: "Coding Plan API key (CN / Global)",
|
||||
groupLabel: "Qwen (Alibaba Cloud Model Studio)",
|
||||
groupHint: "Standard / Coding Plan (CN / Global)",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -51,13 +97,13 @@ export default defineSingleProviderPluginEntry({
|
||||
noteMessage: [
|
||||
"Get your API key at: https://bailian.console.aliyun.com/",
|
||||
"Endpoint: coding-intl.dashscope.aliyuncs.com",
|
||||
"Models: qwen3.5-plus, glm-4.7, kimi-k2.5, MiniMax-M2.5, etc.",
|
||||
"Models: qwen3.5-plus, glm-5, kimi-k2.5, MiniMax-M2.5, etc.",
|
||||
].join("\n"),
|
||||
noteTitle: "Alibaba Cloud Model Studio Coding Plan (Global/Intl)",
|
||||
wizard: {
|
||||
choiceHint: "Endpoint: coding-intl.dashscope.aliyuncs.com",
|
||||
groupLabel: "Alibaba Cloud Model Studio",
|
||||
groupHint: "Coding Plan API key (CN / Global)",
|
||||
groupLabel: "Qwen (Alibaba Cloud Model Studio)",
|
||||
groupHint: "Standard / Coding Plan (CN / Global)",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -2,6 +2,9 @@ import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-models"
|
||||
|
||||
export const MODELSTUDIO_CN_BASE_URL = "https://coding.dashscope.aliyuncs.com/v1";
|
||||
export const MODELSTUDIO_GLOBAL_BASE_URL = "https://coding-intl.dashscope.aliyuncs.com/v1";
|
||||
export const MODELSTUDIO_STANDARD_CN_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1";
|
||||
export const MODELSTUDIO_STANDARD_GLOBAL_BASE_URL =
|
||||
"https://dashscope-intl.aliyuncs.com/compatible-mode/v1";
|
||||
export const MODELSTUDIO_DEFAULT_MODEL_ID = "qwen3.5-plus";
|
||||
export const MODELSTUDIO_DEFAULT_MODEL_REF = `modelstudio/${MODELSTUDIO_DEFAULT_MODEL_ID}`;
|
||||
export const MODELSTUDIO_DEFAULT_COST = {
|
||||
|
||||
@@ -6,10 +6,18 @@ import {
|
||||
MODELSTUDIO_CN_BASE_URL,
|
||||
MODELSTUDIO_DEFAULT_MODEL_REF,
|
||||
MODELSTUDIO_GLOBAL_BASE_URL,
|
||||
MODELSTUDIO_STANDARD_CN_BASE_URL,
|
||||
MODELSTUDIO_STANDARD_GLOBAL_BASE_URL,
|
||||
} from "./model-definitions.js";
|
||||
import { buildModelStudioProvider } from "./provider-catalog.js";
|
||||
|
||||
export { MODELSTUDIO_CN_BASE_URL, MODELSTUDIO_DEFAULT_MODEL_REF, MODELSTUDIO_GLOBAL_BASE_URL };
|
||||
export {
|
||||
MODELSTUDIO_CN_BASE_URL,
|
||||
MODELSTUDIO_DEFAULT_MODEL_REF,
|
||||
MODELSTUDIO_GLOBAL_BASE_URL,
|
||||
MODELSTUDIO_STANDARD_CN_BASE_URL,
|
||||
MODELSTUDIO_STANDARD_GLOBAL_BASE_URL,
|
||||
};
|
||||
|
||||
const modelStudioPresetAppliers = createModelCatalogPresetAppliers<[string]>({
|
||||
primaryModelRef: MODELSTUDIO_DEFAULT_MODEL_REF,
|
||||
@@ -43,3 +51,19 @@ export function applyModelStudioConfig(cfg: OpenClawConfig): OpenClawConfig {
|
||||
export function applyModelStudioConfigCn(cfg: OpenClawConfig): OpenClawConfig {
|
||||
return modelStudioPresetAppliers.applyConfig(cfg, MODELSTUDIO_CN_BASE_URL);
|
||||
}
|
||||
|
||||
export function applyModelStudioStandardProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
|
||||
return modelStudioPresetAppliers.applyProviderConfig(cfg, MODELSTUDIO_STANDARD_GLOBAL_BASE_URL);
|
||||
}
|
||||
|
||||
export function applyModelStudioStandardProviderConfigCn(cfg: OpenClawConfig): OpenClawConfig {
|
||||
return modelStudioPresetAppliers.applyProviderConfig(cfg, MODELSTUDIO_STANDARD_CN_BASE_URL);
|
||||
}
|
||||
|
||||
export function applyModelStudioStandardConfig(cfg: OpenClawConfig): OpenClawConfig {
|
||||
return modelStudioPresetAppliers.applyConfig(cfg, MODELSTUDIO_STANDARD_GLOBAL_BASE_URL);
|
||||
}
|
||||
|
||||
export function applyModelStudioStandardConfigCn(cfg: OpenClawConfig): OpenClawConfig {
|
||||
return modelStudioPresetAppliers.applyConfig(cfg, MODELSTUDIO_STANDARD_CN_BASE_URL);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,34 @@
|
||||
"modelstudio": ["MODELSTUDIO_API_KEY"]
|
||||
},
|
||||
"providerAuthChoices": [
|
||||
{
|
||||
"provider": "modelstudio",
|
||||
"method": "standard-api-key-cn",
|
||||
"choiceId": "modelstudio-standard-api-key-cn",
|
||||
"choiceLabel": "Standard API Key for China (pay-as-you-go)",
|
||||
"choiceHint": "Endpoint: dashscope.aliyuncs.com",
|
||||
"groupId": "modelstudio",
|
||||
"groupLabel": "Qwen (Alibaba Cloud Model Studio)",
|
||||
"groupHint": "Standard / Coding Plan (CN / Global)",
|
||||
"optionKey": "modelstudioStandardApiKeyCn",
|
||||
"cliFlag": "--modelstudio-standard-api-key-cn",
|
||||
"cliOption": "--modelstudio-standard-api-key-cn <key>",
|
||||
"cliDescription": "Alibaba Cloud Model Studio Standard API key (China)"
|
||||
},
|
||||
{
|
||||
"provider": "modelstudio",
|
||||
"method": "standard-api-key",
|
||||
"choiceId": "modelstudio-standard-api-key",
|
||||
"choiceLabel": "Standard API Key for Global/Intl (pay-as-you-go)",
|
||||
"choiceHint": "Endpoint: dashscope-intl.aliyuncs.com",
|
||||
"groupId": "modelstudio",
|
||||
"groupLabel": "Qwen (Alibaba Cloud Model Studio)",
|
||||
"groupHint": "Standard / Coding Plan (CN / Global)",
|
||||
"optionKey": "modelstudioStandardApiKey",
|
||||
"cliFlag": "--modelstudio-standard-api-key",
|
||||
"cliOption": "--modelstudio-standard-api-key <key>",
|
||||
"cliDescription": "Alibaba Cloud Model Studio Standard API key (Global/Intl)"
|
||||
},
|
||||
{
|
||||
"provider": "modelstudio",
|
||||
"method": "api-key-cn",
|
||||
@@ -12,8 +40,8 @@
|
||||
"choiceLabel": "Coding Plan API Key for China (subscription)",
|
||||
"choiceHint": "Endpoint: coding.dashscope.aliyuncs.com",
|
||||
"groupId": "modelstudio",
|
||||
"groupLabel": "Alibaba Cloud Model Studio",
|
||||
"groupHint": "Coding Plan API key (CN / Global)",
|
||||
"groupLabel": "Qwen (Alibaba Cloud Model Studio)",
|
||||
"groupHint": "Standard / Coding Plan (CN / Global)",
|
||||
"optionKey": "modelstudioApiKeyCn",
|
||||
"cliFlag": "--modelstudio-api-key-cn",
|
||||
"cliOption": "--modelstudio-api-key-cn <key>",
|
||||
@@ -26,8 +54,8 @@
|
||||
"choiceLabel": "Coding Plan API Key for Global/Intl (subscription)",
|
||||
"choiceHint": "Endpoint: coding-intl.dashscope.aliyuncs.com",
|
||||
"groupId": "modelstudio",
|
||||
"groupLabel": "Alibaba Cloud Model Studio",
|
||||
"groupHint": "Coding Plan API key (CN / Global)",
|
||||
"groupLabel": "Qwen (Alibaba Cloud Model Studio)",
|
||||
"groupHint": "Standard / Coding Plan (CN / Global)",
|
||||
"optionKey": "modelstudioApiKey",
|
||||
"cliFlag": "--modelstudio-api-key",
|
||||
"cliOption": "--modelstudio-api-key <key>",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
ensureGlobalUndiciEnvProxyDispatcher: vi.fn(),
|
||||
getOAuthApiKey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/infra-runtime", () => ({
|
||||
ensureGlobalUndiciEnvProxyDispatcher: mocks.ensureGlobalUndiciEnvProxyDispatcher,
|
||||
}));
|
||||
|
||||
vi.mock("@mariozechner/pi-ai/oauth", () => ({
|
||||
getOAuthApiKey: mocks.getOAuthApiKey,
|
||||
}));
|
||||
|
||||
import { getOAuthApiKey } from "./openai-codex-provider.runtime.js";
|
||||
|
||||
describe("openai-codex-provider.runtime", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("bootstraps the env proxy dispatcher before refreshing oauth credentials", async () => {
|
||||
const refreshed = {
|
||||
newCredentials: {
|
||||
access: "next-access",
|
||||
refresh: "next-refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
};
|
||||
mocks.getOAuthApiKey.mockResolvedValue(refreshed);
|
||||
|
||||
await expect(
|
||||
getOAuthApiKey("openai-codex", {
|
||||
"openai-codex": {
|
||||
provider: "openai-codex",
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now(),
|
||||
},
|
||||
}),
|
||||
).resolves.toBe(refreshed);
|
||||
|
||||
expect(mocks.ensureGlobalUndiciEnvProxyDispatcher).toHaveBeenCalledOnce();
|
||||
expect(mocks.getOAuthApiKey).toHaveBeenCalledOnce();
|
||||
expect(mocks.ensureGlobalUndiciEnvProxyDispatcher.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mocks.getOAuthApiKey.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1 +1,9 @@
|
||||
export { getOAuthApiKey } from "@mariozechner/pi-ai/oauth";
|
||||
import { getOAuthApiKey as getOAuthApiKeyFromPi } from "@mariozechner/pi-ai/oauth";
|
||||
import { ensureGlobalUndiciEnvProxyDispatcher } from "openclaw/plugin-sdk/infra-runtime";
|
||||
|
||||
export async function getOAuthApiKey(
|
||||
...args: Parameters<typeof getOAuthApiKeyFromPi>
|
||||
): Promise<Awaited<ReturnType<typeof getOAuthApiKeyFromPi>>> {
|
||||
ensureGlobalUndiciEnvProxyDispatcher();
|
||||
return await getOAuthApiKeyFromPi(...args);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -172,6 +192,11 @@ describe("slackPlugin outbound", () => {
|
||||
},
|
||||
};
|
||||
|
||||
it("advertises the 8000-character Slack default chunk limit", () => {
|
||||
expect(slackOutbound.textChunkLimit).toBe(8000);
|
||||
expect(slackPlugin.outbound?.textChunkLimit).toBe(8000);
|
||||
});
|
||||
|
||||
it("uses threadId as threadTs fallback for sendText", async () => {
|
||||
const sendSlack = vi.fn().mockResolvedValue({ messageId: "m-text" });
|
||||
const sendText = requireSlackSendText();
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
} from "./directory-config.js";
|
||||
import { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy } from "./group-policy.js";
|
||||
import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js";
|
||||
import { SLACK_TEXT_LIMIT } from "./limits.js";
|
||||
import { normalizeAllowListLower } from "./monitor/allow-list.js";
|
||||
import type { SlackProbe } from "./probe.js";
|
||||
import { resolveSlackUserAllowlist } from "./resolve-users.js";
|
||||
@@ -602,7 +603,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
|
||||
base: {
|
||||
deliveryMode: "direct",
|
||||
chunker: null,
|
||||
textChunkLimit: 4000,
|
||||
textChunkLimit: SLACK_TEXT_LIMIT,
|
||||
},
|
||||
attachedResults: {
|
||||
channel: "slack",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -98,6 +98,24 @@ describe("createSlackDraftStream", () => {
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows a 4205-character preview with the default max chars", async () => {
|
||||
const { stream, send, warn } = createDraftStreamHarness();
|
||||
const text = "a".repeat(4205);
|
||||
|
||||
stream.update(text);
|
||||
await stream.flush();
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
"channel:C123",
|
||||
text,
|
||||
expect.objectContaining({
|
||||
token: "xoxb-test",
|
||||
}),
|
||||
);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clear removes preview message when one exists", async () => {
|
||||
const { stream, remove } = createDraftStreamHarness();
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createDraftStreamLoop } from "openclaw/plugin-sdk/channel-lifecycle";
|
||||
import { deleteSlackMessage, editSlackMessage } from "./actions.js";
|
||||
import { SLACK_TEXT_LIMIT } from "./limits.js";
|
||||
import { sendMessageSlack } from "./send.js";
|
||||
|
||||
const SLACK_STREAM_MAX_CHARS = 4000;
|
||||
const DEFAULT_THROTTLE_MS = 1000;
|
||||
|
||||
export type SlackDraftStream = {
|
||||
@@ -29,7 +29,7 @@ export function createSlackDraftStream(params: {
|
||||
edit?: typeof editSlackMessage;
|
||||
remove?: typeof deleteSlackMessage;
|
||||
}): SlackDraftStream {
|
||||
const maxChars = Math.min(params.maxChars ?? SLACK_STREAM_MAX_CHARS, SLACK_STREAM_MAX_CHARS);
|
||||
const maxChars = Math.min(params.maxChars ?? SLACK_TEXT_LIMIT, SLACK_TEXT_LIMIT);
|
||||
const throttleMs = Math.max(250, params.throttleMs ?? DEFAULT_THROTTLE_MS);
|
||||
const send = params.send ?? sendMessageSlack;
|
||||
const edit = params.edit ?? editSlackMessage;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const SLACK_TEXT_LIMIT = 8000;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user