Compare commits

..

2 Commits

Author SHA1 Message Date
Tak Hoffman
949c09f408 fix: satisfy check formatting and lint for plugin tool-call hooks 2026-02-12 18:19:38 -06:00
Patrick Barletta
59f9da02b4 feat: dispatch before_tool_call and after_tool_call hooks from both tool execution paths
The typed plugin hook system (registry.typedHooks) supports before_tool_call
and after_tool_call hooks, but they were only partially wired up:

- pi-tool-definition-adapter: had before_tool_call via runBeforeToolCallHook
  but was missing after_tool_call entirely
- pi-embedded-subscribe: had neither hook dispatched

This adds full before_tool_call and after_tool_call dispatch to both code
paths, enabling plugins registered via api.on() to observe all tool
executions. The after_tool_call hook fires on both success and error paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 16:35:31 -08:00
443 changed files with 3628 additions and 18872 deletions

View File

@@ -110,9 +110,9 @@ Before any substantive review or prep work, **always rebase the PR branch onto c
- During `prepare-pr`, use this commit subject format: `fix: <summary> (openclaw#<PR>) thanks @<pr-author>`.
- Group related changes; avoid bundling unrelated refactors.
- Changelog workflow: keep the latest released version at the top (no `Unreleased`); after publishing, bump the version and start a new top section.
- When working on a PR: add a changelog entry with the PR number and thank the contributor (mandatory in this workflow).
- When working on a PR: add a changelog entry with the PR number and thank the contributor.
- When working on an issue: reference the issue in the changelog entry.
- In this workflow, changelog is always required even for internal/test-only changes.
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
## Gate policy
@@ -233,7 +233,7 @@ Go or no-go checklist before merge:
- All BLOCKER and IMPORTANT findings are resolved.
- Verification is meaningful and regression risk is acceptably low.
- Changelog is updated (mandatory) and docs are updated when required.
- Docs and changelog are updated when required.
- Required CI checks are green and the branch is not behind `main`.
Expected output:

View File

@@ -67,7 +67,7 @@ jq -r '.findings[] | select(.severity=="BLOCKER" or .severity=="IMPORTANT") | "-
Fix all required findings. Keep scope tight.
3. Update changelog/docs (changelog is mandatory in this workflow)
3. Update changelog/docs when required
```sh
jq -r '.changelog' .local/review.json

View File

@@ -123,7 +123,7 @@ Minimum JSON shape:
"result": "pass"
},
"docs": "up_to_date|missing|not_applicable",
"changelog": "required"
"changelog": "required|not_required"
}
```

View File

@@ -60,47 +60,22 @@ jobs:
},
];
const triggerLabel = "trigger-response";
const target = context.payload.issue ?? context.payload.pull_request;
if (!target) {
return;
}
const labelSet = new Set(
(target.labels ?? [])
.map((label) => (typeof label === "string" ? label : label?.name))
.filter((name) => typeof name === "string"),
);
const hasTriggerLabel = labelSet.has(triggerLabel);
if (hasTriggerLabel) {
labelSet.delete(triggerLabel);
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: target.number,
name: triggerLabel,
});
} catch (error) {
if (error?.status !== 404) {
throw error;
}
}
}
if (!hasTriggerLabel) {
return;
}
const issue = context.payload.issue;
if (issue) {
const title = issue.title ?? "";
const body = issue.body ?? "";
const haystack = `${title}\n${body}`.toLowerCase();
const hasMoltbookLabel = labelSet.has("r: moltbook");
const hasTestflightLabel = labelSet.has("r: testflight");
const hasSecurityLabel = labelSet.has("security");
const hasMoltbookLabel = (issue.labels ?? []).some((label) =>
typeof label === "string" ? label === "r: moltbook" : label?.name === "r: moltbook",
);
const hasTestflightLabel = (issue.labels ?? []).some((label) =>
typeof label === "string"
? label === "r: testflight"
: label?.name === "r: testflight",
);
const hasSecurityLabel = (issue.labels ?? []).some((label) =>
typeof label === "string" ? label === "security" : label?.name === "security",
);
if (title.toLowerCase().includes("security") && !hasSecurityLabel) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
@@ -108,7 +83,7 @@ jobs:
issue_number: issue.number,
labels: ["security"],
});
labelSet.add("security");
return;
}
if (title.toLowerCase().includes("testflight") && !hasTestflightLabel) {
await github.rest.issues.addLabels({
@@ -117,7 +92,7 @@ jobs:
issue_number: issue.number,
labels: ["r: testflight"],
});
labelSet.add("r: testflight");
return;
}
if (haystack.includes("moltbook") && !hasMoltbookLabel) {
await github.rest.issues.addLabels({
@@ -126,36 +101,24 @@ jobs:
issue_number: issue.number,
labels: ["r: moltbook"],
});
labelSet.add("r: moltbook");
}
}
const pullRequest = context.payload.pull_request;
if (pullRequest) {
const labelCount = labelSet.size;
if (labelCount > 20) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
body: "Closing this PR because it has more than 20 labels, which usually means the branch is too noisy. Please recreate the PR from a clean branch.",
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
state: "closed",
});
return;
}
}
const rule = rules.find((item) => labelSet.has(item.label));
const labelName = context.payload.label?.name;
if (!labelName) {
return;
}
const rule = rules.find((item) => item.label === labelName);
if (!rule) {
return;
}
const issueNumber = target.number;
const issueNumber = context.payload.issue?.number ?? context.payload.pull_request?.number;
if (!issueNumber) {
return;
}
await github.rest.issues.createComment({
owner: context.repo.owner,

View File

@@ -200,28 +200,9 @@ jobs:
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
- name: Configure vitest JSON reports
if: matrix.task == 'test' && matrix.runtime == 'node'
run: echo "OPENCLAW_VITEST_REPORT_DIR=$RUNNER_TEMP/vitest-reports" >> "$GITHUB_ENV"
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
run: ${{ matrix.command }}
- name: Summarize slowest tests
if: matrix.task == 'test' && matrix.runtime == 'node'
run: |
node scripts/vitest-slowest.mjs --dir "$OPENCLAW_VITEST_REPORT_DIR" --top 50 --out "$RUNNER_TEMP/vitest-slowest.md" > /dev/null
echo "Slowest test summary written to $RUNNER_TEMP/vitest-slowest.md"
- name: Upload vitest reports
if: matrix.task == 'test' && matrix.runtime == 'node'
uses: actions/upload-artifact@v4
with:
name: vitest-reports-${{ runner.os }}-${{ matrix.runtime }}
path: |
${{ env.OPENCLAW_VITEST_REPORT_DIR }}
${{ runner.temp }}/vitest-slowest.md
# Types, lint, and format check.
check:
name: "check"
@@ -383,28 +364,9 @@ jobs:
pnpm -v
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
- name: Configure vitest JSON reports
if: matrix.task == 'test'
run: echo "OPENCLAW_VITEST_REPORT_DIR=$RUNNER_TEMP/vitest-reports" >> "$GITHUB_ENV"
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
run: ${{ matrix.command }}
- name: Summarize slowest tests
if: matrix.task == 'test'
run: |
node scripts/vitest-slowest.mjs --dir "$OPENCLAW_VITEST_REPORT_DIR" --top 50 --out "$RUNNER_TEMP/vitest-slowest.md" > /dev/null
echo "Slowest test summary written to $RUNNER_TEMP/vitest-slowest.md"
- name: Upload vitest reports
if: matrix.task == 'test'
uses: actions/upload-artifact@v4
with:
name: vitest-reports-${{ runner.os }}-${{ matrix.runtime }}
path: |
${{ env.OPENCLAW_VITEST_REPORT_DIR }}
${{ runner.temp }}/vitest-slowest.md
# Consolidated macOS job: runs TS tests + Swift lint/build/test sequentially
# on a single runner. GitHub limits macOS concurrent jobs to 5 per org;
# running 4 separate jobs per PR (as before) starved the queue. One job

View File

@@ -5,16 +5,6 @@ on:
types: [opened, synchronize, reopened]
issues:
types: [opened]
workflow_dispatch:
inputs:
max_prs:
description: "Maximum number of open PRs to process (0 = all)"
required: false
default: "200"
per_page:
description: "PRs per page (1-100)"
required: false
default: "50"
permissions: {}
@@ -46,7 +36,7 @@ jobs:
}
const sizeLabels = ["size: XS", "size: S", "size: M", "size: L", "size: XL"];
const labelColor = "b76e79";
const labelColor = "fbca04";
for (const label of sizeLabels) {
try {
@@ -124,7 +114,7 @@ jobs:
issue_number: pullRequest.number,
labels: [targetSizeLabel],
});
- name: Apply maintainer or trusted-contributor label
- name: Apply maintainer label for org members
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
github-token: ${{ steps.app-token.outputs.token }}
@@ -134,12 +124,6 @@ jobs:
return;
}
const repo = `${context.repo.owner}/${context.repo.repo}`;
const trustedLabel = "trusted-contributor";
const experiencedLabel = "experienced-contributor";
const trustedThreshold = 4;
const experiencedThreshold = 10;
let isMaintainer = false;
try {
const membership = await github.rest.teams.getMembershipForUserInOrg({
@@ -154,288 +138,15 @@ jobs:
}
}
if (isMaintainer) {
await github.rest.issues.addLabels({
...context.repo,
issue_number: context.payload.pull_request.number,
labels: ["maintainer"],
});
if (!isMaintainer) {
return;
}
const mergedQuery = `repo:${repo} is:pr is:merged author:${login}`;
let mergedCount = 0;
try {
const merged = await github.rest.search.issuesAndPullRequests({
q: mergedQuery,
per_page: 1,
});
mergedCount = merged?.data?.total_count ?? 0;
} catch (error) {
if (error?.status !== 422) {
throw error;
}
core.warning(`Skipping merged search for ${login}; treating as 0.`);
}
if (mergedCount >= experiencedThreshold) {
await github.rest.issues.addLabels({
...context.repo,
issue_number: context.payload.pull_request.number,
labels: [experiencedLabel],
});
return;
}
if (mergedCount >= trustedThreshold) {
await github.rest.issues.addLabels({
...context.repo,
issue_number: context.payload.pull_request.number,
labels: [trustedLabel],
});
}
backfill-pr-labels:
if: github.event_name == 'workflow_dispatch'
permissions:
contents: read
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
id: app-token
with:
app-id: "2729701"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Backfill PR labels
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const repoFull = `${owner}/${repo}`;
const inputs = context.payload.inputs ?? {};
const maxPrsInput = inputs.max_prs ?? "200";
const perPageInput = inputs.per_page ?? "50";
const parsedMaxPrs = Number.parseInt(maxPrsInput, 10);
const parsedPerPage = Number.parseInt(perPageInput, 10);
const maxPrs = Number.isFinite(parsedMaxPrs) ? parsedMaxPrs : 200;
const perPage = Number.isFinite(parsedPerPage) ? Math.min(100, Math.max(1, parsedPerPage)) : 50;
const processAll = maxPrs <= 0;
const maxCount = processAll ? Number.POSITIVE_INFINITY : Math.max(1, maxPrs);
const sizeLabels = ["size: XS", "size: S", "size: M", "size: L", "size: XL"];
const labelColor = "b76e79";
const trustedLabel = "trusted-contributor";
const experiencedLabel = "experienced-contributor";
const trustedThreshold = 4;
const experiencedThreshold = 10;
const contributorCache = new Map();
async function ensureSizeLabels() {
for (const label of sizeLabels) {
try {
await github.rest.issues.getLabel({
owner,
repo,
name: label,
});
} catch (error) {
if (error?.status !== 404) {
throw error;
}
await github.rest.issues.createLabel({
owner,
repo,
name: label,
color: labelColor,
});
}
}
}
async function resolveContributorLabel(login) {
if (contributorCache.has(login)) {
return contributorCache.get(login);
}
let isMaintainer = false;
try {
const membership = await github.rest.teams.getMembershipForUserInOrg({
org: owner,
team_slug: "maintainer",
username: login,
});
isMaintainer = membership?.data?.state === "active";
} catch (error) {
if (error?.status !== 404) {
throw error;
}
}
if (isMaintainer) {
contributorCache.set(login, "maintainer");
return "maintainer";
}
const mergedQuery = `repo:${repoFull} is:pr is:merged author:${login}`;
let mergedCount = 0;
try {
const merged = await github.rest.search.issuesAndPullRequests({
q: mergedQuery,
per_page: 1,
});
mergedCount = merged?.data?.total_count ?? 0;
} catch (error) {
if (error?.status !== 422) {
throw error;
}
core.warning(`Skipping merged search for ${login}; treating as 0.`);
}
let label = null;
if (mergedCount >= experiencedThreshold) {
label = experiencedLabel;
} else if (mergedCount >= trustedThreshold) {
label = trustedLabel;
}
contributorCache.set(login, label);
return label;
}
async function applySizeLabel(pullRequest, currentLabels, labelNames) {
const files = await github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: pullRequest.number,
per_page: 100,
});
const excludedLockfiles = new Set(["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"]);
const totalChangedLines = files.reduce((total, file) => {
const path = file.filename ?? "";
if (path === "docs.acp.md" || path.startsWith("docs/") || excludedLockfiles.has(path)) {
return total;
}
return total + (file.additions ?? 0) + (file.deletions ?? 0);
}, 0);
let targetSizeLabel = "size: XL";
if (totalChangedLines < 50) {
targetSizeLabel = "size: XS";
} else if (totalChangedLines < 200) {
targetSizeLabel = "size: S";
} else if (totalChangedLines < 500) {
targetSizeLabel = "size: M";
} else if (totalChangedLines < 1000) {
targetSizeLabel = "size: L";
}
for (const label of currentLabels) {
const name = label.name ?? "";
if (!sizeLabels.includes(name)) {
continue;
}
if (name === targetSizeLabel) {
continue;
}
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: pullRequest.number,
name,
});
labelNames.delete(name);
}
if (!labelNames.has(targetSizeLabel)) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: pullRequest.number,
labels: [targetSizeLabel],
});
labelNames.add(targetSizeLabel);
}
}
async function applyContributorLabel(pullRequest, labelNames) {
const login = pullRequest.user?.login;
if (!login) {
return;
}
const label = await resolveContributorLabel(login);
if (!label) {
return;
}
if (labelNames.has(label)) {
return;
}
await github.rest.issues.addLabels({
owner,
repo,
issue_number: pullRequest.number,
labels: [label],
});
labelNames.add(label);
}
await ensureSizeLabels();
let page = 1;
let processed = 0;
while (processed < maxCount) {
const remaining = maxCount - processed;
const pageSize = processAll ? perPage : Math.min(perPage, remaining);
const { data: pullRequests } = await github.rest.pulls.list({
owner,
repo,
state: "open",
per_page: pageSize,
page,
});
if (pullRequests.length === 0) {
break;
}
for (const pullRequest of pullRequests) {
if (!processAll && processed >= maxCount) {
break;
}
const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
owner,
repo,
issue_number: pullRequest.number,
per_page: 100,
});
const labelNames = new Set(
currentLabels.map((label) => label.name).filter((name) => typeof name === "string"),
);
await applySizeLabel(pullRequest, currentLabels, labelNames);
await applyContributorLabel(pullRequest, labelNames);
processed += 1;
}
if (pullRequests.length < pageSize) {
break;
}
page += 1;
}
core.info(`Processed ${processed} pull requests.`);
await github.rest.issues.addLabels({
...context.repo,
issue_number: context.payload.pull_request.number,
labels: ["maintainer"],
});
label-issues:
permissions:
@@ -447,7 +158,7 @@ jobs:
with:
app-id: "2729701"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Apply maintainer or trusted-contributor label
- name: Apply maintainer label for org members
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
github-token: ${{ steps.app-token.outputs.token }}
@@ -457,12 +168,6 @@ jobs:
return;
}
const repo = `${context.repo.owner}/${context.repo.repo}`;
const trustedLabel = "trusted-contributor";
const experiencedLabel = "experienced-contributor";
const trustedThreshold = 4;
const experiencedThreshold = 10;
let isMaintainer = false;
try {
const membership = await github.rest.teams.getMembershipForUserInOrg({
@@ -477,43 +182,12 @@ jobs:
}
}
if (isMaintainer) {
await github.rest.issues.addLabels({
...context.repo,
issue_number: context.payload.issue.number,
labels: ["maintainer"],
});
if (!isMaintainer) {
return;
}
const mergedQuery = `repo:${repo} is:pr is:merged author:${login}`;
let mergedCount = 0;
try {
const merged = await github.rest.search.issuesAndPullRequests({
q: mergedQuery,
per_page: 1,
});
mergedCount = merged?.data?.total_count ?? 0;
} catch (error) {
if (error?.status !== 422) {
throw error;
}
core.warning(`Skipping merged search for ${login}; treating as 0.`);
}
if (mergedCount >= experiencedThreshold) {
await github.rest.issues.addLabels({
...context.repo,
issue_number: context.payload.issue.number,
labels: [experiencedLabel],
});
return;
}
if (mergedCount >= trustedThreshold) {
await github.rest.issues.addLabels({
...context.repo,
issue_number: context.payload.issue.number,
labels: [trustedLabel],
});
}
await github.rest.issues.addLabels({
...context.repo,
issue_number: context.payload.issue.number,
labels: ["maintainer"],
});

1
.gitignore vendored
View File

@@ -73,7 +73,6 @@ docs/.local/
IDENTITY.md
USER.md
.tgz
.idea
# local tooling
.serena/

View File

@@ -88,13 +88,12 @@
- Do not set test workers above 16; tried already.
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (OpenClaw-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
- Full kit + whats covered: `docs/testing.md`.
- Changelog: user-facing changes only; no internal/meta notes (version alignment, appcast reminders, release process).
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
- Mobile: before using a simulator, check for connected real devices (iOS + Android) and prefer them when available.
## Commit & Pull Request Guidelines
**Full maintainer PR workflow (optional):** If you want the repo's end-to-end maintainer workflow (triage order, quality bar, rebase rules, commit/changelog conventions, co-contributor policy, and the `review-pr` > `prepare-pr` > `merge-pr` pipeline), see `.agents/skills/PR_WORKFLOW.md`. Maintainers may use other workflows; when a maintainer specifies a workflow, follow that. If no workflow is specified, default to PR_WORKFLOW.
**Full maintainer PR workflow:** `.agents/skills/PR_WORKFLOW.md` -- triage order, quality bar, rebase rules, commit/changelog conventions, co-contributor policy, and the 3-step skill pipeline (`review-pr` > `prepare-pr` > `merge-pr`).
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).

View File

@@ -2,56 +2,16 @@
Docs: https://docs.openclaw.ai
## 2026.2.13 (Unreleased)
### Fixes
- Security/Audit: distinguish external webhooks (`hooks.enabled`) from internal hooks (`hooks.internal.enabled`) in attack-surface summaries to avoid false exposure signals when only internal hooks are enabled. (#13474) Thanks @mcaxtr.
- Auto-reply/Threading: auto-inject implicit reply threading so `replyToMode` works without requiring model-emitted `[[reply_to_current]]`, while preserving `replyToMode: "off"` behavior for implicit Slack replies and keeping block-streaming chunk coalescing stable under `replyToMode: "first"`. (#14976) Thanks @Diaspar4u.
- Sandbox: pass configured `sandbox.docker.env` variables to sandbox containers at `docker create` time. (#15138) Thanks @stevebot-alive.
- Onboarding/CLI: restore terminal state without resuming paused `stdin`, so onboarding exits cleanly after choosing Web UI and the installer returns instead of appearing stuck.
- macOS Voice Wake: fix a crash in trigger trimming for CJK/Unicode transcripts by matching and slicing on original-string ranges instead of transformed-string indices. (#11052) Thanks @Flash-LHR.
- Heartbeat: prevent scheduler silent-death races during runner reloads, preserve retry cooldown backoff under wake bursts, and prioritize user/action wake causes over interval/retry reasons when coalescing. (#15108) Thanks @joeykrug.
- Outbound targets: fail closed for WhatsApp/Twitch/Google Chat fallback paths so invalid or missing targets are dropped instead of rerouted, and align resolver hints with strict target requirements. (#13578) Thanks @mcaxtr.
- Exec/Allowlist: allow multiline heredoc bodies (`<<`, `<<-`) while keeping multiline non-heredoc shell commands blocked, so exec approval parsing permits heredoc input safely without allowing general newline command chaining. (#13811) Thanks @mcaxtr.
- Docs/Mermaid: remove hardcoded Mermaid init theme blocks from four docs diagrams so dark mode inherits readable theme defaults. (#15157) Thanks @heytulsiprasad.
- Outbound/Threading: pass `replyTo` and `threadId` from `message send` tool actions through the core outbound send path to channel adapters, preserving thread/reply routing. (#14948) Thanks @mcaxtr.
- Sessions/Agents: pass `agentId` when resolving existing transcript paths in reply runs so non-default agents and heartbeat/chat handlers no longer fail with `Session file path must be within sessions directory`. (#15141) Thanks @Goldenmonstew.
## 2026.2.12
## 2026.2.10
### Changes
- CLI/Plugins: add `openclaw plugins uninstall <id>` with `--dry-run`, `--force`, and `--keep-files` options, including safe uninstall path handling and plugin uninstall docs. (#5985) Thanks @JustasMonkev.
- Version alignment: bump manifests and package versions to `2026.2.10`; keep `appcast.xml` unchanged until the next macOS release cut.
- CLI: add `openclaw logs --local-time` to display log timestamps in local timezone. (#13818) Thanks @xialonglee.
- Telegram: render blockquotes as native `<blockquote>` tags instead of stripping them. (#14608)
- Telegram: expose `/compact` in the native command menu. (#10352) Thanks @akramcodez.
- Discord: add role-based allowlists and role-based agent routing. (#10650) Thanks @Minidoracat.
- Config: avoid redacting `maxTokens`-like fields during config snapshot redaction, preventing round-trip validation failures in `/config`. (#14006) Thanks @constansino.
### Breaking
- Hooks: `POST /hooks/agent` now rejects payload `sessionKey` overrides by default. To keep fixed hook context, set `hooks.defaultSessionKey` (recommended with `hooks.allowedSessionKeyPrefixes: ["hook:"]`). If you need legacy behavior, explicitly set `hooks.allowRequestSessionKey: true`. Thanks @alpernae for reporting.
### Fixes
- Gateway/OpenResponses: harden URL-based `input_file`/`input_image` handling with explicit SSRF deny policy, hostname allowlists (`files.urlAllowlist` / `images.urlAllowlist`), per-request URL input caps (`maxUrlParts`), blocked-fetch audit logging, and regression coverage/docs updates.
- Security: fix unauthenticated Nostr profile API remote config tampering. (#13719) Thanks @coygeek.
- Security: remove bundled soul-evil hook. (#14757) Thanks @Imccccc.
- Security/Audit: add hook session-routing hardening checks (`hooks.defaultSessionKey`, `hooks.allowRequestSessionKey`, and prefix allowlists), and warn when HTTP API endpoints allow explicit session-key routing.
- Security/Sandbox: confine mirrored skill sync destinations to the sandbox `skills/` root and stop using frontmatter-controlled skill names as filesystem destination paths. Thanks @1seal.
- Security/Web tools: treat browser/web content as untrusted by default (wrapped outputs for browser snapshot/tabs/console and structured external-content metadata for web tools), and strip `toolResult.details` from model-facing transcript/compaction inputs to reduce prompt-injection replay risk.
- Security/Hooks: harden webhook and device token verification with shared constant-time secret comparison, and add per-client auth-failure throttling for hook endpoints (`429` + `Retry-After`). Thanks @akhmittra.
- Security/Browser: require auth for loopback browser control HTTP routes, auto-generate `gateway.auth.token` when browser control starts without auth, and add a security-audit check for unauthenticated browser control. Thanks @tcusolle.
- Sessions/Gateway: harden transcript path resolution and reject unsafe session IDs/file paths so session operations stay within agent sessions directories. Thanks @akhmittra.
- Sessions: preserve `verboseLevel`, `thinkingLevel`/`reasoningLevel`, and `ttsAuto` overrides across `/new` and `/reset` session resets. (#10787) Thanks @mcaxtr.
- Gateway: raise WS payload/buffer limits so 5,000,000-byte image attachments work reliably. (#14486) Thanks @0xRaini.
- Logging/CLI: use local timezone timestamps for console prefixing, and include `±HH:MM` offsets when using `openclaw logs --local-time` to avoid ambiguity. (#14771) Thanks @0xRaini.
- Gateway: drain active turns before restart to prevent message loss. (#13931) Thanks @0xRaini.
- Gateway: auto-generate auth token during install to prevent launchd restart loops. (#13813) Thanks @cathrynlavery.
- Gateway: prevent `undefined`/missing token in auth config. (#13809) Thanks @asklee-klawd.
- Gateway: handle async `EPIPE` on stdout/stderr during shutdown. (#13414) Thanks @keshav55.
- Gateway/Control UI: resolve missing dashboard assets when `openclaw` is installed globally via symlink-based Node managers (nvm/fnm/n/Homebrew). (#14919) Thanks @aynorica.
- Cron: use requested `agentId` for isolated job auth resolution. (#13983) Thanks @0xRaini.
- Cron: prevent cron jobs from skipping execution when `nextRunAtMs` advances. (#14068) Thanks @WalterSumbon.
- Cron: pass `agentId` to `runHeartbeatOnce` for main-session jobs. (#14140) Thanks @ishikawa-pro.
@@ -59,59 +19,22 @@ Docs: https://docs.openclaw.ai
- Cron: prevent duplicate fires when multiple jobs trigger simultaneously. (#14256) Thanks @xinhuagu.
- Cron: isolate scheduler errors so one bad job does not break all jobs. (#14385) Thanks @MarvinDontPanic.
- Cron: prevent one-shot `at` jobs from re-firing on restart after skipped/errored runs. (#13878) Thanks @lailoo.
- Heartbeat: prevent scheduler stalls on unexpected run errors and avoid immediate rerun loops after `requests-in-flight` skips. (#14901) Thanks @joeykrug.
- Cron: honor stored session model overrides for isolated-agent runs while preserving `hooks.gmail.model` precedence for Gmail hook sessions. (#14983) Thanks @shtse8.
- Logging/Browser: fall back to `os.tmpdir()/openclaw` for default log, browser trace, and browser download temp paths when `/tmp/openclaw` is unavailable.
- WhatsApp: convert Markdown bold/strikethrough to WhatsApp formatting. (#14285) Thanks @Raikan10.
- WhatsApp: allow media-only sends and normalize leading blank payloads. (#14408) Thanks @karimnaguib.
- WhatsApp: default MIME type for voice messages when Baileys omits it. (#14444) Thanks @mcaxtr.
- Telegram: handle no-text message in model picker editMessageText. (#14397) Thanks @0xRaini.
- Telegram: surface REACTION_INVALID as non-fatal warning. (#14340) Thanks @0xRaini.
- BlueBubbles: fix webhook auth bypass via loopback proxy trust. (#13787) Thanks @coygeek.
- Slack: change default replyToMode from "off" to "all". (#14364) Thanks @nm-de.
- Slack: detect control commands when channel messages start with bot mention prefixes (for example, `@Bot /new`). (#14142) Thanks @beefiker.
- Slack: include thread reply metadata in inbound message footer context (`thread_ts`, `parent_user_id`) while keeping top-level `thread_ts == ts` events unthreaded. (#14625) Thanks @bennewton999.
- Signal: enforce E.164 validation for the Signal bot account prompt so mistyped numbers are caught early. (#15063) Thanks @Duartemartins.
- Discord: process DM reactions instead of silently dropping them. (#10418) Thanks @mcaxtr.
- Discord: treat Administrator as full permissions in channel permission checks. Thanks @thewilloftheshadow.
- Discord: respect replyToMode in threads. (#11062) Thanks @cordx56.
- Browser: add Chrome launch flag `--disable-blink-features=AutomationControlled` to reduce `navigator.webdriver` automation detection issues on reCAPTCHA-protected sites. (#10735) Thanks @Milofax.
- Heartbeat: filter noise-only system events so scheduled reminder notifications do not fire when cron runs carry only heartbeat markers. (#13317) Thanks @pvtclawn.
- Signal: render mention placeholders as `@uuid`/`@phone` so mention gating and Clawdbot targeting work. (#2013) Thanks @alexgleason.
- Discord: omit empty content fields for media-only messages while preserving caption whitespace. (#9507) Thanks @leszekszpunar.
- Onboarding/Providers: add Z.AI endpoint-specific auth choices (`zai-coding-global`, `zai-coding-cn`, `zai-global`, `zai-cn`) and expand default Z.AI model wiring. (#13456) Thanks @tomsun28.
- Onboarding/Providers: update MiniMax API default/recommended models from M2.1 to M2.5, add M2.5/M2.5-Lightning model entries, and include `minimax-m2.5` in modern model filtering. (#14865) Thanks @adao-max.
- Ollama: use configured `models.providers.ollama.baseUrl` for model discovery and normalize `/v1` endpoints to the native Ollama API root. (#14131) Thanks @shtse8.
- Voice Call: pass Twilio stream auth token via `<Parameter>` instead of query string. (#14029) Thanks @mcwigglesmcgee.
- Slack: detect control commands when channel messages start with bot mention prefixes (for example, `@Bot /new`). (#14142) Thanks @beefiker.
- Discord tests: use a partial @buape/carbon mock in slash command coverage. (#13262) Thanks @arosstale.
- CLI/Wizard: exit with code 1 when `configure`, `agents add`, or interactive `onboard` wizards are canceled, so `set -e` automation stops correctly. (#14156) Thanks @0xRaini.
- Feishu: pass `Buffer` directly to the Feishu SDK upload APIs instead of `Readable.from(...)` to avoid form-data upload failures. (#10345) Thanks @youngerstyle.
- Feishu: trigger mention-gated group handling only when the bot itself is mentioned (not just any mention). (#11088) Thanks @openperf.
- Feishu: probe status uses the resolved account context for multi-account credential checks. (#11233) Thanks @onevcat.
- Feishu: add streaming card replies via Card Kit API and preserve `renderMode=auto` fallback behavior for plain-text responses. (#10379) Thanks @xzq-xu.
- Feishu DocX: preserve top-level converted block order using `firstLevelBlockIds` when writing/appending documents. (#13994) Thanks @Cynosure159.
- Feishu plugin packaging: remove `workspace:*` `openclaw` dependency from `extensions/feishu` and sync lockfile for install compatibility. (#14423) Thanks @jackcooper2015.
- CLI/Wizard: exit with code 1 when `configure`, `agents add`, or interactive `onboard` wizards are canceled, so `set -e` automation stops correctly. (#14156) Thanks @0xRaini.
- Media: strip `MEDIA:` lines with local paths instead of leaking as visible text. (#14399) Thanks @0xRaini.
- Config/Cron: exclude `maxTokens` from config redaction and honor `deleteAfterRun` on skipped cron jobs. (#13342) Thanks @niceysam.
- Config: ignore `meta` field changes in config file watcher. (#13460) Thanks @brandonwise.
- Cron: use requested `agentId` for isolated job auth resolution. (#13983) Thanks @0xRaini.
- Cron: pass `agentId` to `runHeartbeatOnce` for main-session jobs. (#14140) Thanks @ishikawa-pro.
- Cron: prevent cron jobs from skipping execution when `nextRunAtMs` advances. (#14068) Thanks @WalterSumbon.
- Cron: re-arm timers when `onTimer` fires while a job is still executing. (#14233) Thanks @tomron87.
- Cron: prevent duplicate fires when multiple jobs trigger simultaneously. (#14256) Thanks @xinhuagu.
- Cron: isolate scheduler errors so one bad job does not break all jobs. (#14385) Thanks @MarvinDontPanic.
- Cron: prevent one-shot `at` jobs from re-firing on restart after skipped/errored runs. (#13878) Thanks @lailoo.
- Daemon: suppress `EPIPE` error when restarting LaunchAgent. (#14343) Thanks @0xRaini.
- Antigravity: add opus 4.6 forward-compat model and bypass thinking signature sanitization. (#14218) Thanks @jg-noncelogic.
- Agents: prevent file descriptor leaks in child process cleanup. (#13565) Thanks @KyleChen26.
- Agents: prevent double compaction caused by cache TTL bypassing guard. (#13514) Thanks @taw0002.
- Agents: use last API call's cache tokens for context display instead of accumulated sum. (#13805) Thanks @akari-musubi.
- Agents: keep followup-runner session `totalTokens` aligned with post-compaction context by using last-call usage and shared token-accounting logic. (#14979) Thanks @shtse8.
- Hooks/Plugins: wire 9 previously unwired plugin lifecycle hooks into core runtime paths (session, compaction, gateway, and outbound message hooks). (#14882) Thanks @shtse8.
- Hooks/Tools: dispatch `before_tool_call` and `after_tool_call` hooks from both tool execution paths with rebased conflict fixes. (#15012) Thanks @Patrick-Barletta, @Takhoffman.
- Discord: allow channel-edit to archive/lock threads and set auto-archive duration. (#5542) Thanks @stumct.
- Discord tests: use a partial @buape/carbon mock in slash command coverage. (#13262) Thanks @arosstale.
- Telegram: handle no-text message in model picker editMessageText. (#14397) Thanks @0xRaini.
- Slack: change default replyToMode from "off" to "all". (#14364) Thanks @nm-de.
- Tests: update thread ID handling in Slack message collection tests. (#14108) Thanks @swizzmagik.
- Update/Daemon: fix post-update restart compatibility by generating `dist/cli/daemon-cli.js` with alias-aware exports from hashed daemon bundles, preventing `registerDaemonCli` import failures during `openclaw update`.
- Telegram: surface REACTION_INVALID as non-fatal warning. (#14340) Thanks @0xRaini.
## 2026.2.9
@@ -140,7 +63,6 @@ Docs: https://docs.openclaw.ai
- Sessions: prune stale entries, cap session store size, rotate large stores, accept duration/size thresholds, default to warn-only maintenance, and prune cron run sessions after retention windows. (#13083) Thanks @skyfallsin, @Glucksberg, @gumadeiras.
- CI: Implement pipeline and workflow order. Thanks @quotentiroler.
- WhatsApp: preserve original filenames for inbound documents. (#12691) Thanks @akramcodez.
- Feishu: enforce DM `dmPolicy`/pairing gating and sender allow checks for inbound DMs. (#14876) Thanks @coygeek.
- Telegram: harden quote parsing; preserve quote context; avoid QUOTE_TEXT_INVALID; avoid nested reply quote misclassification. (#12156) Thanks @rybnikov.
- Telegram: recover proactive sends when stale topic thread IDs are used by retrying without `message_thread_id`. (#11620)
- Discord: auto-create forum/media thread posts on send, with chunked follow-up replies and media handling for forum sends. (#12380) Thanks @magendary, @thewilloftheshadow.
@@ -204,7 +126,6 @@ Docs: https://docs.openclaw.ai
- Providers: add xAI (Grok) support. (#9885) Thanks @grp06.
- Providers: add Baidu Qianfan support. (#8868) Thanks @ide-rea.
- Web UI: add token usage dashboard. (#10072) Thanks @Takhoffman.
- Web UI: add RTL auto-direction support for Hebrew/Arabic text in chat composer and rendered messages. (#11498) Thanks @dirbalak.
- Memory: native Voyage AI support. (#7078) Thanks @mcinteerj.
- Sessions: cap sessions_history payloads to reduce context overflow. (#10000) Thanks @gut-puncture.
- CLI: sort commands alphabetically in help output. (#8068) Thanks @deepsoumya617.

View File

@@ -2,102 +2,6 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>OpenClaw</title>
<item>
<title>2026.2.12</title>
<pubDate>Fri, 13 Feb 2026 03:17:54 +0100</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>9500</sparkle:version>
<sparkle:shortVersionString>2026.2.12</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.2.12</h2>
<h3>Changes</h3>
<ul>
<li>CLI: add <code>openclaw logs --local-time</code> to display log timestamps in local timezone. (#13818) Thanks @xialonglee.</li>
<li>Telegram: render blockquotes as native <code><blockquote></code> tags instead of stripping them. (#14608)</li>
<li>Config: avoid redacting <code>maxTokens</code>-like fields during config snapshot redaction, preventing round-trip validation failures in <code>/config</code>. (#14006) Thanks @constansino.</li>
</ul>
<h3>Breaking</h3>
<ul>
<li>Hooks: <code>POST /hooks/agent</code> now rejects payload <code>sessionKey</code> overrides by default. To keep fixed hook context, set <code>hooks.defaultSessionKey</code> (recommended with <code>hooks.allowedSessionKeyPrefixes: ["hook:"]</code>). If you need legacy behavior, explicitly set <code>hooks.allowRequestSessionKey: true</code>. Thanks @alpernae for reporting.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Gateway/OpenResponses: harden URL-based <code>input_file</code>/<code>input_image</code> handling with explicit SSRF deny policy, hostname allowlists (<code>files.urlAllowlist</code> / <code>images.urlAllowlist</code>), per-request URL input caps (<code>maxUrlParts</code>), blocked-fetch audit logging, and regression coverage/docs updates.</li>
<li>Security: fix unauthenticated Nostr profile API remote config tampering. (#13719) Thanks @coygeek.</li>
<li>Security: remove bundled soul-evil hook. (#14757) Thanks @Imccccc.</li>
<li>Security/Audit: add hook session-routing hardening checks (<code>hooks.defaultSessionKey</code>, <code>hooks.allowRequestSessionKey</code>, and prefix allowlists), and warn when HTTP API endpoints allow explicit session-key routing.</li>
<li>Security/Sandbox: confine mirrored skill sync destinations to the sandbox <code>skills/</code> root and stop using frontmatter-controlled skill names as filesystem destination paths. Thanks @1seal.</li>
<li>Security/Web tools: treat browser/web content as untrusted by default (wrapped outputs for browser snapshot/tabs/console and structured external-content metadata for web tools), and strip <code>toolResult.details</code> from model-facing transcript/compaction inputs to reduce prompt-injection replay risk.</li>
<li>Security/Hooks: harden webhook and device token verification with shared constant-time secret comparison, and add per-client auth-failure throttling for hook endpoints (<code>429</code> + <code>Retry-After</code>). Thanks @akhmittra.</li>
<li>Security/Browser: require auth for loopback browser control HTTP routes, auto-generate <code>gateway.auth.token</code> when browser control starts without auth, and add a security-audit check for unauthenticated browser control. Thanks @tcusolle.</li>
<li>Sessions/Gateway: harden transcript path resolution and reject unsafe session IDs/file paths so session operations stay within agent sessions directories. Thanks @akhmittra.</li>
<li>Gateway: raise WS payload/buffer limits so 5,000,000-byte image attachments work reliably. (#14486) Thanks @0xRaini.</li>
<li>Logging/CLI: use local timezone timestamps for console prefixing, and include <code>±HH:MM</code> offsets when using <code>openclaw logs --local-time</code> to avoid ambiguity. (#14771) Thanks @0xRaini.</li>
<li>Gateway: drain active turns before restart to prevent message loss. (#13931) Thanks @0xRaini.</li>
<li>Gateway: auto-generate auth token during install to prevent launchd restart loops. (#13813) Thanks @cathrynlavery.</li>
<li>Gateway: prevent <code>undefined</code>/missing token in auth config. (#13809) Thanks @asklee-klawd.</li>
<li>Gateway: handle async <code>EPIPE</code> on stdout/stderr during shutdown. (#13414) Thanks @keshav55.</li>
<li>Gateway/Control UI: resolve missing dashboard assets when <code>openclaw</code> is installed globally via symlink-based Node managers (nvm/fnm/n/Homebrew). (#14919) Thanks @aynorica.</li>
<li>Cron: use requested <code>agentId</code> for isolated job auth resolution. (#13983) Thanks @0xRaini.</li>
<li>Cron: prevent cron jobs from skipping execution when <code>nextRunAtMs</code> advances. (#14068) Thanks @WalterSumbon.</li>
<li>Cron: pass <code>agentId</code> to <code>runHeartbeatOnce</code> for main-session jobs. (#14140) Thanks @ishikawa-pro.</li>
<li>Cron: re-arm timers when <code>onTimer</code> fires while a job is still executing. (#14233) Thanks @tomron87.</li>
<li>Cron: prevent duplicate fires when multiple jobs trigger simultaneously. (#14256) Thanks @xinhuagu.</li>
<li>Cron: isolate scheduler errors so one bad job does not break all jobs. (#14385) Thanks @MarvinDontPanic.</li>
<li>Cron: prevent one-shot <code>at</code> jobs from re-firing on restart after skipped/errored runs. (#13878) Thanks @lailoo.</li>
<li>Heartbeat: prevent scheduler stalls on unexpected run errors and avoid immediate rerun loops after <code>requests-in-flight</code> skips. (#14901) Thanks @joeykrug.</li>
<li>Cron: honor stored session model overrides for isolated-agent runs while preserving <code>hooks.gmail.model</code> precedence for Gmail hook sessions. (#14983) Thanks @shtse8.</li>
<li>Logging/Browser: fall back to <code>os.tmpdir()/openclaw</code> for default log, browser trace, and browser download temp paths when <code>/tmp/openclaw</code> is unavailable.</li>
<li>WhatsApp: convert Markdown bold/strikethrough to WhatsApp formatting. (#14285) Thanks @Raikan10.</li>
<li>WhatsApp: allow media-only sends and normalize leading blank payloads. (#14408) Thanks @karimnaguib.</li>
<li>WhatsApp: default MIME type for voice messages when Baileys omits it. (#14444) Thanks @mcaxtr.</li>
<li>Telegram: handle no-text message in model picker editMessageText. (#14397) Thanks @0xRaini.</li>
<li>Telegram: surface REACTION_INVALID as non-fatal warning. (#14340) Thanks @0xRaini.</li>
<li>BlueBubbles: fix webhook auth bypass via loopback proxy trust. (#13787) Thanks @coygeek.</li>
<li>Slack: change default replyToMode from "off" to "all". (#14364) Thanks @nm-de.</li>
<li>Slack: detect control commands when channel messages start with bot mention prefixes (for example, <code>@Bot /new</code>). (#14142) Thanks @beefiker.</li>
<li>Signal: enforce E.164 validation for the Signal bot account prompt so mistyped numbers are caught early. (#15063) Thanks @Duartemartins.</li>
<li>Discord: process DM reactions instead of silently dropping them. (#10418) Thanks @mcaxtr.</li>
<li>Discord: respect replyToMode in threads. (#11062) Thanks @cordx56.</li>
<li>Heartbeat: filter noise-only system events so scheduled reminder notifications do not fire when cron runs carry only heartbeat markers. (#13317) Thanks @pvtclawn.</li>
<li>Signal: render mention placeholders as <code>@uuid</code>/<code>@phone</code> so mention gating and Clawdbot targeting work. (#2013) Thanks @alexgleason.</li>
<li>Discord: omit empty content fields for media-only messages while preserving caption whitespace. (#9507) Thanks @leszekszpunar.</li>
<li>Onboarding/Providers: add Z.AI endpoint-specific auth choices (<code>zai-coding-global</code>, <code>zai-coding-cn</code>, <code>zai-global</code>, <code>zai-cn</code>) and expand default Z.AI model wiring. (#13456) Thanks @tomsun28.</li>
<li>Onboarding/Providers: update MiniMax API default/recommended models from M2.1 to M2.5, add M2.5/M2.5-Lightning model entries, and include <code>minimax-m2.5</code> in modern model filtering. (#14865) Thanks @adao-max.</li>
<li>Ollama: use configured <code>models.providers.ollama.baseUrl</code> for model discovery and normalize <code>/v1</code> endpoints to the native Ollama API root. (#14131) Thanks @shtse8.</li>
<li>Voice Call: pass Twilio stream auth token via <code><Parameter></code> instead of query string. (#14029) Thanks @mcwigglesmcgee.</li>
<li>Feishu: pass <code>Buffer</code> directly to the Feishu SDK upload APIs instead of <code>Readable.from(...)</code> to avoid form-data upload failures. (#10345) Thanks @youngerstyle.</li>
<li>Feishu: trigger mention-gated group handling only when the bot itself is mentioned (not just any mention). (#11088) Thanks @openperf.</li>
<li>Feishu: probe status uses the resolved account context for multi-account credential checks. (#11233) Thanks @onevcat.</li>
<li>Feishu DocX: preserve top-level converted block order using <code>firstLevelBlockIds</code> when writing/appending documents. (#13994) Thanks @Cynosure159.</li>
<li>Feishu plugin packaging: remove <code>workspace:*</code> <code>openclaw</code> dependency from <code>extensions/feishu</code> and sync lockfile for install compatibility. (#14423) Thanks @jackcooper2015.</li>
<li>CLI/Wizard: exit with code 1 when <code>configure</code>, <code>agents add</code>, or interactive <code>onboard</code> wizards are canceled, so <code>set -e</code> automation stops correctly. (#14156) Thanks @0xRaini.</li>
<li>Media: strip <code>MEDIA:</code> lines with local paths instead of leaking as visible text. (#14399) Thanks @0xRaini.</li>
<li>Config/Cron: exclude <code>maxTokens</code> from config redaction and honor <code>deleteAfterRun</code> on skipped cron jobs. (#13342) Thanks @niceysam.</li>
<li>Config: ignore <code>meta</code> field changes in config file watcher. (#13460) Thanks @brandonwise.</li>
<li>Cron: use requested <code>agentId</code> for isolated job auth resolution. (#13983) Thanks @0xRaini.</li>
<li>Cron: pass <code>agentId</code> to <code>runHeartbeatOnce</code> for main-session jobs. (#14140) Thanks @ishikawa-pro.</li>
<li>Cron: prevent cron jobs from skipping execution when <code>nextRunAtMs</code> advances. (#14068) Thanks @WalterSumbon.</li>
<li>Cron: re-arm timers when <code>onTimer</code> fires while a job is still executing. (#14233) Thanks @tomron87.</li>
<li>Cron: prevent duplicate fires when multiple jobs trigger simultaneously. (#14256) Thanks @xinhuagu.</li>
<li>Cron: isolate scheduler errors so one bad job does not break all jobs. (#14385) Thanks @MarvinDontPanic.</li>
<li>Cron: prevent one-shot <code>at</code> jobs from re-firing on restart after skipped/errored runs. (#13878) Thanks @lailoo.</li>
<li>Daemon: suppress <code>EPIPE</code> error when restarting LaunchAgent. (#14343) Thanks @0xRaini.</li>
<li>Antigravity: add opus 4.6 forward-compat model and bypass thinking signature sanitization. (#14218) Thanks @jg-noncelogic.</li>
<li>Agents: prevent file descriptor leaks in child process cleanup. (#13565) Thanks @KyleChen26.</li>
<li>Agents: prevent double compaction caused by cache TTL bypassing guard. (#13514) Thanks @taw0002.</li>
<li>Agents: use last API call's cache tokens for context display instead of accumulated sum. (#13805) Thanks @akari-musubi.</li>
<li>Agents: keep followup-runner session <code>totalTokens</code> aligned with post-compaction context by using last-call usage and shared token-accounting logic. (#14979) Thanks @shtse8.</li>
<li>Hooks/Plugins: wire 9 previously unwired plugin lifecycle hooks into core runtime paths (session, compaction, gateway, and outbound message hooks). (#14882) Thanks @shtse8.</li>
<li>Hooks/Tools: dispatch <code>before_tool_call</code> and <code>after_tool_call</code> hooks from both tool execution paths with rebased conflict fixes. (#15012) Thanks @Patrick-Barletta, @Takhoffman.</li>
<li>Discord: allow channel-edit to archive/lock threads and set auto-archive duration. (#5542) Thanks @stumct.</li>
<li>Discord tests: use a partial @buape/carbon mock in slash command coverage. (#13262) Thanks @arosstale.</li>
<li>Tests: update thread ID handling in Slack message collection tests. (#14108) Thanks @swizzmagik.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.12/OpenClaw-2026.2.12.zip" length="22877692" type="application/octet-stream" sparkle:edSignature="TGylTM4/7Lab+qp1nuPeOAmEVV1WkafXUPub8ws0z/0mYfbVygRuiev+u3zdPjQWhLnGYTgRgKVyW+kB2+Q2BQ=="/>
</item>
<item>
<title>2026.2.9</title>
<pubDate>Mon, 09 Feb 2026 13:23:25 -0600</pubDate>
@@ -204,5 +108,49 @@
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.3/OpenClaw-2026.2.3.zip" length="22530161" type="application/octet-stream" sparkle:edSignature="7eHUaQC6cx87HWbcaPh9T437+LqfE9VtQBf4p9JBjIyBrqGYxxp9KPvI5unEjg55j9j2djCXhseSMeyyRmvYBg=="/>
</item>
<item>
<title>2026.2.2</title>
<pubDate>Tue, 03 Feb 2026 17:04:17 -0800</pubDate>
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
<sparkle:version>8809</sparkle:version>
<sparkle:shortVersionString>2026.2.2</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>OpenClaw 2026.2.2</h2>
<h3>Changes</h3>
<ul>
<li>Feishu: add Feishu/Lark plugin support + docs. (#7313) Thanks @jiulingyun (openclaw-cn).</li>
<li>Web UI: add Agents dashboard for managing agent files, tools, skills, models, channels, and cron jobs.</li>
<li>Memory: implement the opt-in QMD backend for workspace memory. (#3160) Thanks @vignesh07.</li>
<li>Security: add healthcheck skill and bootstrap audit guidance. (#7641) Thanks @Takhoffman.</li>
<li>Config: allow setting a default subagent thinking level via <code>agents.defaults.subagents.thinking</code> (and per-agent <code>agents.list[].subagents.thinking</code>). (#7372) Thanks @tyler6204.</li>
<li>Docs: zh-CN translations seed + polish, pipeline guidance, nav/landing updates, and typo fixes. (#8202, #6995, #6619, #7242, #7303, #7415) Thanks @AaronWander, @taiyi747, @Explorer1092, @rendaoyuan, @joshp123, @lailoo.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Security: require operator.approvals for gateway /approve commands. (#1) Thanks @mitsuhiko, @yueyueL.</li>
<li>Security: Matrix allowlists now require full MXIDs; ambiguous name resolution no longer grants access. Thanks @MegaManSec.</li>
<li>Security: enforce access-group gating for Slack slash commands when channel type lookup fails.</li>
<li>Security: require validated shared-secret auth before skipping device identity on gateway connect.</li>
<li>Security: guard skill installer downloads with SSRF checks (block private/localhost URLs).</li>
<li>Security: harden Windows exec allowlist; block cmd.exe bypass via single &. Thanks @simecek.</li>
<li>fix(voice-call): harden inbound allowlist; reject anonymous callers; require Telnyx publicKey for allowlist; token-gate Twilio media streams; cap webhook body size (thanks @simecek)</li>
<li>Media understanding: apply SSRF guardrails to provider fetches; allow private baseUrl overrides explicitly.</li>
<li>fix(webchat): respect user scroll position during streaming and refresh (#7226) (thanks @marcomarandiz)</li>
<li>Telegram: recover from grammY long-poll timed out errors. (#7466) Thanks @macmimi23.</li>
<li>Agents: repair malformed tool calls and session transcripts. (#7473) Thanks @justinhuangcode.</li>
<li>fix(agents): validate AbortSignal instances before calling AbortSignal.any() (#7277) (thanks @Elarwei001)</li>
<li>Media understanding: skip binary media from file text extraction. (#7475) Thanks @AlexZhangji.</li>
<li>Onboarding: keep TUI flow exclusive (skip completion prompt + background Web UI seed); completion prompt now handled by install/update.</li>
<li>TUI: block onboarding output while TUI is active and restore terminal state on exit.</li>
<li>CLI/Zsh completion: cache scripts in state dir and escape option descriptions to avoid invalid option errors.</li>
<li>fix(ui): resolve Control UI asset path correctly.</li>
<li>fix(ui): refresh agent files after external edits.</li>
<li>Docs: finish renaming the QMD memory docs to reference the OpenClaw state dir.</li>
<li>Tests: stub SSRF DNS pinning in web auto-reply + Gemini video coverage. (#6619) Thanks @joshp123.</li>
</ul>
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.2/OpenClaw-2026.2.2.zip" length="22519052" type="application/octet-stream" sparkle:edSignature="a6viD+aS5EfY/RkPIPMfoQQNkJCk6QTdV5WobXFxyYwURskUm8/nXTHVXsCh1c5+0WKUnmlDIyf0i+6IWiavAA=="/>
</item>
</channel>
</rss>

View File

@@ -21,8 +21,8 @@ android {
applicationId = "ai.openclaw.android"
minSdk = 31
targetSdk = 36
versionCode = 202602130
versionName = "2026.2.13"
versionCode = 202602030
versionName = "2026.2.10"
}
buildTypes {

View File

@@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.13</string>
<string>2026.2.10</string>
<key>CFBundleVersion</key>
<string>20260213</string>
<string>20260202</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoadsInWebContent</key>

View File

@@ -17,8 +17,8 @@
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.13</string>
<string>2026.2.10</string>
<key>CFBundleVersion</key>
<string>20260213</string>
<string>20260202</string>
</dict>
</plist>

View File

@@ -81,8 +81,8 @@ targets:
properties:
CFBundleDisplayName: OpenClaw
CFBundleIconName: AppIcon
CFBundleShortVersionString: "2026.2.13"
CFBundleVersion: "20260213"
CFBundleShortVersionString: "2026.2.10"
CFBundleVersion: "20260202"
UILaunchScreen: {}
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false
@@ -130,5 +130,5 @@ targets:
path: Tests/Info.plist
properties:
CFBundleDisplayName: OpenClawTests
CFBundleShortVersionString: "2026.2.13"
CFBundleVersion: "20260213"
CFBundleShortVersionString: "2026.2.10"
CFBundleVersion: "20260202"

View File

@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.13</string>
<string>2026.2.10</string>
<key>CFBundleVersion</key>
<string>202602130</string>
<string>202602020</string>
<key>CFBundleIconFile</key>
<string>OpenClaw</string>
<key>CFBundleURLTypes</key>

View File

@@ -735,13 +735,12 @@ actor VoiceWakeRuntime {
}
private static func trimmedAfterTrigger(_ text: String, triggers: [String]) -> String {
let lower = text.lowercased()
for trigger in triggers {
let token = trigger.trimmingCharacters(in: .whitespacesAndNewlines)
guard !token.isEmpty else { continue }
guard let range = text.range(
of: token,
options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive]) else { continue }
let trimmed = text[range.upperBound...].trimmingCharacters(in: .whitespacesAndNewlines)
let token = trigger.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
guard !token.isEmpty, let range = lower.range(of: token) else { continue }
let after = range.upperBound
let trimmed = text[after...].trimmingCharacters(in: .whitespacesAndNewlines)
return String(trimmed)
}
return text

View File

@@ -489,7 +489,6 @@ public struct AgentParams: Codable, Sendable {
public let timeout: Int?
public let lane: String?
public let extrasystemprompt: String?
public let inputprovenance: [String: AnyCodable]?
public let idempotencykey: String
public let label: String?
public let spawnedby: String?
@@ -515,7 +514,6 @@ public struct AgentParams: Codable, Sendable {
timeout: Int?,
lane: String?,
extrasystemprompt: String?,
inputprovenance: [String: AnyCodable]?,
idempotencykey: String,
label: String?,
spawnedby: String?
@@ -540,7 +538,6 @@ public struct AgentParams: Codable, Sendable {
self.timeout = timeout
self.lane = lane
self.extrasystemprompt = extrasystemprompt
self.inputprovenance = inputprovenance
self.idempotencykey = idempotencykey
self.label = label
self.spawnedby = spawnedby
@@ -566,7 +563,6 @@ public struct AgentParams: Codable, Sendable {
case timeout
case lane
case extrasystemprompt = "extraSystemPrompt"
case inputprovenance = "inputProvenance"
case idempotencykey = "idempotencyKey"
case label
case spawnedby = "spawnedBy"

View File

@@ -35,18 +35,6 @@ import Testing
#expect(VoiceWakeRuntime._testHasContentAfterTrigger(text, triggers: triggers))
}
@Test func trimsAfterChineseTriggerKeepsPostSpeech() {
let triggers = ["小爪", "openclaw"]
let text = "嘿 小爪 帮我打开设置"
#expect(VoiceWakeRuntime._testTrimmedAfterTrigger(text, triggers: triggers) == "帮我打开设置")
}
@Test func trimsAfterTriggerHandlesWidthInsensitiveForms() {
let triggers = ["openclaw"]
let text = " 请帮我"
#expect(VoiceWakeRuntime._testTrimmedAfterTrigger(text, triggers: triggers) == "请帮我")
}
@Test func gateRequiresGapBetweenTriggerAndCommand() {
let transcript = "hey openclaw do thing"
let segments = makeSegments(

View File

@@ -489,7 +489,6 @@ public struct AgentParams: Codable, Sendable {
public let timeout: Int?
public let lane: String?
public let extrasystemprompt: String?
public let inputprovenance: [String: AnyCodable]?
public let idempotencykey: String
public let label: String?
public let spawnedby: String?
@@ -515,7 +514,6 @@ public struct AgentParams: Codable, Sendable {
timeout: Int?,
lane: String?,
extrasystemprompt: String?,
inputprovenance: [String: AnyCodable]?,
idempotencykey: String,
label: String?,
spawnedby: String?
@@ -540,7 +538,6 @@ public struct AgentParams: Codable, Sendable {
self.timeout = timeout
self.lane = lane
self.extrasystemprompt = extrasystemprompt
self.inputprovenance = inputprovenance
self.idempotencykey = idempotencykey
self.label = label
self.spawnedby = spawnedby
@@ -566,7 +563,6 @@ public struct AgentParams: Codable, Sendable {
case timeout
case lane
case extrasystemprompt = "extraSystemPrompt"
case inputprovenance = "inputProvenance"
case idempotencykey = "idempotencyKey"
case label
case spawnedby = "spawnedBy"

View File

@@ -41,11 +41,12 @@ The hooks system allows you to:
### Bundled Hooks
OpenClaw ships with three bundled hooks that are automatically discovered:
OpenClaw ships with four bundled hooks that are automatically discovered:
- **💾 session-memory**: Saves session context to your agent workspace (default `~/.openclaw/workspace/memory/`) when you issue `/new`
- **📝 command-logger**: Logs all command events to `~/.openclaw/logs/commands.log`
- **🚀 boot-md**: Runs `BOOT.md` when the gateway starts (requires internal hooks enabled)
- **😈 soul-evil**: Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance
List available hooks:
@@ -526,6 +527,42 @@ grep '"action":"new"' ~/.openclaw/logs/commands.log | jq .
openclaw hooks enable command-logger
```
### soul-evil
Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance.
**Events**: `agent:bootstrap`
**Docs**: [SOUL Evil Hook](/hooks/soul-evil)
**Output**: No files written; swaps happen in-memory only.
**Enable**:
```bash
openclaw hooks enable soul-evil
```
**Config**:
```json
{
"hooks": {
"internal": {
"enabled": true,
"entries": {
"soul-evil": {
"enabled": true,
"file": "SOUL_EVIL.md",
"chance": 0.1,
"purge": { "at": "21:00", "duration": "15m" }
}
}
}
}
}
```
### boot-md
Runs `BOOT.md` when the gateway starts (after channels start).

View File

@@ -37,7 +37,7 @@ Every request must include the hook token. Prefer headers:
- `Authorization: Bearer <token>` (recommended)
- `x-openclaw-token: <token>`
- Query-string tokens are rejected (`?token=...` returns `400`).
- `?token=<token>` (deprecated; logs a warning and will be removed in a future major release)
## Endpoints
@@ -80,7 +80,7 @@ Payload:
- `message` **required** (string): The prompt or message for the agent to process.
- `name` optional (string): Human-readable name for the hook (e.g., "GitHub"), used as a prefix in session summaries.
- `agentId` optional (string): Route this hook to a specific agent. Unknown IDs fall back to the default agent. When set, the hook runs using the resolved agent's workspace and configuration.
- `sessionKey` optional (string): The key used to identify the agent's session. By default this field is rejected unless `hooks.allowRequestSessionKey=true`.
- `sessionKey` optional (string): The key used to identify the agent's session. Defaults to a random `hook:<uuid>`. Using a consistent key allows for a multi-turn conversation within the hook context.
- `wakeMode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check.
- `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. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `mattermost` (plugin), `signal`, `imessage`, `msteams`. Defaults to `last`.
@@ -95,40 +95,6 @@ Effect:
- Always posts a summary into the **main** session
- If `wakeMode=now`, triggers an immediate heartbeat
## Session key policy (breaking change)
`/hooks/agent` payload `sessionKey` overrides are disabled by default.
- Recommended: set a fixed `hooks.defaultSessionKey` and keep request overrides off.
- Optional: allow request overrides only when needed, and restrict prefixes.
Recommended config:
```json5
{
hooks: {
enabled: true,
token: "${OPENCLAW_HOOKS_TOKEN}",
defaultSessionKey: "hook:ingress",
allowRequestSessionKey: false,
allowedSessionKeyPrefixes: ["hook:"],
},
}
```
Compatibility config (legacy behavior):
```json5
{
hooks: {
enabled: true,
token: "${OPENCLAW_HOOKS_TOKEN}",
allowRequestSessionKey: true,
allowedSessionKeyPrefixes: ["hook:"], // strongly recommended
},
}
```
### `POST /hooks/<name>` (mapped)
Custom hook names are resolved via `hooks.mappings` (see configuration). A mapping can
@@ -146,9 +112,6 @@ Mapping options (summary):
(`channel` defaults to `last` and falls back to WhatsApp).
- `agentId` routes the hook to a specific agent; unknown IDs fall back to the default agent.
- `hooks.allowedAgentIds` restricts explicit `agentId` routing. Omit it (or include `*`) to allow any agent. Set `[]` to deny explicit `agentId` routing.
- `hooks.defaultSessionKey` sets the default session for hook agent runs when no explicit key is provided.
- `hooks.allowRequestSessionKey` controls whether `/hooks/agent` payloads may set `sessionKey` (default: `false`).
- `hooks.allowedSessionKeyPrefixes` optionally restricts explicit `sessionKey` values from request payloads and mappings.
- `allowUnsafeExternalContent: true` disables the external content safety wrapper for that hook
(dangerous; only for trusted internal sources).
- `openclaw webhooks gmail setup` writes `hooks.gmail` config for `openclaw webhooks gmail run`.
@@ -159,7 +122,6 @@ Mapping options (summary):
- `200` for `/hooks/wake`
- `202` for `/hooks/agent` (async run started)
- `401` on auth failure
- `429` after repeated auth failures from the same client (check `Retry-After`)
- `400` on invalid payload
- `413` on oversized payloads
@@ -203,10 +165,7 @@ curl -X POST http://127.0.0.1:18789/hooks/gmail \
- Keep hook endpoints behind loopback, tailnet, or trusted reverse proxy.
- Use a dedicated hook token; do not reuse gateway auth tokens.
- Repeated auth failures are rate-limited per client address to slow brute-force attempts.
- If you use multi-agent routing, set `hooks.allowedAgentIds` to limit explicit `agentId` selection.
- Keep `hooks.allowRequestSessionKey=false` unless you require caller-selected sessions.
- If you enable request `sessionKey`, restrict `hooks.allowedSessionKeyPrefixes` (for example, `["hook:"]`).
- Avoid including sensitive raw payloads in webhook logs.
- Hook payloads are treated as untrusted and wrapped with safety boundaries by default.
If you must disable this for a specific hook, set `allowUnsafeExternalContent: true`

View File

@@ -28,7 +28,7 @@ Status: ready for DMs and guild channels via the official Discord gateway.
Create an application in the Discord Developer Portal, add a bot, then enable:
- **Message Content Intent**
- **Server Members Intent** (required for role allowlists and role-based routing; recommended for name-to-ID allowlist matching)
- **Server Members Intent** (recommended for name-to-ID lookups and allowlist matching)
</Step>
@@ -121,7 +121,6 @@ Token resolution is account-aware. Config token values win over env fallback. `D
`allowlist` behavior:
- guild must match `channels.discord.guilds` (`id` preferred, slug accepted)
- optional sender allowlists: `users` (IDs or names) and `roles` (role IDs only); if either is configured, senders are allowed when they match `users` OR `roles`
- if a guild has `channels` configured, non-listed channels are denied
- if a guild has no `channels` block, all channels in that allowlisted guild are allowed
@@ -136,7 +135,6 @@ Token resolution is account-aware. Config token values win over env fallback. `D
"123456789012345678": {
requireMention: true,
users: ["987654321098765432"],
roles: ["123456789012345678"],
channels: {
general: { allow: true },
help: { allow: true, requireMention: true },
@@ -171,32 +169,6 @@ Token resolution is account-aware. Config token values win over env fallback. `D
</Tab>
</Tabs>
### Role-based agent routing
Use `bindings[].match.roles` to route Discord guild members to different agents by role ID. Role-based bindings accept role IDs only and are evaluated after peer or parent-peer bindings and before guild-only bindings.
```json5
{
bindings: [
{
agentId: "opus",
match: {
channel: "discord",
guildId: "123456789012345678",
roles: ["111111111111111111"],
},
},
{
agentId: "sonnet",
match: {
channel: "discord",
guildId: "123456789012345678",
},
},
],
}
```
## Developer Portal setup
<AccordionGroup>

View File

@@ -36,7 +36,7 @@ openclaw pairing list telegram
openclaw pairing approve telegram <CODE>
```
Supported channels: `telegram`, `whatsapp`, `signal`, `imessage`, `discord`, `slack`, `feishu`.
Supported channels: `telegram`, `whatsapp`, `signal`, `imessage`, `discord`, `slack`.
### Where the state lives

View File

@@ -32,12 +32,13 @@ List all discovered hooks from workspace, managed, and bundled directories.
**Example output:**
```
Hooks (3/3 ready)
Hooks (4/4 ready)
Ready:
🚀 boot-md ✓ - Run BOOT.md on gateway startup
📝 command-logger ✓ - Log all command events to a centralized audit file
💾 session-memory ✓ - Save session context to memory when /new command is issued
😈 soul-evil ✓ - Swap injected SOUL content during a purge window or by random chance
```
**Example (verbose):**
@@ -276,6 +277,18 @@ grep '"action":"new"' ~/.openclaw/logs/commands.log | jq .
**See:** [command-logger documentation](/automation/hooks#command-logger)
### soul-evil
Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance.
**Enable:**
```bash
openclaw hooks enable soul-evil
```
**See:** [SOUL Evil Hook](/hooks/soul-evil)
### boot-md
Runs `BOOT.md` when the gateway starts (after channels start).

View File

@@ -39,23 +39,6 @@ openclaw onboard --non-interactive \
`--custom-api-key` is optional in non-interactive mode. If omitted, onboarding checks `CUSTOM_API_KEY`.
Non-interactive Z.AI endpoint choices:
Note: `--auth-choice zai-api-key` now auto-detects the best Z.AI endpoint for your key (prefers the general API with `zai/glm-5`).
If you specifically want the GLM Coding Plan endpoints, pick `zai-coding-global` or `zai-coding-cn`.
```bash
# Promptless endpoint selection
openclaw onboard --non-interactive \
--auth-choice zai-coding-global \
--zai-api-key "$ZAI_API_KEY"
# Other Z.AI endpoint choices:
# --auth-choice zai-coding-cn
# --auth-choice zai-global
# --auth-choice zai-cn
```
Flow notes:
- `quickstart`: minimal prompts, auto-generates a gateway token.

View File

@@ -1,5 +1,5 @@
---
summary: "CLI reference for `openclaw plugins` (list, install, uninstall, enable/disable, doctor)"
summary: "CLI reference for `openclaw plugins` (list, install, enable/disable, doctor)"
read_when:
- You want to install or manage in-process Gateway plugins
- You want to debug plugin load failures
@@ -23,7 +23,6 @@ openclaw plugins list
openclaw plugins info <id>
openclaw plugins enable <id>
openclaw plugins disable <id>
openclaw plugins uninstall <id>
openclaw plugins doctor
openclaw plugins update <id>
openclaw plugins update --all
@@ -52,24 +51,6 @@ Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`):
openclaw plugins install -l ./my-plugin
```
### Uninstall
```bash
openclaw plugins uninstall <id>
openclaw plugins uninstall <id> --dry-run
openclaw plugins uninstall <id> --keep-files
```
`uninstall` removes plugin records from `plugins.entries`, `plugins.installs`,
the plugin allowlist, and linked `plugins.load.paths` entries when applicable.
For active memory plugins, the memory slot resets to `memory-core`.
By default, uninstall also removes the plugin install directory under the active
state dir extensions root (`$OPENCLAW_STATE_DIR/extensions/<id>`). Use
`--keep-files` to keep files on disk.
`--keep-config` is supported as a deprecated alias for `--keep-files`.
### Update
```bash

View File

@@ -24,4 +24,3 @@ openclaw security audit --fix
The audit warns when multiple DM senders share the main session and recommends **secure DM mode**: `session.dmScope="per-channel-peer"` (or `per-account-channel-peer` for multi-account channels) for shared inboxes.
It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled.
For webhook ingress, it warns when `hooks.defaultSessionKey` is unset, when request `sessionKey` overrides are enabled, and when overrides are enabled without `hooks.allowedSessionKeyPrefixes`.

View File

@@ -56,6 +56,22 @@ Protocol details:
## Connection lifecycle (single client)
```mermaid
%%{init: {
'theme': 'base',
'themeVariables': {
'primaryColor': '#ffffff',
'primaryTextColor': '#000000',
'primaryBorderColor': '#000000',
'lineColor': '#000000',
'secondaryColor': '#f9f9fb',
'tertiaryColor': '#ffffff',
'clusterBkg': '#f9f9fb',
'clusterBorder': '#000000',
'nodeBorder': '#000000',
'mainBkg': '#ffffff',
'edgeLabelBackground': '#ffffff'
}
}}%%
sequenceDiagram
participant Client
participant Gateway

View File

@@ -94,7 +94,6 @@ Behavior:
- Announce delivery runs after the primary run completes and is best-effort; `status: "ok"` does not guarantee the announce was delivered.
- Waits via gateway `agent.wait` (server-side) so reconnects don't drop the wait.
- Agent-to-agent message context is injected for the primary run.
- Inter-session messages are persisted with `message.provenance.kind = "inter_session"` so transcript readers can distinguish routed agent instructions from external user input.
- After the primary run completes, OpenClaw runs a **reply-back loop**:
- Round 2+ alternates between requester and target agents.
- Reply exactly `REPLY_SKIP` to stop the pingpong.

View File

@@ -1003,6 +1003,10 @@
"automation/auth-monitoring"
]
},
{
"group": "Hooks",
"pages": ["hooks/soul-evil"]
},
{
"group": "Media and devices",
"pages": [
@@ -1519,6 +1523,10 @@
"zh-CN/automation/auth-monitoring"
]
},
{
"group": "Hooks",
"pages": ["zh-CN/hooks/soul-evil"]
},
{
"group": "媒体与设备",
"pages": [

View File

@@ -1934,10 +1934,6 @@ See [Plugins](/tools/plugin).
- Chat Completions: disabled by default. Enable with `gateway.http.endpoints.chatCompletions.enabled: true`.
- Responses API: `gateway.http.endpoints.responses.enabled`.
- Responses URL-input hardening:
- `gateway.http.endpoints.responses.maxUrlParts`
- `gateway.http.endpoints.responses.files.urlAllowlist`
- `gateway.http.endpoints.responses.images.urlAllowlist`
### Multi-instance isolation
@@ -1964,9 +1960,6 @@ See [Multiple Gateways](/gateway/multiple-gateways).
token: "shared-secret",
path: "/hooks",
maxBodyBytes: 262144,
defaultSessionKey: "hook:ingress",
allowRequestSessionKey: false,
allowedSessionKeyPrefixes: ["hook:"],
allowedAgentIds: ["hooks", "main"],
presets: ["gmail"],
transformsDir: "~/.openclaw/hooks",
@@ -1994,7 +1987,6 @@ Auth: `Authorization: Bearer <token>` or `x-openclaw-token: <token>`.
- `POST /hooks/wake``{ text, mode?: "now"|"next-heartbeat" }`
- `POST /hooks/agent``{ message, name?, agentId?, sessionKey?, wakeMode?, deliver?, channel?, to?, model?, thinking?, timeoutSeconds? }`
- `sessionKey` from request payload is accepted only when `hooks.allowRequestSessionKey=true` (default: `false`).
- `POST /hooks/<name>` → resolved via `hooks.mappings`
<Accordion title="Mapping details">
@@ -2005,9 +1997,6 @@ Auth: `Authorization: Bearer <token>` or `x-openclaw-token: <token>`.
- `transform` can point to a JS/TS module returning a hook action.
- `agentId` routes to a specific agent; unknown IDs fall back to default.
- `allowedAgentIds`: restricts explicit routing (`*` or omitted = allow all, `[]` = deny all).
- `defaultSessionKey`: optional fixed session key for hook agent runs without explicit `sessionKey`.
- `allowRequestSessionKey`: allow `/hooks/agent` callers to set `sessionKey` (default: `false`).
- `allowedSessionKeyPrefixes`: optional prefix allowlist for explicit `sessionKey` values (request + mapping), e.g. `["hook:"]`.
- `deliver: true` sends final reply to a channel; `channel` defaults to `last`.
- `model` overrides LLM for this hook run (must be allowed if model catalog is set).

View File

@@ -262,9 +262,6 @@ When validation fails:
enabled: true,
token: "shared-secret",
path: "/hooks",
defaultSessionKey: "hook:ingress",
allowRequestSessionKey: false,
allowedSessionKeyPrefixes: ["hook:"],
mappings: [
{
match: { path: "gmail" },

View File

@@ -186,11 +186,7 @@ URL fetch defaults:
- `files.allowUrl`: `true`
- `images.allowUrl`: `true`
- `maxUrlParts`: `8` (total URL-based `input_file` + `input_image` parts per request)
- Requests are guarded (DNS resolution, private IP blocking, redirect caps, timeouts).
- Optional hostname allowlists are supported per input type (`files.urlAllowlist`, `images.urlAllowlist`).
- Exact host: `"cdn.example.com"`
- Wildcard subdomains: `"*.assets.example.com"` (does not match apex)
## File + image limits (config)
@@ -204,10 +200,8 @@ Defaults can be tuned under `gateway.http.endpoints.responses`:
responses: {
enabled: true,
maxBodyBytes: 20000000,
maxUrlParts: 8,
files: {
allowUrl: true,
urlAllowlist: ["cdn.example.com", "*.assets.example.com"],
allowedMimes: [
"text/plain",
"text/markdown",
@@ -228,7 +222,6 @@ Defaults can be tuned under `gateway.http.endpoints.responses`:
},
images: {
allowUrl: true,
urlAllowlist: ["images.example.com"],
allowedMimes: ["image/jpeg", "image/png", "image/gif", "image/webp"],
maxBytes: 10485760,
maxRedirects: 3,
@@ -244,7 +237,6 @@ Defaults can be tuned under `gateway.http.endpoints.responses`:
Defaults when omitted:
- `maxBodyBytes`: 20MB
- `maxUrlParts`: 8
- `files.maxBytes`: 5MB
- `files.maxChars`: 200k
- `files.maxRedirects`: 3
@@ -256,13 +248,6 @@ Defaults when omitted:
- `images.maxRedirects`: 3
- `images.timeoutMs`: 10s
Security note:
- URL allowlists are enforced before fetch and on redirect hops.
- Allowlisting a hostname does not bypass private/internal IP blocking.
- For internet-exposed gateways, apply network egress controls in addition to app-level guards.
See [Security](/gateway/security).
## Streaming (SSE)
Set `stream: true` to receive Server-Sent Events (SSE):

View File

@@ -11,6 +11,22 @@ OpenClaw.app uses SSH tunneling to connect to a remote gateway. This guide shows
## Overview
```mermaid
%%{init: {
'theme': 'base',
'themeVariables': {
'primaryColor': '#ffffff',
'primaryTextColor': '#000000',
'primaryBorderColor': '#000000',
'lineColor': '#000000',
'secondaryColor': '#f9f9fb',
'tertiaryColor': '#ffffff',
'clusterBkg': '#f9f9fb',
'clusterBorder': '#000000',
'nodeBorder': '#000000',
'mainBkg': '#ffffff',
'edgeLabelBackground': '#ffffff'
}
}}%%
flowchart TB
subgraph Client["Client Machine"]
direction TB

View File

@@ -265,9 +265,6 @@ tool calls. Reduce the blast radius by:
- Using a read-only or tool-disabled **reader agent** to summarize untrusted content,
then pass the summary to your main agent.
- Keeping `web_search` / `web_fetch` / `browser` off for tool-enabled agents unless needed.
- For OpenResponses URL inputs (`input_file` / `input_image`), set tight
`gateway.http.endpoints.responses.files.urlAllowlist` and
`gateway.http.endpoints.responses.images.urlAllowlist`, and keep `maxUrlParts` low.
- Enabling sandboxing and strict tool allowlists for any agent that touches untrusted input.
- Keeping secrets out of prompts; pass them via env/config on the gateway host instead.
@@ -801,6 +798,22 @@ Commit the updated `.secrets.baseline` once it reflects the intended state.
## The Trust Hierarchy
```mermaid
%%{init: {
'theme': 'base',
'themeVariables': {
'primaryColor': '#ffffff',
'primaryTextColor': '#000000',
'primaryBorderColor': '#000000',
'lineColor': '#000000',
'secondaryColor': '#f9f9fb',
'tertiaryColor': '#ffffff',
'clusterBkg': '#f9f9fb',
'clusterBorder': '#000000',
'nodeBorder': '#000000',
'mainBkg': '#ffffff',
'edgeLabelBackground': '#ffffff'
}
}}%%
flowchart TB
A["Owner (Peter)"] -- Full trust --> B["AI (Clawd)"]
B -- Trust but verify --> C["Friends in allowlist"]

View File

@@ -546,15 +546,6 @@ For a hackable (git) install:
curl -fsSL https://openclaw.ai/install.sh | bash -s -- --install-method git --verbose
```
Windows (PowerShell) equivalent:
```powershell
# install.ps1 has no dedicated -Verbose flag yet.
Set-PSDebug -Trace 1
& ([scriptblock]::Create((iwr -useb https://openclaw.ai/install.ps1))) -NoOnboard
Set-PSDebug -Trace 0
```
More options: [Installer flags](/install/installer).
### Windows install says git not found or openclaw not recognized

69
docs/hooks/soul-evil.md Normal file
View File

@@ -0,0 +1,69 @@
---
summary: "SOUL Evil hook (swap SOUL.md with SOUL_EVIL.md)"
read_when:
- You want to enable or tune the SOUL Evil hook
- You want a purge window or random-chance persona swap
title: "SOUL Evil Hook"
---
# SOUL Evil Hook
The SOUL Evil hook swaps the **injected** `SOUL.md` content with `SOUL_EVIL.md` during
a purge window or by random chance. It does **not** modify files on disk.
## How It Works
When `agent:bootstrap` runs, the hook can replace the `SOUL.md` content in memory
before the system prompt is assembled. If `SOUL_EVIL.md` is missing or empty,
OpenClaw logs a warning and keeps the normal `SOUL.md`.
Sub-agent runs do **not** include `SOUL.md` in their bootstrap files, so this hook
has no effect on sub-agents.
## Enable
```bash
openclaw hooks enable soul-evil
```
Then set the config:
```json
{
"hooks": {
"internal": {
"enabled": true,
"entries": {
"soul-evil": {
"enabled": true,
"file": "SOUL_EVIL.md",
"chance": 0.1,
"purge": { "at": "21:00", "duration": "15m" }
}
}
}
}
}
```
Create `SOUL_EVIL.md` in the agent workspace root (next to `SOUL.md`).
## Options
- `file` (string): alternate SOUL filename (default: `SOUL_EVIL.md`)
- `chance` (number 01): random chance per run to use `SOUL_EVIL.md`
- `purge.at` (HH:mm): daily purge start (24-hour clock)
- `purge.duration` (duration): window length (e.g. `30s`, `10m`, `1h`)
**Precedence:** purge window wins over chance.
**Timezone:** uses `agents.defaults.userTimezone` when set; otherwise host timezone.
## Notes
- No files are written or modified on disk.
- If `SOUL.md` is not in the bootstrap list, the hook does nothing.
## See Also
- [Hooks](/automation/hooks)

View File

@@ -286,14 +286,6 @@ Designed for environments where you want everything under a local prefix (defaul
& ([scriptblock]::Create((iwr -useb https://openclaw.ai/install.ps1))) -DryRun
```
</Tab>
<Tab title="Debug trace">
```powershell
# install.ps1 has no dedicated -Verbose flag yet.
Set-PSDebug -Trace 1
& ([scriptblock]::Create((iwr -useb https://openclaw.ai/install.ps1))) -NoOnboard
Set-PSDebug -Trace 0
```
</Tab>
</Tabs>
<AccordionGroup>
@@ -387,18 +379,6 @@ Use non-interactive flags/env vars for predictable runs.
Run `npm config get prefix`, append `\bin`, add that directory to user PATH, then reopen PowerShell.
</Accordion>
<Accordion title="Windows: how to get verbose installer output">
`install.ps1` does not currently expose a `-Verbose` switch.
Use PowerShell tracing for script-level diagnostics:
```powershell
Set-PSDebug -Trace 1
& ([scriptblock]::Create((iwr -useb https://openclaw.ai/install.ps1))) -NoOnboard
Set-PSDebug -Trace 0
```
</Accordion>
<Accordion title="openclaw not found after install">
Usually a PATH issue. See [Node.js troubleshooting](/install/node#troubleshooting).
</Accordion>

View File

@@ -107,27 +107,8 @@ Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI
- Transcript is available to templates as `{{Transcript}}`.
- CLI stdout is capped (5MB); keep CLI output concise.
## Mention Detection in Groups
When `requireMention: true` is set for a group chat, OpenClaw now transcribes audio **before** checking for mentions. This allows voice notes to be processed even when they contain mentions.
**How it works:**
1. If a voice message has no text body and the group requires mentions, OpenClaw performs a "preflight" transcription.
2. The transcript is checked for mention patterns (e.g., `@BotName`, emoji triggers).
3. If a mention is found, the message proceeds through the full reply pipeline.
4. The transcript is used for mention detection so voice notes can pass the mention gate.
**Fallback behavior:**
- If transcription fails during preflight (timeout, API error, etc.), the message is processed based on text-only mention detection.
- This ensures that mixed messages (text + audio) are never incorrectly dropped.
**Example:** A user sends a voice note saying "Hey @Claude, what's the weather?" in a Telegram group with `requireMention: true`. The voice note is transcribed, the mention is detected, and the agent replies.
## Gotchas
- Scope rules use first-match wins. `chatType` is normalized to `direct`, `group`, or `room`.
- Ensure your CLI exits 0 and prints plain text; JSON needs to be massaged via `jq -r .text`.
- Keep timeouts reasonable (`timeoutSeconds`, default 60s) to avoid blocking the reply queue.
- Preflight transcription only processes the **first** audio attachment for mention detection. Additional audio is processed during the main media understanding phase.

View File

@@ -34,17 +34,17 @@ Notes:
# From repo root; set release IDs so Sparkle feed is enabled.
# APP_BUILD must be numeric + monotonic for Sparkle compare.
BUNDLE_ID=bot.molt.mac \
APP_VERSION=2026.2.13 \
APP_VERSION=2026.2.10 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-app.sh
# Zip for distribution (includes resource forks for Sparkle delta support)
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.13.zip
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.10.zip
# Optional: also build a styled DMG for humans (drag to /Applications)
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.13.dmg
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.10.dmg
# Recommended: build + notarize/staple zip + DMG
# First, create a keychain profile once:
@@ -52,14 +52,14 @@ scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.13.dmg
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \
BUNDLE_ID=bot.molt.mac \
APP_VERSION=2026.2.13 \
APP_VERSION=2026.2.10 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-dist.sh
# Optional: ship dSYM alongside the release
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.13.dSYM.zip
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.10.dSYM.zip
```
## Appcast entry
@@ -67,7 +67,7 @@ ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenCl
Use the release note generator so Sparkle renders formatted HTML notes:
```bash
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.13.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.10.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
```
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
@@ -75,7 +75,7 @@ Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when
## Publish & verify
- Upload `OpenClaw-2026.2.13.zip` (and `OpenClaw-2026.2.13.dSYM.zip`) to the GitHub release for tag `v2026.2.13`.
- Upload `OpenClaw-2026.2.10.zip` (and `OpenClaw-2026.2.10.dSYM.zip`) to the GitHub release for tag `v2026.2.10`.
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`.
- Sanity checks:
- `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` returns 200.

View File

@@ -9,7 +9,7 @@ title: "GLM Models"
# GLM models
GLM is a **model family** (not a company) available through the Z.AI platform. In OpenClaw, GLM
models are accessed via the `zai` provider and model IDs like `zai/glm-5`.
models are accessed via the `zai` provider and model IDs like `zai/glm-4.7`.
## CLI setup
@@ -22,12 +22,12 @@ openclaw onboard --auth-choice zai-api-key
```json5
{
env: { ZAI_API_KEY: "sk-..." },
agents: { defaults: { model: { primary: "zai/glm-5" } } },
agents: { defaults: { model: { primary: "zai/glm-4.7" } } },
}
```
## Notes
- GLM versions and availability can change; check Z.AI's docs for the latest.
- Example model IDs include `glm-5`, `glm-4.7`, and `glm-4.6`.
- Example model IDs include `glm-4.7` and `glm-4.6`.
- For provider details, see [/providers/zai](/providers/zai).

View File

@@ -25,12 +25,12 @@ openclaw onboard --zai-api-key "$ZAI_API_KEY"
```json5
{
env: { ZAI_API_KEY: "sk-..." },
agents: { defaults: { model: { primary: "zai/glm-5" } } },
agents: { defaults: { model: { primary: "zai/glm-4.7" } } },
}
```
## Notes
- GLM models are available as `zai/<model>` (example: `zai/glm-5`).
- GLM models are available as `zai/<model>` (example: `zai/glm-4.7`).
- See [/providers/glm](/providers/glm) for the model family overview.
- Z.AI uses Bearer auth with your API key.

View File

@@ -24,7 +24,6 @@ Scope includes:
- Turn validation / ordering
- Thought signature cleanup
- Image payload sanitization
- User-input provenance tagging (for inter-session routed prompts)
If you need transcript storage details, see:
@@ -73,23 +72,6 @@ Implementation:
---
## Global rule: inter-session input provenance
When an agent sends a prompt into another session via `sessions_send` (including
agent-to-agent reply/announce steps), OpenClaw persists the created user turn with:
- `message.provenance.kind = "inter_session"`
This metadata is written at transcript append time and does not change role
(`role: "user"` remains for provider compatibility). Transcript readers can use
this to avoid treating routed internal prompts as end-user-authored instructions.
During context rebuild, OpenClaw also prepends a short `[Inter-session message]`
marker to those user turns in-memory so the model can distinguish them from
external end-user instructions.
---
## Provider matrix (current behavior)
**OpenAI / OpenAI Codex**

View File

@@ -34,6 +34,22 @@ Start conservative:
You want this:
```mermaid
%%{init: {
'theme': 'base',
'themeVariables': {
'primaryColor': '#ffffff',
'primaryTextColor': '#000000',
'primaryBorderColor': '#000000',
'lineColor': '#000000',
'secondaryColor': '#f9f9fb',
'tertiaryColor': '#ffffff',
'clusterBkg': '#f9f9fb',
'clusterBorder': '#000000',
'nodeBorder': '#000000',
'mainBkg': '#ffffff',
'edgeLabelBackground': '#ffffff'
}
}}%%
flowchart TB
A["<b>Your Phone (personal)<br></b><br>Your WhatsApp<br>+1-555-YOU"] -- message --> B["<b>Second Phone (assistant)<br></b><br>Assistant WA<br>+1-555-ASSIST"]
B -- linked via QR --> C["<b>Your Mac (openclaw)<br></b><br>Pi agent"]

View File

@@ -192,7 +192,6 @@ Notes:
Key ideas:
- Browser control is loopback-only; access flows through the Gateways auth or node pairing.
- If browser control is enabled and no auth is configured, OpenClaw auto-generates `gateway.auth.token` on startup and persists it to config.
- Keep the Gateway and any node hosts on a private network (Tailscale); avoid public exposure.
- Treat remote CDP URLs/tokens as secrets; prefer env vars or a secrets manager.
@@ -316,11 +315,6 @@ For local integrations only, the Gateway exposes a small loopback HTTP API:
All endpoints accept `?profile=<name>`.
If gateway auth is configured, browser HTTP routes require auth too:
- `Authorization: Bearer <gateway token>`
- `x-openclaw-password: <gateway password>` or HTTP Basic auth with that password
### Playwright requirement
Some features (navigate/act/AI snapshot/role snapshot, element screenshots, PDF) require

View File

@@ -48,11 +48,12 @@ hooks 系统允许你:
### 捆绑的 Hooks
OpenClaw 附带个自动发现的捆绑 hooks
OpenClaw 附带个自动发现的捆绑 hooks
- **💾 session-memory**:当你发出 `/new` 时将会话上下文保存到智能体工作区(默认 `~/.openclaw/workspace/memory/`
- **📝 command-logger**:将所有命令事件记录到 `~/.openclaw/logs/commands.log`
- **🚀 boot-md**:当 Gateway 网关启动时运行 `BOOT.md`(需要启用内部 hooks
- **😈 soul-evil**:在清除窗口期间或随机机会下将注入的 `SOUL.md` 内容替换为 `SOUL_EVIL.md`
列出可用的 hooks
@@ -532,6 +533,42 @@ grep '"action":"new"' ~/.openclaw/logs/commands.log | jq .
openclaw hooks enable command-logger
```
### soul-evil
在清除窗口期间或随机机会下将注入的 `SOUL.md` 内容替换为 `SOUL_EVIL.md`
**事件**`agent:bootstrap`
**文档**[SOUL Evil Hook](/hooks/soul-evil)
**输出**:不写入文件;替换仅在内存中发生。
**启用**
```bash
openclaw hooks enable soul-evil
```
**配置**
```json
{
"hooks": {
"internal": {
"enabled": true,
"entries": {
"soul-evil": {
"enabled": true,
"file": "SOUL_EVIL.md",
"chance": 0.1,
"purge": { "at": "21:00", "duration": "15m" }
}
}
}
}
}
```
### boot-md
当 Gateway 网关启动时运行 `BOOT.md`(在渠道启动之后)。

View File

@@ -39,12 +39,13 @@ openclaw hooks list
**示例输出:**
```
Hooks (3/3 ready)
Hooks (4/4 ready)
Ready:
🚀 boot-md ✓ - Run BOOT.md on gateway startup
📝 command-logger ✓ - Log all command events to a centralized audit file
💾 session-memory ✓ - Save session context to memory when /new command is issued
😈 soul-evil ✓ - Swap injected SOUL content during a purge window or by random chance
```
**示例(详细模式):**
@@ -283,6 +284,18 @@ grep '"action":"new"' ~/.openclaw/logs/commands.log | jq .
**参见:** [command-logger 文档](/automation/hooks#command-logger)
### soul-evil
在清除窗口期间或随机情况下,将注入的 `SOUL.md` 内容替换为 `SOUL_EVIL.md`
**启用:**
```bash
openclaw hooks enable soul-evil
```
**参见:** [SOUL Evil 钩子](/hooks/soul-evil)
### boot-md
在 Gateway 网关启动时(渠道启动后)运行 `BOOT.md`

View File

@@ -0,0 +1,72 @@
---
read_when:
- 你想要启用或调整 SOUL Evil 钩子
- 你想要设置清除窗口或随机概率的人格替换
summary: SOUL Evil 钩子(将 SOUL.md 替换为 SOUL_EVIL.md
title: SOUL Evil 钩子
x-i18n:
generated_at: "2026-02-01T20:42:18Z"
model: claude-opus-4-5
provider: pi
source_hash: cc32c1e207f2b6923a6ede8299293f8fc07f3c8d6b2a377775237c0173fe8d1b
source_path: hooks/soul-evil.md
workflow: 14
---
# SOUL Evil 钩子
SOUL Evil 钩子在清除窗口期间或随机概率下,将**注入的** `SOUL.md` 内容替换为 `SOUL_EVIL.md`。它**不会**修改磁盘上的文件。
## 工作原理
`agent:bootstrap` 运行时,该钩子可以在系统提示词组装之前,在内存中替换 `SOUL.md` 的内容。如果 `SOUL_EVIL.md` 缺失或为空OpenClaw 会记录警告并保留正常的 `SOUL.md`
子智能体运行**不会**在其引导文件中包含 `SOUL.md`,因此此钩子对子智能体没有影响。
## 启用
```bash
openclaw hooks enable soul-evil
```
然后设置配置:
```json
{
"hooks": {
"internal": {
"enabled": true,
"entries": {
"soul-evil": {
"enabled": true,
"file": "SOUL_EVIL.md",
"chance": 0.1,
"purge": { "at": "21:00", "duration": "15m" }
}
}
}
}
}
```
在智能体工作区根目录(`SOUL.md` 旁边)创建 `SOUL_EVIL.md`
## 选项
- `file`(字符串):替代的 SOUL 文件名(默认:`SOUL_EVIL.md`
- `chance`(数字 01每次运行使用 `SOUL_EVIL.md` 的随机概率
- `purge.at`HH:mm每日清除开始时间24 小时制)
- `purge.duration`(时长):窗口长度(例如 `30s``10m``1h`
**优先级:** 清除窗口优先于随机概率。
**时区:** 设置了 `agents.defaults.userTimezone` 时使用该时区;否则使用主机时区。
## 注意事项
- 不会在磁盘上写入或修改任何文件。
- 如果 `SOUL.md` 不在引导列表中,该钩子不执行任何操作。
## 另请参阅
- [钩子](/automation/hooks)

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/bluebubbles",
"version": "2026.2.12",
"version": "2026.2.10",
"description": "OpenClaw BlueBubbles channel plugin",
"type": "module",
"devDependencies": {

View File

@@ -254,20 +254,9 @@ function createMockRequest(
body: unknown,
headers: Record<string, string> = {},
): IncomingMessage {
const parsedUrl = new URL(url, "http://localhost");
const hasAuthQuery = parsedUrl.searchParams.has("guid") || parsedUrl.searchParams.has("password");
const hasAuthHeader =
headers["x-guid"] !== undefined ||
headers["x-password"] !== undefined ||
headers["x-bluebubbles-guid"] !== undefined ||
headers.authorization !== undefined;
if (!hasAuthQuery && !hasAuthHeader) {
parsedUrl.searchParams.set("password", "test-password");
}
const req = new EventEmitter() as IncomingMessage;
req.method = method;
req.url = `${parsedUrl.pathname}${parsedUrl.search}`;
req.url = url;
req.headers = headers;
(req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress: "127.0.0.1" };
@@ -557,41 +546,40 @@ describe("BlueBubbles webhook monitor", () => {
expect(res.statusCode).toBe(401);
});
it("requires authentication for loopback requests when password is configured", async () => {
it("allows localhost requests without authentication", async () => {
const account = createMockAccount({ password: "secret-token" });
const config: OpenClawConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
for (const remoteAddress of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) {
const req = createMockRequest("POST", "/bluebubbles-webhook", {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
});
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress,
};
const loopbackUnregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const req = createMockRequest("POST", "/bluebubbles-webhook", {
type: "new-message",
data: {
text: "hello",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
},
});
// Localhost address
(req as unknown as { socket: { remoteAddress: string } }).socket = {
remoteAddress: "127.0.0.1",
};
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(401);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
loopbackUnregister();
}
const res = createMockResponse();
const handled = await handleBlueBubblesWebhookRequest(req, res);
expect(handled).toBe(true);
expect(res.statusCode).toBe(200);
});
it("ignores unregistered webhook paths", async () => {

View File

@@ -1533,6 +1533,10 @@ export async function handleBlueBubblesWebhookRequest(
if (guid && guid.trim() === token) {
return true;
}
const remote = req.socket?.remoteAddress ?? "";
if (remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1") {
return true;
}
return false;
});

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/copilot-proxy",
"version": "2026.2.12",
"version": "2026.2.10",
"private": true,
"description": "OpenClaw Copilot Proxy provider plugin",
"type": "module",

View File

@@ -1,19 +1,19 @@
{
"name": "@openclaw/diagnostics-otel",
"version": "2026.2.12",
"version": "2026.2.10",
"description": "OpenClaw diagnostics OpenTelemetry exporter",
"type": "module",
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.212.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.212.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.212.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.212.0",
"@opentelemetry/resources": "^2.5.1",
"@opentelemetry/sdk-logs": "^0.212.0",
"@opentelemetry/sdk-metrics": "^2.5.1",
"@opentelemetry/sdk-node": "^0.212.0",
"@opentelemetry/sdk-trace-base": "^2.5.1",
"@opentelemetry/api-logs": "^0.211.0",
"@opentelemetry/exporter-logs-otlp-http": "^0.211.0",
"@opentelemetry/exporter-metrics-otlp-http": "^0.211.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.211.0",
"@opentelemetry/resources": "^2.5.0",
"@opentelemetry/sdk-logs": "^0.211.0",
"@opentelemetry/sdk-metrics": "^2.5.0",
"@opentelemetry/sdk-node": "^0.211.0",
"@opentelemetry/sdk-trace-base": "^2.5.0",
"@opentelemetry/semantic-conventions": "^1.39.0"
},
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/discord",
"version": "2026.2.12",
"version": "2026.2.10",
"description": "OpenClaw Discord channel plugin",
"type": "module",
"devDependencies": {

View File

@@ -1,10 +1,10 @@
{
"name": "@openclaw/feishu",
"version": "2026.2.12",
"version": "2026.2.10",
"description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
"type": "module",
"dependencies": {
"@larksuiteoapi/node-sdk": "^1.59.0",
"@larksuiteoapi/node-sdk": "^1.58.0",
"@sinclair/typebox": "0.34.48",
"zod": "^4.3.6"
},

View File

@@ -1,265 +0,0 @@
import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { FeishuMessageEvent } from "./bot.js";
import { handleFeishuMessage } from "./bot.js";
import { setFeishuRuntime } from "./runtime.js";
const { mockCreateFeishuReplyDispatcher, mockSendMessageFeishu, mockGetMessageFeishu } = vi.hoisted(
() => ({
mockCreateFeishuReplyDispatcher: vi.fn(() => ({
dispatcher: vi.fn(),
replyOptions: {},
markDispatchIdle: vi.fn(),
})),
mockSendMessageFeishu: vi.fn().mockResolvedValue({ messageId: "pairing-msg", chatId: "oc-dm" }),
mockGetMessageFeishu: vi.fn().mockResolvedValue(null),
}),
);
vi.mock("./reply-dispatcher.js", () => ({
createFeishuReplyDispatcher: mockCreateFeishuReplyDispatcher,
}));
vi.mock("./send.js", () => ({
sendMessageFeishu: mockSendMessageFeishu,
getMessageFeishu: mockGetMessageFeishu,
}));
describe("handleFeishuMessage command authorization", () => {
const mockFinalizeInboundContext = vi.fn((ctx: unknown) => ctx);
const mockDispatchReplyFromConfig = vi
.fn()
.mockResolvedValue({ queuedFinal: false, counts: { final: 1 } });
const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false);
const mockShouldComputeCommandAuthorized = vi.fn(() => true);
const mockReadAllowFromStore = vi.fn().mockResolvedValue([]);
const mockUpsertPairingRequest = vi.fn().mockResolvedValue({ code: "ABCDEFGH", created: false });
const mockBuildPairingReply = vi.fn(() => "Pairing response");
beforeEach(() => {
vi.clearAllMocks();
setFeishuRuntime({
system: {
enqueueSystemEvent: vi.fn(),
},
channel: {
routing: {
resolveAgentRoute: vi.fn(() => ({
agentId: "main",
accountId: "default",
sessionKey: "agent:main:feishu:dm:ou-attacker",
matchedBy: "default",
})),
},
reply: {
resolveEnvelopeFormatOptions: vi.fn(() => ({ template: "channel+name+time" })),
formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
finalizeInboundContext: mockFinalizeInboundContext,
dispatchReplyFromConfig: mockDispatchReplyFromConfig,
},
commands: {
shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized,
resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers,
},
pairing: {
readAllowFromStore: mockReadAllowFromStore,
upsertPairingRequest: mockUpsertPairingRequest,
buildPairingReply: mockBuildPairingReply,
},
},
} as unknown as PluginRuntime);
});
it("uses authorizer resolution instead of hardcoded CommandAuthorized=true", async () => {
const cfg: ClawdbotConfig = {
commands: { useAccessGroups: true },
channels: {
feishu: {
dmPolicy: "open",
allowFrom: ["ou-admin"],
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-attacker",
},
},
message: {
message_id: "msg-auth-bypass-regression",
chat_id: "oc-dm",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "/status" }),
},
};
await handleFeishuMessage({
cfg,
event,
runtime: { log: vi.fn(), error: vi.fn() } as RuntimeEnv,
});
expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({
useAccessGroups: true,
authorizers: [{ configured: true, allowed: false }],
});
expect(mockFinalizeInboundContext).toHaveBeenCalledTimes(1);
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
CommandAuthorized: false,
SenderId: "ou-attacker",
Surface: "feishu",
}),
);
});
it("reads pairing allow store for non-command DMs when dmPolicy is pairing", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
mockReadAllowFromStore.mockResolvedValue(["ou-attacker"]);
const cfg: ClawdbotConfig = {
commands: { useAccessGroups: true },
channels: {
feishu: {
dmPolicy: "pairing",
allowFrom: [],
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-attacker",
},
},
message: {
message_id: "msg-read-store-non-command",
chat_id: "oc-dm",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "hello there" }),
},
};
await handleFeishuMessage({
cfg,
event,
runtime: { log: vi.fn(), error: vi.fn() } as RuntimeEnv,
});
expect(mockReadAllowFromStore).toHaveBeenCalledWith("feishu");
expect(mockResolveCommandAuthorizedFromAuthorizers).not.toHaveBeenCalled();
expect(mockFinalizeInboundContext).toHaveBeenCalledTimes(1);
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
});
it("creates pairing request and drops unauthorized DMs in pairing mode", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
mockReadAllowFromStore.mockResolvedValue([]);
mockUpsertPairingRequest.mockResolvedValue({ code: "ABCDEFGH", created: true });
const cfg: ClawdbotConfig = {
channels: {
feishu: {
dmPolicy: "pairing",
allowFrom: [],
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-unapproved",
},
},
message: {
message_id: "msg-pairing-flow",
chat_id: "oc-dm",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "hello" }),
},
};
await handleFeishuMessage({
cfg,
event,
runtime: { log: vi.fn(), error: vi.fn() } as RuntimeEnv,
});
expect(mockUpsertPairingRequest).toHaveBeenCalledWith({
channel: "feishu",
id: "ou-unapproved",
meta: { name: undefined },
});
expect(mockBuildPairingReply).toHaveBeenCalledWith({
channel: "feishu",
idLine: "Your Feishu user id: ou-unapproved",
code: "ABCDEFGH",
});
expect(mockSendMessageFeishu).toHaveBeenCalledWith(
expect.objectContaining({
to: "user:ou-unapproved",
accountId: "default",
}),
);
expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
});
it("computes group command authorization from group allowFrom", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(true);
mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(false);
const cfg: ClawdbotConfig = {
commands: { useAccessGroups: true },
channels: {
feishu: {
groups: {
"oc-group": {
requireMention: false,
},
},
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-attacker",
},
},
message: {
message_id: "msg-group-command-auth",
chat_id: "oc-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "/status" }),
},
};
await handleFeishuMessage({
cfg,
event,
runtime: { log: vi.fn(), error: vi.fn() } as RuntimeEnv,
});
expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({
useAccessGroups: true,
authorizers: [{ configured: false, allowed: false }],
});
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
ChatType: "group",
CommandAuthorized: false,
SenderId: "ou-attacker",
}),
);
});
});

View File

@@ -21,7 +21,7 @@ import {
} from "./policy.js";
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
import { getFeishuRuntime } from "./runtime.js";
import { getMessageFeishu, sendMessageFeishu } from "./send.js";
import { getMessageFeishu } from "./send.js";
// --- Message deduplication ---
// Prevent duplicate processing when WebSocket reconnects or Feishu redelivers messages.
@@ -581,17 +581,12 @@ export async function handleFeishuMessage(params: {
0,
feishuCfg?.historyLimit ?? cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
);
const groupConfig = isGroup
? resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId })
: undefined;
const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
const configAllowFrom = feishuCfg?.allowFrom ?? [];
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
if (isGroup) {
const groupPolicy = feishuCfg?.groupPolicy ?? "open";
const groupAllowFrom = feishuCfg?.groupAllowFrom ?? [];
// DEBUG: log(`feishu[${account.accountId}]: groupPolicy=${groupPolicy}`);
const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId });
// Check if this GROUP is allowed (groupAllowFrom contains group IDs like oc_xxx, not user IDs)
const groupAllowed = isFeishuGroupAllowed({
@@ -647,73 +642,23 @@ export async function handleFeishuMessage(params: {
return;
}
} else {
const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
const allowFrom = feishuCfg?.allowFrom ?? [];
if (dmPolicy === "allowlist") {
const match = resolveFeishuAllowlistMatch({
allowFrom,
senderId: ctx.senderOpenId,
});
if (!match.allowed) {
log(`feishu[${account.accountId}]: sender ${ctx.senderOpenId} not in DM allowlist`);
return;
}
}
}
try {
const core = getFeishuRuntime();
const shouldComputeCommandAuthorized = core.channel.commands.shouldComputeCommandAuthorized(
ctx.content,
cfg,
);
const storeAllowFrom =
!isGroup && (dmPolicy !== "open" || shouldComputeCommandAuthorized)
? await core.channel.pairing.readAllowFromStore("feishu").catch(() => [])
: [];
const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom];
const dmAllowed = resolveFeishuAllowlistMatch({
allowFrom: effectiveDmAllowFrom,
senderId: ctx.senderOpenId,
senderName: ctx.senderName,
}).allowed;
if (!isGroup && dmPolicy !== "open" && !dmAllowed) {
if (dmPolicy === "pairing") {
const { code, created } = await core.channel.pairing.upsertPairingRequest({
channel: "feishu",
id: ctx.senderOpenId,
meta: { name: ctx.senderName },
});
if (created) {
log(`feishu[${account.accountId}]: pairing request sender=${ctx.senderOpenId}`);
try {
await sendMessageFeishu({
cfg,
to: `user:${ctx.senderOpenId}`,
text: core.channel.pairing.buildPairingReply({
channel: "feishu",
idLine: `Your Feishu user id: ${ctx.senderOpenId}`,
code,
}),
accountId: account.accountId,
});
} catch (err) {
log(
`feishu[${account.accountId}]: pairing reply failed for ${ctx.senderOpenId}: ${String(err)}`,
);
}
}
} else {
log(
`feishu[${account.accountId}]: blocked unauthorized sender ${ctx.senderOpenId} (dmPolicy=${dmPolicy})`,
);
}
return;
}
const commandAllowFrom = isGroup ? (groupConfig?.allowFrom ?? []) : effectiveDmAllowFrom;
const senderAllowedForCommands = resolveFeishuAllowlistMatch({
allowFrom: commandAllowFrom,
senderId: ctx.senderOpenId,
senderName: ctx.senderName,
}).allowed;
const commandAuthorized = shouldComputeCommandAuthorized
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands },
],
})
: undefined;
// In group chats, the session is scoped to the group, but the *speaker* is the sender.
// Using a group-scoped From causes the agent to treat different users as the same person.
@@ -870,7 +815,7 @@ export async function handleFeishuMessage(params: {
MessageSid: `${ctx.messageId}:permission-error`,
Timestamp: Date.now(),
WasMentioned: false,
CommandAuthorized: commandAuthorized,
CommandAuthorized: true,
OriginatingChannel: "feishu" as const,
OriginatingTo: feishuTo,
});
@@ -958,7 +903,7 @@ export async function handleFeishuMessage(params: {
ReplyToBody: quotedContent ?? undefined,
Timestamp: Date.now(),
WasMentioned: ctx.mentionedBot,
CommandAuthorized: commandAuthorized,
CommandAuthorized: true,
OriginatingChannel: "feishu" as const,
OriginatingTo: feishuTo,
...mediaPayload,

View File

@@ -36,10 +36,6 @@ const MarkdownConfigSchema = z
// Message render mode: auto (default) = detect markdown, raw = plain text, card = always card
const RenderModeSchema = z.enum(["auto", "raw", "card"]).optional();
// Streaming card mode: when enabled, card replies use Feishu's Card Kit streaming API
// for incremental text display with a "Thinking..." placeholder
const StreamingModeSchema = z.boolean().optional();
const BlockStreamingCoalesceSchema = z
.object({
enabled: z.boolean().optional(),
@@ -146,7 +142,6 @@ export const FeishuAccountConfigSchema = z
mediaMaxMb: z.number().positive().optional(),
heartbeat: ChannelHeartbeatVisibilitySchema,
renderMode: RenderModeSchema,
streaming: StreamingModeSchema, // Enable streaming card mode (default: true)
tools: FeishuToolsConfigSchema,
})
.strict();
@@ -182,7 +177,6 @@ export const FeishuConfigSchema = z
mediaMaxMb: z.number().positive().optional(),
heartbeat: ChannelHeartbeatVisibilitySchema,
renderMode: RenderModeSchema, // raw = plain text (default), card = interactive card with markdown
streaming: StreamingModeSchema, // Enable streaming card mode (default: true)
tools: FeishuToolsConfigSchema,
// Dynamic agent creation for DM users
dynamicAgentCreation: DynamicAgentCreationSchema,

View File

@@ -1,151 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const createFeishuClientMock = vi.hoisted(() => vi.fn());
const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
const normalizeFeishuTargetMock = vi.hoisted(() => vi.fn());
const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
const fileCreateMock = vi.hoisted(() => vi.fn());
const messageCreateMock = vi.hoisted(() => vi.fn());
const messageReplyMock = vi.hoisted(() => vi.fn());
vi.mock("./client.js", () => ({
createFeishuClient: createFeishuClientMock,
}));
vi.mock("./accounts.js", () => ({
resolveFeishuAccount: resolveFeishuAccountMock,
}));
vi.mock("./targets.js", () => ({
normalizeFeishuTarget: normalizeFeishuTargetMock,
resolveReceiveIdType: resolveReceiveIdTypeMock,
}));
import { sendMediaFeishu } from "./media.js";
describe("sendMediaFeishu msg_type routing", () => {
beforeEach(() => {
vi.clearAllMocks();
resolveFeishuAccountMock.mockReturnValue({
configured: true,
accountId: "main",
appId: "app_id",
appSecret: "app_secret",
domain: "feishu",
});
normalizeFeishuTargetMock.mockReturnValue("ou_target");
resolveReceiveIdTypeMock.mockReturnValue("open_id");
createFeishuClientMock.mockReturnValue({
im: {
file: {
create: fileCreateMock,
},
message: {
create: messageCreateMock,
reply: messageReplyMock,
},
},
});
fileCreateMock.mockResolvedValue({
code: 0,
data: { file_key: "file_key_1" },
});
messageCreateMock.mockResolvedValue({
code: 0,
data: { message_id: "msg_1" },
});
messageReplyMock.mockResolvedValue({
code: 0,
data: { message_id: "reply_1" },
});
});
it("uses msg_type=media for mp4", async () => {
await sendMediaFeishu({
cfg: {} as any,
to: "user:ou_target",
mediaBuffer: Buffer.from("video"),
fileName: "clip.mp4",
});
expect(fileCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ file_type: "mp4" }),
}),
);
expect(messageCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ msg_type: "media" }),
}),
);
});
it("uses msg_type=media for opus", async () => {
await sendMediaFeishu({
cfg: {} as any,
to: "user:ou_target",
mediaBuffer: Buffer.from("audio"),
fileName: "voice.opus",
});
expect(fileCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ file_type: "opus" }),
}),
);
expect(messageCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ msg_type: "media" }),
}),
);
});
it("uses msg_type=file for documents", async () => {
await sendMediaFeishu({
cfg: {} as any,
to: "user:ou_target",
mediaBuffer: Buffer.from("doc"),
fileName: "paper.pdf",
});
expect(fileCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ file_type: "pdf" }),
}),
);
expect(messageCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ msg_type: "file" }),
}),
);
});
it("uses msg_type=media when replying with mp4", async () => {
await sendMediaFeishu({
cfg: {} as any,
to: "user:ou_target",
mediaBuffer: Buffer.from("video"),
fileName: "reply.mp4",
replyToMessageId: "om_parent",
});
expect(messageReplyMock).toHaveBeenCalledWith(
expect.objectContaining({
path: { message_id: "om_parent" },
data: expect.objectContaining({ msg_type: "media" }),
}),
);
expect(messageCreateMock).not.toHaveBeenCalled();
});
});

View File

@@ -359,13 +359,10 @@ export async function sendFileFeishu(params: {
cfg: ClawdbotConfig;
to: string;
fileKey: string;
/** Use "media" for audio/video files, "file" for documents */
msgType?: "file" | "media";
replyToMessageId?: string;
accountId?: string;
}): Promise<SendMediaResult> {
const { cfg, to, fileKey, replyToMessageId, accountId } = params;
const msgType = params.msgType ?? "file";
const account = resolveFeishuAccount({ cfg, accountId });
if (!account.configured) {
throw new Error(`Feishu account "${account.accountId}" not configured`);
@@ -385,7 +382,7 @@ export async function sendFileFeishu(params: {
path: { message_id: replyToMessageId },
data: {
content,
msg_type: msgType,
msg_type: "file",
},
});
@@ -404,7 +401,7 @@ export async function sendFileFeishu(params: {
data: {
receive_id: receiveId,
content,
msg_type: msgType,
msg_type: "file",
},
});
@@ -527,15 +524,6 @@ export async function sendMediaFeishu(params: {
fileType,
accountId,
});
// Feishu requires msg_type "media" for audio/video, "file" for documents
const isMedia = fileType === "mp4" || fileType === "opus";
return sendFileFeishu({
cfg,
to,
fileKey,
msgType: isMedia ? "media" : "file",
replyToMessageId,
accountId,
});
return sendFileFeishu({ cfg, to, fileKey, replyToMessageId, accountId });
}
}

View File

@@ -1,116 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
const getFeishuRuntimeMock = vi.hoisted(() => vi.fn());
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
const createFeishuClientMock = vi.hoisted(() => vi.fn());
const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
const createReplyDispatcherWithTypingMock = vi.hoisted(() => vi.fn());
const streamingInstances = vi.hoisted(() => [] as any[]);
vi.mock("./accounts.js", () => ({ resolveFeishuAccount: resolveFeishuAccountMock }));
vi.mock("./runtime.js", () => ({ getFeishuRuntime: getFeishuRuntimeMock }));
vi.mock("./send.js", () => ({
sendMessageFeishu: sendMessageFeishuMock,
sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
}));
vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock }));
vi.mock("./targets.js", () => ({ resolveReceiveIdType: resolveReceiveIdTypeMock }));
vi.mock("./streaming-card.js", () => ({
FeishuStreamingSession: class {
active = false;
start = vi.fn(async () => {
this.active = true;
});
update = vi.fn(async () => {});
close = vi.fn(async () => {
this.active = false;
});
isActive = vi.fn(() => this.active);
constructor() {
streamingInstances.push(this);
}
},
}));
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
describe("createFeishuReplyDispatcher streaming behavior", () => {
beforeEach(() => {
vi.clearAllMocks();
streamingInstances.length = 0;
resolveFeishuAccountMock.mockReturnValue({
accountId: "main",
appId: "app_id",
appSecret: "app_secret",
domain: "feishu",
config: {
renderMode: "auto",
streaming: true,
},
});
resolveReceiveIdTypeMock.mockReturnValue("chat_id");
createFeishuClientMock.mockReturnValue({});
createReplyDispatcherWithTypingMock.mockImplementation((opts) => ({
dispatcher: {},
replyOptions: {},
markDispatchIdle: vi.fn(),
_opts: opts,
}));
getFeishuRuntimeMock.mockReturnValue({
channel: {
text: {
resolveTextChunkLimit: vi.fn(() => 4000),
resolveChunkMode: vi.fn(() => "line"),
resolveMarkdownTableMode: vi.fn(() => "preserve"),
convertMarkdownTables: vi.fn((text) => text),
chunkTextWithMode: vi.fn((text) => [text]),
},
reply: {
createReplyDispatcherWithTyping: createReplyDispatcherWithTypingMock,
resolveHumanDelayConfig: vi.fn(() => undefined),
},
},
});
});
it("keeps auto mode plain text on non-streaming send path", async () => {
createFeishuReplyDispatcher({
cfg: {} as never,
agentId: "agent",
runtime: {} as never,
chatId: "oc_chat",
});
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
await options.deliver({ text: "plain text" }, { kind: "final" });
expect(streamingInstances).toHaveLength(0);
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
});
it("uses streaming session for auto mode markdown payloads", async () => {
createFeishuReplyDispatcher({
cfg: {} as never,
agentId: "agent",
runtime: { log: vi.fn(), error: vi.fn() } as never,
chatId: "oc_chat",
});
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
expect(streamingInstances).toHaveLength(1);
expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
});
});

View File

@@ -3,22 +3,29 @@ import {
createTypingCallbacks,
logTypingFailure,
type ClawdbotConfig,
type ReplyPayload,
type RuntimeEnv,
type ReplyPayload,
} from "openclaw/plugin-sdk";
import type { MentionTarget } from "./mention.js";
import { resolveFeishuAccount } from "./accounts.js";
import { createFeishuClient } from "./client.js";
import { buildMentionedCardContent } from "./mention.js";
import { getFeishuRuntime } from "./runtime.js";
import { sendMarkdownCardFeishu, sendMessageFeishu } from "./send.js";
import { FeishuStreamingSession } from "./streaming-card.js";
import { resolveReceiveIdType } from "./targets.js";
import { sendMessageFeishu, sendMarkdownCardFeishu } from "./send.js";
import { addTypingIndicator, removeTypingIndicator, type TypingIndicatorState } from "./typing.js";
/** Detect if text contains markdown elements that benefit from card rendering */
/**
* Detect if text contains markdown elements that benefit from card rendering.
* Used by auto render mode.
*/
function shouldUseCard(text: string): boolean {
return /```[\s\S]*?```/.test(text) || /\|.+\|[\r\n]+\|[-:| ]+\|/.test(text);
// Code blocks (fenced)
if (/```[\s\S]*?```/.test(text)) {
return true;
}
// Tables (at least header + separator row with |)
if (/\|.+\|[\r\n]+\|[-:| ]+\|/.test(text)) {
return true;
}
return false;
}
export type CreateFeishuReplyDispatcherParams = {
@@ -27,23 +34,35 @@ export type CreateFeishuReplyDispatcherParams = {
runtime: RuntimeEnv;
chatId: string;
replyToMessageId?: string;
/** Mention targets, will be auto-included in replies */
mentionTargets?: MentionTarget[];
/** Account ID for multi-account support */
accountId?: string;
};
export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherParams) {
const core = getFeishuRuntime();
const { cfg, agentId, chatId, replyToMessageId, mentionTargets, accountId } = params;
const account = resolveFeishuAccount({ cfg, accountId });
const prefixContext = createReplyPrefixContext({ cfg, agentId });
// Resolve account for config access
const account = resolveFeishuAccount({ cfg, accountId });
const prefixContext = createReplyPrefixContext({
cfg,
agentId,
});
// Feishu doesn't have a native typing indicator API.
// We use message reactions as a typing indicator substitute.
let typingState: TypingIndicatorState | null = null;
const typingCallbacks = createTypingCallbacks({
start: async () => {
if (!replyToMessageId) {
return;
}
typingState = await addTypingIndicator({ cfg, messageId: replyToMessageId, accountId });
params.runtime.log?.(`feishu[${account.accountId}]: added typing indicator reaction`);
},
stop: async () => {
if (!typingState) {
@@ -51,21 +70,24 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
}
await removeTypingIndicator({ cfg, state: typingState, accountId });
typingState = null;
params.runtime.log?.(`feishu[${account.accountId}]: removed typing indicator reaction`);
},
onStartError: (err) =>
onStartError: (err) => {
logTypingFailure({
log: (message) => params.runtime.log?.(message),
channel: "feishu",
action: "start",
error: err,
}),
onStopError: (err) =>
});
},
onStopError: (err) => {
logTypingFailure({
log: (message) => params.runtime.log?.(message),
channel: "feishu",
action: "stop",
error: err,
}),
});
},
});
const textChunkLimit = core.channel.text.resolveTextChunkLimit(cfg, "feishu", accountId, {
@@ -73,139 +95,77 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
});
const chunkMode = core.channel.text.resolveChunkMode(cfg, "feishu");
const tableMode = core.channel.text.resolveMarkdownTableMode({ cfg, channel: "feishu" });
const renderMode = account.config?.renderMode ?? "auto";
const streamingEnabled = account.config?.streaming !== false && renderMode !== "raw";
let streaming: FeishuStreamingSession | null = null;
let streamText = "";
let lastPartial = "";
let partialUpdateQueue: Promise<void> = Promise.resolve();
let streamingStartPromise: Promise<void> | null = null;
const startStreaming = () => {
if (!streamingEnabled || streamingStartPromise || streaming) {
return;
}
streamingStartPromise = (async () => {
const creds =
account.appId && account.appSecret
? { appId: account.appId, appSecret: account.appSecret, domain: account.domain }
: null;
if (!creds) {
return;
}
streaming = new FeishuStreamingSession(createFeishuClient(account), creds, (message) =>
params.runtime.log?.(`feishu[${account.accountId}] ${message}`),
);
try {
await streaming.start(chatId, resolveReceiveIdType(chatId));
} catch (error) {
params.runtime.error?.(`feishu: streaming start failed: ${String(error)}`);
streaming = null;
}
})();
};
const closeStreaming = async () => {
if (streamingStartPromise) {
await streamingStartPromise;
}
await partialUpdateQueue;
if (streaming?.isActive()) {
let text = streamText;
if (mentionTargets?.length) {
text = buildMentionedCardContent(mentionTargets, text);
}
await streaming.close(text);
}
streaming = null;
streamingStartPromise = null;
streamText = "";
lastPartial = "";
};
const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({
responsePrefix: prefixContext.responsePrefix,
responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
onReplyStart: () => {
if (streamingEnabled && renderMode === "card") {
startStreaming();
}
void typingCallbacks.onReplyStart?.();
},
deliver: async (payload: ReplyPayload, info) => {
onReplyStart: typingCallbacks.onReplyStart,
deliver: async (payload: ReplyPayload) => {
params.runtime.log?.(
`feishu[${account.accountId}] deliver called: text=${payload.text?.slice(0, 100)}`,
);
const text = payload.text ?? "";
if (!text.trim()) {
params.runtime.log?.(`feishu[${account.accountId}] deliver: empty text, skipping`);
return;
}
// Check render mode: auto (default), raw, or card
const feishuCfg = account.config;
const renderMode = feishuCfg?.renderMode ?? "auto";
// Determine if we should use card for this message
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
if ((info?.kind === "block" || info?.kind === "final") && streamingEnabled && useCard) {
startStreaming();
if (streamingStartPromise) {
await streamingStartPromise;
}
}
// Only include @mentions in the first chunk (avoid duplicate @s)
let isFirstChunk = true;
if (streaming?.isActive()) {
if (info?.kind === "final") {
streamText = text;
await closeStreaming();
}
return;
}
let first = true;
if (useCard) {
for (const chunk of core.channel.text.chunkTextWithMode(
text,
textChunkLimit,
chunkMode,
)) {
// Card mode: send as interactive card with markdown rendering
const chunks = core.channel.text.chunkTextWithMode(text, textChunkLimit, chunkMode);
params.runtime.log?.(
`feishu[${account.accountId}] deliver: sending ${chunks.length} card chunks to ${chatId}`,
);
for (const chunk of chunks) {
await sendMarkdownCardFeishu({
cfg,
to: chatId,
text: chunk,
replyToMessageId,
mentions: first ? mentionTargets : undefined,
mentions: isFirstChunk ? mentionTargets : undefined,
accountId,
});
first = false;
isFirstChunk = false;
}
} else {
// Raw mode: send as plain text with table conversion
const converted = core.channel.text.convertMarkdownTables(text, tableMode);
for (const chunk of core.channel.text.chunkTextWithMode(
converted,
textChunkLimit,
chunkMode,
)) {
const chunks = core.channel.text.chunkTextWithMode(converted, textChunkLimit, chunkMode);
params.runtime.log?.(
`feishu[${account.accountId}] deliver: sending ${chunks.length} text chunks to ${chatId}`,
);
for (const chunk of chunks) {
await sendMessageFeishu({
cfg,
to: chatId,
text: chunk,
replyToMessageId,
mentions: first ? mentionTargets : undefined,
mentions: isFirstChunk ? mentionTargets : undefined,
accountId,
});
first = false;
isFirstChunk = false;
}
}
},
onError: async (error, info) => {
onError: (err, info) => {
params.runtime.error?.(
`feishu[${account.accountId}] ${info.kind} reply failed: ${String(error)}`,
`feishu[${account.accountId}] ${info.kind} reply failed: ${String(err)}`,
);
await closeStreaming();
typingCallbacks.onIdle?.();
},
onIdle: async () => {
await closeStreaming();
typingCallbacks.onIdle?.();
},
onIdle: typingCallbacks.onIdle,
});
return {
@@ -213,23 +173,6 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
replyOptions: {
...replyOptions,
onModelSelected: prefixContext.onModelSelected,
onPartialReply: streamingEnabled
? (payload: ReplyPayload) => {
if (!payload.text || payload.text === lastPartial) {
return;
}
lastPartial = payload.text;
streamText = payload.text;
partialUpdateQueue = partialUpdateQueue.then(async () => {
if (streamingStartPromise) {
await streamingStartPromise;
}
if (streaming?.isActive()) {
await streaming.update(streamText);
}
});
}
: undefined,
},
markDispatchIdle,
};

View File

@@ -1,223 +0,0 @@
/**
* Feishu Streaming Card - Card Kit streaming API for real-time text output
*/
import type { Client } from "@larksuiteoapi/node-sdk";
import type { FeishuDomain } from "./types.js";
type Credentials = { appId: string; appSecret: string; domain?: FeishuDomain };
type CardState = { cardId: string; messageId: string; sequence: number; currentText: string };
// Token cache (keyed by domain + appId)
const tokenCache = new Map<string, { token: string; expiresAt: number }>();
function resolveApiBase(domain?: FeishuDomain): string {
if (domain === "lark") {
return "https://open.larksuite.com/open-apis";
}
if (domain && domain !== "feishu" && domain.startsWith("http")) {
return `${domain.replace(/\/+$/, "")}/open-apis`;
}
return "https://open.feishu.cn/open-apis";
}
async function getToken(creds: Credentials): Promise<string> {
const key = `${creds.domain ?? "feishu"}|${creds.appId}`;
const cached = tokenCache.get(key);
if (cached && cached.expiresAt > Date.now() + 60000) {
return cached.token;
}
const res = await fetch(`${resolveApiBase(creds.domain)}/auth/v3/tenant_access_token/internal`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ app_id: creds.appId, app_secret: creds.appSecret }),
});
const data = (await res.json()) as {
code: number;
msg: string;
tenant_access_token?: string;
expire?: number;
};
if (data.code !== 0 || !data.tenant_access_token) {
throw new Error(`Token error: ${data.msg}`);
}
tokenCache.set(key, {
token: data.tenant_access_token,
expiresAt: Date.now() + (data.expire ?? 7200) * 1000,
});
return data.tenant_access_token;
}
function truncateSummary(text: string, max = 50): string {
if (!text) {
return "";
}
const clean = text.replace(/\n/g, " ").trim();
return clean.length <= max ? clean : clean.slice(0, max - 3) + "...";
}
/** Streaming card session manager */
export class FeishuStreamingSession {
private client: Client;
private creds: Credentials;
private state: CardState | null = null;
private queue: Promise<void> = Promise.resolve();
private closed = false;
private log?: (msg: string) => void;
private lastUpdateTime = 0;
private pendingText: string | null = null;
private updateThrottleMs = 100; // Throttle updates to max 10/sec
constructor(client: Client, creds: Credentials, log?: (msg: string) => void) {
this.client = client;
this.creds = creds;
this.log = log;
}
async start(
receiveId: string,
receiveIdType: "open_id" | "user_id" | "union_id" | "email" | "chat_id" = "chat_id",
): Promise<void> {
if (this.state) {
return;
}
const apiBase = resolveApiBase(this.creds.domain);
const cardJson = {
schema: "2.0",
config: {
streaming_mode: true,
summary: { content: "[Generating...]" },
streaming_config: { print_frequency_ms: { default: 50 }, print_step: { default: 2 } },
},
body: {
elements: [{ tag: "markdown", content: "⏳ Thinking...", element_id: "content" }],
},
};
// Create card entity
const createRes = await fetch(`${apiBase}/cardkit/v1/cards`, {
method: "POST",
headers: {
Authorization: `Bearer ${await getToken(this.creds)}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ type: "card_json", data: JSON.stringify(cardJson) }),
});
const createData = (await createRes.json()) as {
code: number;
msg: string;
data?: { card_id: string };
};
if (createData.code !== 0 || !createData.data?.card_id) {
throw new Error(`Create card failed: ${createData.msg}`);
}
const cardId = createData.data.card_id;
// Send card message
const sendRes = await this.client.im.message.create({
params: { receive_id_type: receiveIdType },
data: {
receive_id: receiveId,
msg_type: "interactive",
content: JSON.stringify({ type: "card", data: { card_id: cardId } }),
},
});
if (sendRes.code !== 0 || !sendRes.data?.message_id) {
throw new Error(`Send card failed: ${sendRes.msg}`);
}
this.state = { cardId, messageId: sendRes.data.message_id, sequence: 1, currentText: "" };
this.log?.(`Started streaming: cardId=${cardId}, messageId=${sendRes.data.message_id}`);
}
async update(text: string): Promise<void> {
if (!this.state || this.closed) {
return;
}
// Throttle: skip if updated recently, but remember pending text
const now = Date.now();
if (now - this.lastUpdateTime < this.updateThrottleMs) {
this.pendingText = text;
return;
}
this.pendingText = null;
this.lastUpdateTime = now;
this.queue = this.queue.then(async () => {
if (!this.state || this.closed) {
return;
}
this.state.currentText = text;
this.state.sequence += 1;
const apiBase = resolveApiBase(this.creds.domain);
await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, {
method: "PUT",
headers: {
Authorization: `Bearer ${await getToken(this.creds)}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
content: text,
sequence: this.state.sequence,
uuid: `s_${this.state.cardId}_${this.state.sequence}`,
}),
}).catch((e) => this.log?.(`Update failed: ${String(e)}`));
});
await this.queue;
}
async close(finalText?: string): Promise<void> {
if (!this.state || this.closed) {
return;
}
this.closed = true;
await this.queue;
// Use finalText, or pending throttled text, or current text
const text = finalText ?? this.pendingText ?? this.state.currentText;
const apiBase = resolveApiBase(this.creds.domain);
// Only send final update if content differs from what's already displayed
if (text && text !== this.state.currentText) {
this.state.sequence += 1;
await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, {
method: "PUT",
headers: {
Authorization: `Bearer ${await getToken(this.creds)}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
content: text,
sequence: this.state.sequence,
uuid: `s_${this.state.cardId}_${this.state.sequence}`,
}),
}).catch(() => {});
this.state.currentText = text;
}
// Close streaming mode
this.state.sequence += 1;
await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/settings`, {
method: "PATCH",
headers: {
Authorization: `Bearer ${await getToken(this.creds)}`,
"Content-Type": "application/json; charset=utf-8",
},
body: JSON.stringify({
settings: JSON.stringify({
config: { streaming_mode: false, summary: { content: truncateSummary(text) } },
}),
sequence: this.state.sequence,
uuid: `c_${this.state.cardId}_${this.state.sequence}`,
}),
}).catch((e) => this.log?.(`Close failed: ${String(e)}`));
this.log?.(`Closed streaming: cardId=${this.state.cardId}`);
}
isActive(): boolean {
return this.state !== null && !this.closed;
}
}

View File

@@ -1,16 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveReceiveIdType } from "./targets.js";
describe("resolveReceiveIdType", () => {
it("resolves chat IDs by oc_ prefix", () => {
expect(resolveReceiveIdType("oc_123")).toBe("chat_id");
});
it("resolves open IDs by ou_ prefix", () => {
expect(resolveReceiveIdType("ou_123")).toBe("open_id");
});
it("defaults unprefixed IDs to user_id", () => {
expect(resolveReceiveIdType("u_123")).toBe("user_id");
});
});

View File

@@ -57,7 +57,7 @@ export function resolveReceiveIdType(id: string): "chat_id" | "open_id" | "user_
if (trimmed.startsWith(OPEN_ID_PREFIX)) {
return "open_id";
}
return "user_id";
return "open_id";
}
export function looksLikeFeishuId(raw: string): boolean {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/google-antigravity-auth",
"version": "2026.2.12",
"version": "2026.2.10",
"private": true,
"description": "OpenClaw Google Antigravity OAuth provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/google-gemini-cli-auth",
"version": "2026.2.12",
"version": "2026.2.10",
"private": true,
"description": "OpenClaw Gemini CLI OAuth provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/googlechat",
"version": "2026.2.12",
"version": "2026.2.10",
"private": true,
"description": "OpenClaw Google Chat channel plugin",
"type": "module",

View File

@@ -375,23 +375,40 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
chunker: (text, limit) => getGoogleChatRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
resolveTarget: ({ to }) => {
resolveTarget: ({ to, allowFrom, mode }) => {
const trimmed = to?.trim() ?? "";
const allowListRaw = (allowFrom ?? []).map((entry) => String(entry).trim()).filter(Boolean);
const allowList = allowListRaw
.filter((entry) => entry !== "*")
.map((entry) => normalizeGoogleChatTarget(entry))
.filter((entry): entry is string => Boolean(entry));
if (trimmed) {
const normalized = normalizeGoogleChatTarget(trimmed);
if (!normalized) {
if ((mode === "implicit" || mode === "heartbeat") && allowList.length > 0) {
return { ok: true, to: allowList[0] };
}
return {
ok: false,
error: missingTargetError("Google Chat", "<spaces/{space}|users/{user}>"),
error: missingTargetError(
"Google Chat",
"<spaces/{space}|users/{user}> or channels.googlechat.dm.allowFrom[0]",
),
};
}
return { ok: true, to: normalized };
}
if (allowList.length > 0) {
return { ok: true, to: allowList[0] };
}
return {
ok: false,
error: missingTargetError("Google Chat", "<spaces/{space}|users/{user}>"),
error: missingTargetError(
"Google Chat",
"<spaces/{space}|users/{user}> or channels.googlechat.dm.allowFrom[0]",
),
};
},
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => {

View File

@@ -1,138 +0,0 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("openclaw/plugin-sdk", () => ({
getChatChannelMeta: () => ({ id: "googlechat", label: "Google Chat" }),
missingTargetError: (provider: string, hint: string) =>
new Error(`Delivering to ${provider} requires target ${hint}`),
GoogleChatConfigSchema: {},
DEFAULT_ACCOUNT_ID: "default",
PAIRING_APPROVED_MESSAGE: "Approved",
applyAccountNameToChannelSection: vi.fn(),
buildChannelConfigSchema: vi.fn(),
deleteAccountFromConfigSection: vi.fn(),
formatPairingApproveHint: vi.fn(),
migrateBaseNameToDefaultAccount: vi.fn(),
normalizeAccountId: vi.fn(),
resolveChannelMediaMaxBytes: vi.fn(),
resolveGoogleChatGroupRequireMention: vi.fn(),
setAccountEnabledInConfigSection: vi.fn(),
}));
vi.mock("./accounts.js", () => ({
listGoogleChatAccountIds: vi.fn(),
resolveDefaultGoogleChatAccountId: vi.fn(),
resolveGoogleChatAccount: vi.fn(),
}));
vi.mock("./actions.js", () => ({
googlechatMessageActions: [],
}));
vi.mock("./api.js", () => ({
sendGoogleChatMessage: vi.fn(),
uploadGoogleChatAttachment: vi.fn(),
probeGoogleChat: vi.fn(),
}));
vi.mock("./monitor.js", () => ({
resolveGoogleChatWebhookPath: vi.fn(),
startGoogleChatMonitor: vi.fn(),
}));
vi.mock("./onboarding.js", () => ({
googlechatOnboardingAdapter: {},
}));
vi.mock("./runtime.js", () => ({
getGoogleChatRuntime: vi.fn(() => ({
channel: {
text: { chunkMarkdownText: vi.fn() },
},
})),
}));
vi.mock("./targets.js", () => ({
normalizeGoogleChatTarget: (raw?: string | null) => {
if (!raw?.trim()) return undefined;
if (raw === "invalid-target") return undefined;
const trimmed = raw.trim().replace(/^(googlechat|google-chat|gchat):/i, "");
if (trimmed.startsWith("spaces/")) return trimmed;
if (trimmed.includes("@")) return `users/${trimmed.toLowerCase()}`;
return `users/${trimmed}`;
},
isGoogleChatUserTarget: (value: string) => value.startsWith("users/"),
isGoogleChatSpaceTarget: (value: string) => value.startsWith("spaces/"),
resolveGoogleChatOutboundSpace: vi.fn(),
}));
import { googlechatPlugin } from "./channel.js";
const resolveTarget = googlechatPlugin.outbound!.resolveTarget!;
describe("googlechat resolveTarget", () => {
it("should resolve valid target", () => {
const result = resolveTarget({
to: "spaces/AAA",
mode: "explicit",
allowFrom: [],
});
expect(result.ok).toBe(true);
expect(result.to).toBe("spaces/AAA");
});
it("should resolve email target", () => {
const result = resolveTarget({
to: "user@example.com",
mode: "explicit",
allowFrom: [],
});
expect(result.ok).toBe(true);
expect(result.to).toBe("users/user@example.com");
});
it("should error on normalization failure with allowlist (implicit mode)", () => {
const result = resolveTarget({
to: "invalid-target",
mode: "implicit",
allowFrom: ["spaces/BBB"],
});
expect(result.ok).toBe(false);
expect(result.error).toBeDefined();
});
it("should error when no target provided with allowlist", () => {
const result = resolveTarget({
to: undefined,
mode: "implicit",
allowFrom: ["spaces/BBB"],
});
expect(result.ok).toBe(false);
expect(result.error).toBeDefined();
});
it("should error when no target and no allowlist", () => {
const result = resolveTarget({
to: undefined,
mode: "explicit",
allowFrom: [],
});
expect(result.ok).toBe(false);
expect(result.error).toBeDefined();
});
it("should handle whitespace-only target", () => {
const result = resolveTarget({
to: " ",
mode: "explicit",
allowFrom: [],
});
expect(result.ok).toBe(false);
expect(result.error).toBeDefined();
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/imessage",
"version": "2026.2.12",
"version": "2026.2.10",
"private": true,
"description": "OpenClaw iMessage channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/irc",
"version": "2026.2.12",
"version": "2026.2.10",
"description": "OpenClaw IRC channel plugin",
"type": "module",
"devDependencies": {

View File

@@ -399,7 +399,7 @@ export async function connectIrcClient(options: IrcClientOptions): Promise<IrcCl
}
});
socket.once("error", (err: unknown) => {
socket.once("error", (err) => {
fail(err);
});

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/line",
"version": "2026.2.12",
"version": "2026.2.10",
"private": true,
"description": "OpenClaw LINE channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/llm-task",
"version": "2026.2.12",
"version": "2026.2.10",
"private": true,
"description": "OpenClaw JSON-only LLM task plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/lobster",
"version": "2026.2.12",
"version": "2026.2.10",
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
"type": "module",
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/matrix",
"version": "2026.2.12",
"version": "2026.2.10",
"description": "OpenClaw Matrix channel plugin",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/mattermost",
"version": "2026.2.12",
"version": "2026.2.10",
"private": true,
"description": "OpenClaw Mattermost channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/memory-core",
"version": "2026.2.12",
"version": "2026.2.10",
"private": true,
"description": "OpenClaw core memory search plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/memory-lancedb",
"version": "2026.2.12",
"version": "2026.2.10",
"private": true,
"description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture",
"type": "module",

View File

@@ -8,7 +8,7 @@ import { loginMiniMaxPortalOAuth, type MiniMaxRegion } from "./oauth.js";
const PROVIDER_ID = "minimax-portal";
const PROVIDER_LABEL = "MiniMax";
const DEFAULT_MODEL = "MiniMax-M2.5";
const DEFAULT_MODEL = "MiniMax-M2.1";
const DEFAULT_BASE_URL_CN = "https://api.minimaxi.com/anthropic";
const DEFAULT_BASE_URL_GLOBAL = "https://api.minimax.io/anthropic";
const DEFAULT_CONTEXT_WINDOW = 200000;
@@ -27,12 +27,11 @@ function buildModelDefinition(params: {
id: string;
name: string;
input: Array<"text" | "image">;
reasoning?: boolean;
}) {
return {
id: params.id,
name: params.name,
reasoning: params.reasoning ?? false,
reasoning: false,
input: params.input,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: DEFAULT_CONTEXT_WINDOW,
@@ -90,10 +89,9 @@ function createOAuthHandler(region: MiniMaxRegion) {
input: ["text"],
}),
buildModelDefinition({
id: "MiniMax-M2.5",
name: "MiniMax M2.5",
id: "MiniMax-M2.1-lightning",
name: "MiniMax M2.1 Lightning",
input: ["text"],
reasoning: true,
}),
],
},
@@ -103,7 +101,7 @@ function createOAuthHandler(region: MiniMaxRegion) {
defaults: {
models: {
[modelRef("MiniMax-M2.1")]: { alias: "minimax-m2.1" },
[modelRef("MiniMax-M2.5")]: { alias: "minimax-m2.5" },
[modelRef("MiniMax-M2.1-lightning")]: { alias: "minimax-m2.1-lightning" },
},
},
},

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/minimax-portal-auth",
"version": "2026.2.12",
"version": "2026.2.10",
"private": true,
"description": "OpenClaw MiniMax Portal OAuth provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/msteams",
"version": "2026.2.12",
"version": "2026.2.10",
"description": "OpenClaw Microsoft Teams channel plugin",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/nextcloud-talk",
"version": "2026.2.12",
"version": "2026.2.10",
"description": "OpenClaw Nextcloud Talk channel plugin",
"type": "module",
"devDependencies": {

View File

@@ -1,10 +1,10 @@
{
"name": "@openclaw/nostr",
"version": "2026.2.12",
"version": "2026.2.10",
"description": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs",
"type": "module",
"dependencies": {
"nostr-tools": "^2.23.1",
"nostr-tools": "^2.23.0",
"zod": "^4.3.6"
},
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/open-prose",
"version": "2026.2.12",
"version": "2026.2.10",
"private": true,
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/signal",
"version": "2026.2.12",
"version": "2026.2.10",
"private": true,
"description": "OpenClaw Signal channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/slack",
"version": "2026.2.12",
"version": "2026.2.10",
"private": true,
"description": "OpenClaw Slack channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/telegram",
"version": "2026.2.12",
"version": "2026.2.10",
"private": true,
"description": "OpenClaw Telegram channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/tlon",
"version": "2026.2.12",
"version": "2026.2.10",
"private": true,
"description": "OpenClaw Tlon/Urbit channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/twitch",
"version": "2026.2.12",
"version": "2026.2.10",
"private": true,
"description": "OpenClaw Twitch channel plugin",
"type": "module",

View File

@@ -108,15 +108,15 @@ describe("outbound", () => {
expect(result.to).toBe("allowed");
});
it("should error when target not in allowlist (implicit mode)", () => {
it("should fallback to first allowlist entry when target not in list", () => {
const result = twitchOutbound.resolveTarget({
to: "#notallowed",
mode: "implicit",
allowFrom: ["#primary", "#secondary"],
});
expect(result.ok).toBe(false);
expect(result.error).toContain("Twitch");
expect(result.ok).toBe(true);
expect(result.to).toBe("primary");
});
it("should accept any target when allowlist is empty", () => {
@@ -130,15 +130,15 @@ describe("outbound", () => {
expect(result.to).toBe("anychannel");
});
it("should error when no target provided with allowlist", () => {
it("should use first allowlist entry when no target provided", () => {
const result = twitchOutbound.resolveTarget({
to: undefined,
mode: "implicit",
allowFrom: ["#fallback", "#other"],
});
expect(result.ok).toBe(false);
expect(result.error).toContain("Twitch");
expect(result.ok).toBe(true);
expect(result.to).toBe("fallback");
});
it("should return error when no target and no allowlist", () => {
@@ -163,17 +163,6 @@ describe("outbound", () => {
expect(result.error).toContain("Missing target");
});
it("should error when target normalizes to empty string", () => {
const result = twitchOutbound.resolveTarget({
to: "#",
mode: "explicit",
allowFrom: [],
});
expect(result.ok).toBe(false);
expect(result.error).toContain("Twitch");
});
it("should filter wildcard from allowlist when checking membership", () => {
const result = twitchOutbound.resolveTarget({
to: "#mychannel",

View File

@@ -54,12 +54,6 @@ export const twitchOutbound: ChannelOutboundAdapter = {
// If target is provided, normalize and validate it
if (trimmed) {
const normalizedTo = normalizeTwitchChannel(trimmed);
if (!normalizedTo) {
return {
ok: false,
error: missingTargetError("Twitch", "<channel-name>"),
};
}
// For implicit/heartbeat modes with allowList, check against allowlist
if (mode === "implicit" || mode === "heartbeat") {
@@ -69,22 +63,26 @@ export const twitchOutbound: ChannelOutboundAdapter = {
if (allowList.includes(normalizedTo)) {
return { ok: true, to: normalizedTo };
}
return {
ok: false,
error: missingTargetError("Twitch", "<channel-name>"),
};
// Fallback to first allowFrom entry
return { ok: true, to: allowList[0] };
}
// For explicit mode, accept any valid channel name
return { ok: true, to: normalizedTo };
}
// No target provided - error
// No target provided, use allowFrom fallback
if (allowList.length > 0) {
return { ok: true, to: allowList[0] };
}
// No target and no allowFrom - error
return {
ok: false,
error: missingTargetError("Twitch", "<channel-name>"),
error: missingTargetError(
"Twitch",
"<channel-name> or channels.twitch.accounts.<account>.allowFrom[0]",
),
};
},

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/voice-call",
"version": "2026.2.12",
"version": "2026.2.10",
"description": "OpenClaw voice-call plugin",
"type": "module",
"dependencies": {

View File

@@ -146,11 +146,6 @@ export class MediaStreamHandler {
const streamSid = message.streamSid || "";
const callSid = message.start?.callSid || "";
// Prefer token from start message customParameters (set via TwiML <Parameter>),
// falling back to query string token. Twilio strips query params from WebSocket
// URLs but reliably delivers <Parameter> values in customParameters.
const effectiveToken = message.start?.customParameters?.token ?? streamToken;
console.log(`[MediaStream] Stream started: ${streamSid} (call: ${callSid})`);
if (!callSid) {
console.warn("[MediaStream] Missing callSid; closing stream");
@@ -159,7 +154,7 @@ export class MediaStreamHandler {
}
if (
this.config.shouldAcceptStream &&
!this.config.shouldAcceptStream({ callId: callSid, streamSid, token: effectiveToken })
!this.config.shouldAcceptStream({ callId: callSid, streamSid, token: streamToken })
) {
console.warn(`[MediaStream] Rejecting stream for unknown call: ${callSid}`);
ws.close(1008, "Unknown call");
@@ -398,7 +393,6 @@ interface TwilioMediaMessage {
accountSid: string;
callSid: string;
tracks: string[];
customParameters?: Record<string, string>;
mediaFormat: {
encoding: string;
sampleRate: number;

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import type { WebhookContext } from "../types.js";
import { TwilioProvider } from "./twilio.js";
const STREAM_URL = "wss://example.ngrok.app/voice/stream";
const STREAM_URL_PREFIX = "wss://example.ngrok.app/voice/stream?token=";
function createProvider(): TwilioProvider {
return new TwilioProvider(
@@ -30,8 +30,7 @@ describe("TwilioProvider", () => {
const result = provider.parseWebhookEvent(ctx);
expect(result.providerResponseBody).toContain(STREAM_URL);
expect(result.providerResponseBody).toContain('<Parameter name="token" value="');
expect(result.providerResponseBody).toContain(STREAM_URL_PREFIX);
expect(result.providerResponseBody).toContain("<Connect>");
});
@@ -55,8 +54,7 @@ describe("TwilioProvider", () => {
const result = provider.parseWebhookEvent(ctx);
expect(result.providerResponseBody).toContain(STREAM_URL);
expect(result.providerResponseBody).toContain('<Parameter name="token" value="');
expect(result.providerResponseBody).toContain(STREAM_URL_PREFIX);
expect(result.providerResponseBody).toContain("<Connect>");
});
});

View File

@@ -429,21 +429,10 @@ export class TwilioProvider implements VoiceCallProvider {
* @param streamUrl - WebSocket URL (wss://...) for the media stream
*/
getStreamConnectXml(streamUrl: string): string {
// Extract token from URL and pass via <Parameter> instead of query string.
// Twilio strips query params from WebSocket URLs, but delivers <Parameter>
// values in the "start" message's customParameters field.
const parsed = new URL(streamUrl);
const token = parsed.searchParams.get("token");
parsed.searchParams.delete("token");
const cleanUrl = parsed.toString();
const paramXml = token ? `\n <Parameter name="token" value="${escapeXml(token)}" />` : "";
return `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Connect>
<Stream url="${escapeXml(cleanUrl)}">${paramXml}
</Stream>
<Stream url="${escapeXml(streamUrl)}" />
</Connect>
</Response>`;
}

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/whatsapp",
"version": "2026.2.12",
"version": "2026.2.10",
"private": true,
"description": "OpenClaw WhatsApp channel plugin",
"type": "module",

View File

@@ -301,9 +301,15 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
if (trimmed) {
const normalizedTo = normalizeWhatsAppTarget(trimmed);
if (!normalizedTo) {
if ((mode === "implicit" || mode === "heartbeat") && allowList.length > 0) {
return { ok: true, to: allowList[0] };
}
return {
ok: false,
error: missingTargetError("WhatsApp", "<E.164|group JID>"),
error: missingTargetError(
"WhatsApp",
"<E.164|group JID> or channels.whatsapp.allowFrom[0]",
),
};
}
if (isWhatsAppGroupJid(normalizedTo)) {
@@ -316,16 +322,20 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
if (allowList.includes(normalizedTo)) {
return { ok: true, to: normalizedTo };
}
return {
ok: false,
error: missingTargetError("WhatsApp", "<E.164|group JID>"),
};
return { ok: true, to: allowList[0] };
}
return { ok: true, to: normalizedTo };
}
if (allowList.length > 0) {
return { ok: true, to: allowList[0] };
}
return {
ok: false,
error: missingTargetError("WhatsApp", "<E.164|group JID>"),
error: missingTargetError(
"WhatsApp",
"<E.164|group JID> or channels.whatsapp.allowFrom[0]",
),
};
},
sendText: async ({ to, text, accountId, deps, gifPlayback }) => {

View File

@@ -1,154 +0,0 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("openclaw/plugin-sdk", () => ({
getChatChannelMeta: () => ({ id: "whatsapp", label: "WhatsApp" }),
normalizeWhatsAppTarget: (value: string) => {
if (value === "invalid-target") return null;
// Simulate E.164 normalization: strip leading + and whatsapp: prefix
const stripped = value.replace(/^whatsapp:/i, "").replace(/^\+/, "");
return stripped.includes("@g.us") ? stripped : `${stripped}@s.whatsapp.net`;
},
isWhatsAppGroupJid: (value: string) => value.endsWith("@g.us"),
missingTargetError: (provider: string, hint: string) =>
new Error(`Delivering to ${provider} requires target ${hint}`),
WhatsAppConfigSchema: {},
whatsappOnboardingAdapter: {},
resolveWhatsAppHeartbeatRecipients: vi.fn(),
buildChannelConfigSchema: vi.fn(),
collectWhatsAppStatusIssues: vi.fn(),
createActionGate: vi.fn(),
DEFAULT_ACCOUNT_ID: "default",
escapeRegExp: vi.fn(),
formatPairingApproveHint: vi.fn(),
listWhatsAppAccountIds: vi.fn(),
listWhatsAppDirectoryGroupsFromConfig: vi.fn(),
listWhatsAppDirectoryPeersFromConfig: vi.fn(),
looksLikeWhatsAppTargetId: vi.fn(),
migrateBaseNameToDefaultAccount: vi.fn(),
normalizeAccountId: vi.fn(),
normalizeE164: vi.fn(),
normalizeWhatsAppMessagingTarget: vi.fn(),
readStringParam: vi.fn(),
resolveDefaultWhatsAppAccountId: vi.fn(),
resolveWhatsAppAccount: vi.fn(),
resolveWhatsAppGroupRequireMention: vi.fn(),
resolveWhatsAppGroupToolPolicy: vi.fn(),
applyAccountNameToChannelSection: vi.fn(),
}));
vi.mock("./runtime.js", () => ({
getWhatsAppRuntime: vi.fn(() => ({
channel: {
text: { chunkText: vi.fn() },
whatsapp: {
sendMessageWhatsApp: vi.fn(),
createLoginTool: vi.fn(),
},
},
})),
}));
import { whatsappPlugin } from "./channel.js";
const resolveTarget = whatsappPlugin.outbound!.resolveTarget!;
describe("whatsapp resolveTarget", () => {
it("should resolve valid target in explicit mode", () => {
const result = resolveTarget({
to: "5511999999999",
mode: "explicit",
allowFrom: [],
});
expect(result.ok).toBe(true);
expect(result.to).toBe("5511999999999@s.whatsapp.net");
});
it("should resolve target in implicit mode with wildcard", () => {
const result = resolveTarget({
to: "5511999999999",
mode: "implicit",
allowFrom: ["*"],
});
expect(result.ok).toBe(true);
expect(result.to).toBe("5511999999999@s.whatsapp.net");
});
it("should resolve target in implicit mode when in allowlist", () => {
const result = resolveTarget({
to: "5511999999999",
mode: "implicit",
allowFrom: ["5511999999999"],
});
expect(result.ok).toBe(true);
expect(result.to).toBe("5511999999999@s.whatsapp.net");
});
it("should allow group JID regardless of allowlist", () => {
const result = resolveTarget({
to: "120363123456789@g.us",
mode: "implicit",
allowFrom: ["5511999999999"],
});
expect(result.ok).toBe(true);
expect(result.to).toBe("120363123456789@g.us");
});
it("should error when target not in allowlist (implicit mode)", () => {
const result = resolveTarget({
to: "5511888888888",
mode: "implicit",
allowFrom: ["5511999999999", "5511777777777"],
});
expect(result.ok).toBe(false);
expect(result.error).toBeDefined();
});
it("should error on normalization failure with allowlist (implicit mode)", () => {
const result = resolveTarget({
to: "invalid-target",
mode: "implicit",
allowFrom: ["5511999999999"],
});
expect(result.ok).toBe(false);
expect(result.error).toBeDefined();
});
it("should error when no target provided with allowlist", () => {
const result = resolveTarget({
to: undefined,
mode: "implicit",
allowFrom: ["5511999999999"],
});
expect(result.ok).toBe(false);
expect(result.error).toBeDefined();
});
it("should error when no target and no allowlist", () => {
const result = resolveTarget({
to: undefined,
mode: "explicit",
allowFrom: [],
});
expect(result.ok).toBe(false);
expect(result.error).toBeDefined();
});
it("should handle whitespace-only target", () => {
const result = resolveTarget({
to: " ",
mode: "explicit",
allowFrom: [],
});
expect(result.ok).toBe(false);
expect(result.error).toBeDefined();
});
});

Some files were not shown because too many files have changed in this diff Show More