mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-11 00:11:53 +08:00
Compare commits
46 Commits
feature/pl
...
vincentkoc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db3f25ae75 | ||
|
|
7b3630e310 | ||
|
|
c71fb8cda0 | ||
|
|
db20141993 | ||
|
|
29fec8bb9f | ||
|
|
8aaafa045a | ||
|
|
ba6064cc22 | ||
|
|
f00db91590 | ||
|
|
e3b7ff2f1f | ||
|
|
df3a247db2 | ||
|
|
f4dbd78afd | ||
|
|
946c24d674 | ||
|
|
c57b750be4 | ||
|
|
4c6a7f84a4 | ||
|
|
774b40467b | ||
|
|
f4aff83c51 | ||
|
|
e5a42c0bec | ||
|
|
92fc8065e9 | ||
|
|
b5b589d99d | ||
|
|
c1a0196826 | ||
|
|
b202ac2ad1 | ||
|
|
2806f2b878 | ||
|
|
9e8df16732 | ||
|
|
3928b4872a | ||
|
|
8a607d7553 | ||
|
|
3704293e6f | ||
|
|
2f7e548a57 | ||
|
|
b1d8737017 | ||
|
|
39b4185d0b | ||
|
|
173fe3cb54 | ||
|
|
92834c8440 | ||
|
|
39377b7a20 | ||
|
|
cbec476b6b | ||
|
|
432ea11248 | ||
|
|
e81442ac80 | ||
|
|
678ea77dcf | ||
|
|
747609d7d5 | ||
|
|
b49e1386d0 | ||
|
|
bb06dc7cc9 | ||
|
|
d33f3f843a | ||
|
|
8db6fcca77 | ||
|
|
ac29edf6c3 | ||
|
|
e490f450f3 | ||
|
|
9bffa3422c | ||
|
|
c6e32835d4 | ||
|
|
d9bc1920ed |
45
.github/CODEOWNERS
vendored
45
.github/CODEOWNERS
vendored
@@ -1,6 +1,51 @@
|
||||
# Protect the ownership rules themselves.
|
||||
/.github/CODEOWNERS @steipete
|
||||
|
||||
# WARNING: GitHub CODEOWNERS uses last-match-wins semantics.
|
||||
# If you add overlapping rules below the secops block, include @openclaw/secops
|
||||
# on those entries too or you can silently remove required secops review.
|
||||
# Security-sensitive code, config, and docs require secops review.
|
||||
/SECURITY.md @openclaw/secops
|
||||
/.github/dependabot.yml @openclaw/secops
|
||||
/.github/codeql/ @openclaw/secops
|
||||
/.github/workflows/codeql.yml @openclaw/secops
|
||||
/src/security/ @openclaw/secops
|
||||
/src/secrets/ @openclaw/secops
|
||||
/src/config/*secret*.ts @openclaw/secops
|
||||
/src/config/**/*secret*.ts @openclaw/secops
|
||||
/src/gateway/*auth*.ts @openclaw/secops
|
||||
/src/gateway/**/*auth*.ts @openclaw/secops
|
||||
/src/gateway/*secret*.ts @openclaw/secops
|
||||
/src/gateway/**/*secret*.ts @openclaw/secops
|
||||
/src/gateway/security-path*.ts @openclaw/secops
|
||||
/src/gateway/resolve-configured-secret-input-string*.ts @openclaw/secops
|
||||
/src/gateway/protocol/**/*secret*.ts @openclaw/secops
|
||||
/src/gateway/server-methods/secrets*.ts @openclaw/secops
|
||||
/src/agents/*auth*.ts @openclaw/secops
|
||||
/src/agents/**/*auth*.ts @openclaw/secops
|
||||
/src/agents/auth-profiles*.ts @openclaw/secops
|
||||
/src/agents/auth-health*.ts @openclaw/secops
|
||||
/src/agents/auth-profiles/ @openclaw/secops
|
||||
/src/agents/sandbox.ts @openclaw/secops
|
||||
/src/agents/sandbox-*.ts @openclaw/secops
|
||||
/src/agents/sandbox/ @openclaw/secops
|
||||
/src/infra/secret-file*.ts @openclaw/secops
|
||||
/src/cron/stagger.ts @openclaw/secops
|
||||
/src/cron/service/jobs.ts @openclaw/secops
|
||||
/docs/security/ @openclaw/secops
|
||||
/docs/gateway/authentication.md @openclaw/secops
|
||||
/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md @openclaw/secops
|
||||
/docs/gateway/sandboxing.md @openclaw/secops
|
||||
/docs/gateway/secrets-plan-contract.md @openclaw/secops
|
||||
/docs/gateway/secrets.md @openclaw/secops
|
||||
/docs/gateway/security/ @openclaw/secops
|
||||
/docs/cli/approvals.md @openclaw/secops
|
||||
/docs/cli/sandbox.md @openclaw/secops
|
||||
/docs/cli/security.md @openclaw/secops
|
||||
/docs/cli/secrets.md @openclaw/secops
|
||||
/docs/reference/secretref-credential-surface.md @openclaw/secops
|
||||
/docs/reference/secretref-user-supplied-credentials-matrix.json @openclaw/secops
|
||||
|
||||
# Release workflow and its supporting release-path checks.
|
||||
/.github/workflows/openclaw-npm-release.yml @openclaw/openclaw-release-managers
|
||||
/docs/reference/RELEASING.md @openclaw/openclaw-release-managers
|
||||
|
||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@@ -159,6 +159,9 @@ jobs:
|
||||
- runtime: node
|
||||
task: extensions
|
||||
command: pnpm test:extensions
|
||||
- runtime: node
|
||||
task: channels
|
||||
command: pnpm test:channels
|
||||
- runtime: node
|
||||
task: protocol
|
||||
command: pnpm protocol:check
|
||||
|
||||
4
.github/workflows/docker-release.yml
vendored
4
.github/workflows/docker-release.yml
vendored
@@ -59,7 +59,9 @@ jobs:
|
||||
environment: docker-release
|
||||
steps:
|
||||
- name: Approve Docker backfill
|
||||
run: echo "Approved Docker backfill for ${{ inputs.tag }}"
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: echo "Approved Docker backfill for $RELEASE_TAG"
|
||||
|
||||
# KEEP THIS WORKFLOW ON GITHUB-HOSTED RUNNERS.
|
||||
# DO NOT MOVE IT BACK TO BLACKSMITH WITHOUT RE-VALIDATING TAG BUILDS AND BACKFILLS.
|
||||
|
||||
30
.github/workflows/openclaw-npm-release.yml
vendored
30
.github/workflows/openclaw-npm-release.yml
vendored
@@ -7,7 +7,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Release tag to publish (for example v2026.3.14 or v2026.3.14-beta.1)
|
||||
description: Release tag to publish (for example v2026.3.14, v2026.3.14-beta.1, or fallback v2026.3.14-1)
|
||||
required: true
|
||||
type: string
|
||||
|
||||
@@ -47,9 +47,18 @@ jobs:
|
||||
set -euo pipefail
|
||||
RELEASE_SHA=$(git rev-parse HEAD)
|
||||
PACKAGE_VERSION=$(node -p "require('./package.json').version")
|
||||
if [[ "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*-[1-9][0-9]*$ ]]; then
|
||||
TAG_KIND="fallback correction"
|
||||
else
|
||||
TAG_KIND="standard"
|
||||
fi
|
||||
echo "Release plan for ${RELEASE_TAG}:"
|
||||
echo "Resolved release SHA: ${RELEASE_SHA}"
|
||||
echo "Resolved package version: ${PACKAGE_VERSION}"
|
||||
echo "Resolved tag kind: ${TAG_KIND}"
|
||||
if [[ "${TAG_KIND}" == "fallback correction" ]]; then
|
||||
echo "Correction tag note: npm version remains ${PACKAGE_VERSION}"
|
||||
fi
|
||||
echo "Would run: git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main"
|
||||
echo "Would run with env: RELEASE_SHA=${RELEASE_SHA} RELEASE_TAG=${RELEASE_TAG} RELEASE_MAIN_REF=origin/main pnpm release:openclaw:npm:check"
|
||||
echo "Would run: npm view openclaw@${PACKAGE_VERSION} version"
|
||||
@@ -71,16 +80,31 @@ jobs:
|
||||
pnpm release:openclaw:npm:check
|
||||
|
||||
- name: Ensure version is not already published
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
PACKAGE_VERSION=$(node -p "require('./package.json').version")
|
||||
IS_CORRECTION_TAG=0
|
||||
if [[ "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*-[1-9][0-9]*$ ]]; then
|
||||
IS_CORRECTION_TAG=1
|
||||
fi
|
||||
|
||||
if npm view "openclaw@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
|
||||
if [[ "${IS_CORRECTION_TAG}" == "1" ]]; then
|
||||
echo "openclaw@${PACKAGE_VERSION} is already published on npm."
|
||||
echo "Correction tag ${RELEASE_TAG} is allowed as a fallback release tag, so preview will continue without treating this as an error."
|
||||
exit 0
|
||||
fi
|
||||
echo "openclaw@${PACKAGE_VERSION} is already published on npm."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Previewing openclaw@${PACKAGE_VERSION}"
|
||||
if [[ "${IS_CORRECTION_TAG}" == "1" ]]; then
|
||||
echo "Previewing fallback correction tag ${RELEASE_TAG} for npm version openclaw@${PACKAGE_VERSION}"
|
||||
else
|
||||
echo "Previewing openclaw@${PACKAGE_VERSION}"
|
||||
fi
|
||||
|
||||
- name: Check
|
||||
run: |
|
||||
@@ -114,7 +138,7 @@ jobs:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-beta\.[1-9][0-9]*)?$ ]]; then
|
||||
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-beta\.[1-9][0-9]*)|(-[1-9][0-9]*))?$ ]]; then
|
||||
echo "Invalid release tag format: ${RELEASE_TAG}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
19
.github/workflows/workflow-sanity.yml
vendored
19
.github/workflows/workflow-sanity.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: workflow-sanity-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
@@ -14,6 +15,7 @@ env:
|
||||
|
||||
jobs:
|
||||
no-tabs:
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -45,6 +47,7 @@ jobs:
|
||||
PY
|
||||
|
||||
actionlint:
|
||||
if: github.event_name != 'workflow_dispatch'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -68,3 +71,19 @@ jobs:
|
||||
|
||||
- name: Disallow direct inputs interpolation in composite run blocks
|
||||
run: python3 scripts/check-composite-action-input-interpolation.py
|
||||
|
||||
config-docs-drift:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Check config docs drift statefile
|
||||
run: pnpm config:docs:check
|
||||
|
||||
@@ -12314,14 +12314,14 @@
|
||||
"filename": "src/config/schema.help.ts",
|
||||
"hashed_secret": "9f4cda226d3868676ac7f86f59e4190eb94bd208",
|
||||
"is_verified": false,
|
||||
"line_number": 653
|
||||
"line_number": 657
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/config/schema.help.ts",
|
||||
"hashed_secret": "01822c8bbf6a8b136944b14182cb885100ec2eae",
|
||||
"is_verified": false,
|
||||
"line_number": 686
|
||||
"line_number": 690
|
||||
}
|
||||
],
|
||||
"src/config/schema.irc.ts": [
|
||||
@@ -12360,14 +12360,14 @@
|
||||
"filename": "src/config/schema.labels.ts",
|
||||
"hashed_secret": "e73c9fcad85cd4eecc74181ec4bdb31064d68439",
|
||||
"is_verified": false,
|
||||
"line_number": 217
|
||||
"line_number": 219
|
||||
},
|
||||
{
|
||||
"type": "Secret Keyword",
|
||||
"filename": "src/config/schema.labels.ts",
|
||||
"hashed_secret": "2eda7cd978f39eebec3bf03e4410a40e14167fff",
|
||||
"is_verified": false,
|
||||
"line_number": 326
|
||||
"line_number": 328
|
||||
}
|
||||
],
|
||||
"src/config/slack-http-config.test.ts": [
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
- PR review conversations: if a bot leaves review conversations on your PR, address them and resolve those conversations yourself once fixed. Leave a conversation unresolved only when reviewer or maintainer judgment is still needed; do not leave bot-conversation cleanup to maintainers.
|
||||
- GitHub searching footgun: don't limit yourself to the first 500 issues or PRs when wanting to search all. Unless you're supposed to look at the most recent, keep going until you've reached the last page in the search
|
||||
- Security advisory analysis: before triage/severity decisions, read `SECURITY.md` to align with OpenClaw's trust model and design boundaries.
|
||||
- Do not edit files covered by security-focused `CODEOWNERS` rules unless a listed owner explicitly asked for the change or is already reviewing it with you. Treat those paths as restricted surfaces, not drive-by cleanup.
|
||||
|
||||
## Auto-close labels (issues and PRs)
|
||||
|
||||
|
||||
20
CHANGELOG.md
20
CHANGELOG.md
@@ -8,16 +8,31 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- Commands/btw: add `/btw` side questions for quick tool-less answers about the current session without changing future session context, with dismissible in-session TUI answers and explicit BTW replies on external channels. (#45444) Thanks @ngutman.
|
||||
- Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob.
|
||||
- Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior.
|
||||
- Feishu/cards: add identity-aware structured card headers and note footers for Feishu replies and direct sends, while keeping that presentation wired through the shared outbound identity path. (#29938) Thanks @nszhsl.
|
||||
- Gateway/health monitor: add configurable stale-event thresholds and restart limits, plus per-channel and per-account `healthMonitor.enabled` overrides, while keeping the existing global disable path on `gateway.channelHealthCheckMinutes=0`. (#42107) Thanks @rstar327.
|
||||
- Android/mobile: add a system-aware dark theme across onboarding and post-onboarding screens so the app follows the device theme through setup, chat, and voice flows. (#46249) Thanks @sibbl.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Z.AI/onboarding: detect a working default model even for explicit `zai-coding-*` endpoint choices, so Coding Plan setup can keep the selected endpoint while defaulting to `glm-5` when available or `glm-4.7` as fallback. (#45969)
|
||||
- Zalo/plugin runtime: export `resolveClientIp` from `openclaw/plugin-sdk/zalo` so installed builds no longer crash on startup when the webhook monitor loads from the packaged extension instead of the monorepo source tree. (#46549) Thanks @No898.
|
||||
- Z.AI/onboarding: add `glm-5-turbo` to the default Z.AI provider catalog so onboarding-generated configs expose the new model alongside the existing GLM defaults. (#46670) Thanks @tomsun28.
|
||||
- Control UI/chat sessions: show human-readable labels in the grouped session dropdown again, keep unique scoped fallbacks when metadata is missing, and disambiguate duplicate labels only when needed. (#45130) thanks @luzhidong.
|
||||
- Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob.
|
||||
- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146)
|
||||
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`.
|
||||
- Device pairing/setup codes: bind setup-code pairing to the intended node role and scope set so approval keeps the expected device profile. Thanks @vincentkoc.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. Thanks @vincentkoc.
|
||||
- CI/channel test routing: move the built-in channel suites into `test:channels` and keep them out of `test:extensions`, so extension CI no longer fails after the channel migration while targeted test routing still sends Slack, Signal, and iMessage suites to the right lane. (#46066) Thanks @scoootscooob.
|
||||
- Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46411)
|
||||
- Control UI/dashboard: preserve structured gateway shutdown reasons across restart disconnects so config-triggered restarts no longer fall back to `disconnected (1006): no reason`. (#46532) Thanks @vincentkoc.
|
||||
- Feishu/topic threads: fetch full thread context, including prior bot replies, when starting a topic-thread session so follow-up turns in Feishu topics keep the right conversation state. Thanks @Coobiw.
|
||||
- Browser/profiles: drop the auto-created `chrome-relay` browser profile; users who need the Chrome extension relay must now create their own profile via `openclaw browser create-profile`. (#45777) Thanks @odysseus0.
|
||||
- Docs/Mintlify: fix MDX marker syntax on Perplexity, Model Providers, Moonshot, and exec approvals pages so local docs preview no longer breaks rendering or leaves stale pages unpublished. (#46695) Thanks @velvet-shark.
|
||||
|
||||
## 2026.3.13
|
||||
|
||||
@@ -91,6 +106,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Telegram/media errors: redact Telegram file URLs before building media fetch errors so failed inbound downloads do not leak bot tokens into logs. Thanks @space08.
|
||||
- Agents/failover: normalize abort-wrapped `429 RESOURCE_EXHAUSTED` provider failures before abort short-circuiting so wrapped Google/Vertex rate limits continue across configured fallback models, including the embedded runner prompt-error path. (#39820) Thanks @lupuletic.
|
||||
- Mattermost/thread routing: non-inbound reply paths (TUI/WebUI turns, tool-call callbacks, subagent responses) now correctly route to the originating Mattermost thread when `replyToMode: "all"` is active; also prevents stale `origin.threadId` metadata from resurrecting cleared thread routes. (#44283) thanks @teconomix
|
||||
- Gateway/websocket pairing bypass for disabled auth: skip device-pairing enforcement when `gateway.auth.mode=none` so Control UI connections behind reverse proxies no longer get stuck on `pairing required` (code 1008) despite auth being explicitly disabled. (#42931)
|
||||
- Auth/login lockout recovery: clear stale `auth_permanent` and `billing` disabled state for all profiles matching the target provider when `openclaw models auth login` is invoked, so users locked out by expired or revoked OAuth tokens can recover by re-authenticating instead of waiting for the cooldown timer to expire. (#43057)
|
||||
- Auto-reply/context-engine compaction: persist the exact embedded-run metadata compaction count for main and followup runner session accounting, so metadata-only auto-compactions no longer undercount multi-compaction runs. (#42629) thanks @uf-hy.
|
||||
- Auth/Codex CLI reuse: sync reused Codex CLI credentials into the supported `openai-codex:default` OAuth profile instead of reviving the deprecated `openai-codex:codex-cli` slot, so doctor cleanup no longer loops. (#45353) thanks @Gugu-sugar.
|
||||
|
||||
## 2026.3.12
|
||||
|
||||
@@ -323,6 +342,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/failover: classify HTTP 422 malformed-request responses as `format` and recognize OpenRouter "requires more credits" billing errors so provider fallback triggers instead of surfacing raw errors. (#43823) thanks @jnMetaCode.
|
||||
- Memory/QMD Windows: fail closed when `qmd.cmd` or `mcporter.cmd` wrappers cannot be resolved to a direct entrypoint, so memory search no longer falls back to shell execution on Windows.
|
||||
- macOS/remote gateway: stop PortGuardian from killing Docker Desktop and other external listeners on the gateway port in remote mode, so containerized and tunneled gateway setups no longer lose their port-forward owner on app startup. (#6755) Thanks @teslamint.
|
||||
- Feishu/streaming recovery: clear stale `streamingStartPromise` when card creation fails (HTTP 400) so subsequent messages can retry streaming instead of silently dropping all future replies. Fixes #43322.
|
||||
|
||||
## 2026.3.8
|
||||
|
||||
|
||||
@@ -76,6 +76,9 @@ Welcome to the lobster tank! 🦞
|
||||
- **Tengji (George) Zhang** - Chinese model APIs, cloud, pi
|
||||
- GitHub: [@odysseus0](https://github.com/odysseus0) · X: [@odysseus0z](https://x.com/odysseus0z)
|
||||
|
||||
- **Andrew (Bubbles) Demczuk** - Agents/Gateway/TTS/VTT
|
||||
- GitHub: [@ademczuk](https://github.com/ademczuk) · X: [@ademczuk](https://x.com/ademczuk)
|
||||
|
||||
## How to Contribute
|
||||
|
||||
1. **Bugs & small fixes** → Open a PR!
|
||||
@@ -93,6 +96,7 @@ Welcome to the lobster tank! 🦞
|
||||
- Reply to or resolve bot review conversations you addressed before asking for review again
|
||||
- **Include screenshots** — one showing the problem/before, one showing the fix/after (for UI or visual changes)
|
||||
- Use American English spelling and grammar in code, comments, docs, and UI strings
|
||||
- Do not edit files covered by `CODEOWNERS` security ownership unless a listed owner explicitly asked for the change or is already reviewing it with you. Treat those paths as restricted review surfaces, not opportunistic cleanup targets.
|
||||
|
||||
## Review Conversations Are Author-Owned
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,shar
|
||||
apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get upgrade -y --no-install-recommends && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
procps hostname curl git openssl
|
||||
procps hostname curl git lsof openssl
|
||||
|
||||
RUN chown node:node /app
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.ui.mobileCardSurface
|
||||
|
||||
private enum class ConnectInputMode {
|
||||
SetupCode,
|
||||
@@ -144,7 +145,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = Color.White,
|
||||
color = mobileCardSurface,
|
||||
border = BorderStroke(1.dp, mobileBorder),
|
||||
) {
|
||||
Column {
|
||||
@@ -205,7 +206,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White,
|
||||
containerColor = mobileCardSurface,
|
||||
contentColor = mobileDanger,
|
||||
),
|
||||
border = BorderStroke(1.dp, mobileDanger.copy(alpha = 0.4f)),
|
||||
@@ -298,7 +299,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = Color.White,
|
||||
color = mobileCardSurface,
|
||||
border = BorderStroke(1.dp, mobileBorder),
|
||||
) {
|
||||
Column(
|
||||
@@ -480,7 +481,7 @@ private fun MethodChip(label: String, active: Boolean, onClick: () -> Unit) {
|
||||
containerColor = if (active) mobileAccent else mobileSurface,
|
||||
contentColor = if (active) Color.White else mobileText,
|
||||
),
|
||||
border = BorderStroke(1.dp, if (active) Color(0xFF184DAF) else mobileBorderStrong),
|
||||
border = BorderStroke(1.dp, if (active) mobileAccentBorderStrong else mobileBorderStrong),
|
||||
) {
|
||||
Text(label, style = mobileCaption1.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
@@ -509,10 +510,10 @@ private fun CommandBlock(command: String) {
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = mobileCodeBg,
|
||||
border = BorderStroke(1.dp, Color(0xFF2B2E35)),
|
||||
border = BorderStroke(1.dp, mobileCodeBorder),
|
||||
) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(modifier = Modifier.width(3.dp).height(42.dp).background(Color(0xFF3FC97A)))
|
||||
Box(modifier = Modifier.width(3.dp).height(42.dp).background(mobileCodeAccent))
|
||||
Text(
|
||||
text = command,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
@@ -9,32 +11,147 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import ai.openclaw.app.R
|
||||
|
||||
internal val mobileBackgroundGradient =
|
||||
Brush.verticalGradient(
|
||||
listOf(
|
||||
Color(0xFFFFFFFF),
|
||||
Color(0xFFF7F8FA),
|
||||
Color(0xFFEFF1F5),
|
||||
),
|
||||
// ---------------------------------------------------------------------------
|
||||
// MobileColors – semantic color tokens with light + dark variants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
internal data class MobileColors(
|
||||
val surface: Color,
|
||||
val surfaceStrong: Color,
|
||||
val cardSurface: Color,
|
||||
val border: Color,
|
||||
val borderStrong: Color,
|
||||
val text: Color,
|
||||
val textSecondary: Color,
|
||||
val textTertiary: Color,
|
||||
val accent: Color,
|
||||
val accentSoft: Color,
|
||||
val accentBorderStrong: Color,
|
||||
val success: Color,
|
||||
val successSoft: Color,
|
||||
val warning: Color,
|
||||
val warningSoft: Color,
|
||||
val danger: Color,
|
||||
val dangerSoft: Color,
|
||||
val codeBg: Color,
|
||||
val codeText: Color,
|
||||
val codeBorder: Color,
|
||||
val codeAccent: Color,
|
||||
val chipBorderConnected: Color,
|
||||
val chipBorderConnecting: Color,
|
||||
val chipBorderWarning: Color,
|
||||
val chipBorderError: Color,
|
||||
)
|
||||
|
||||
internal fun lightMobileColors() =
|
||||
MobileColors(
|
||||
surface = Color(0xFFF6F7FA),
|
||||
surfaceStrong = Color(0xFFECEEF3),
|
||||
cardSurface = Color(0xFFFFFFFF),
|
||||
border = Color(0xFFE5E7EC),
|
||||
borderStrong = Color(0xFFD6DAE2),
|
||||
text = Color(0xFF17181C),
|
||||
textSecondary = Color(0xFF5D6472),
|
||||
textTertiary = Color(0xFF99A0AE),
|
||||
accent = Color(0xFF1D5DD8),
|
||||
accentSoft = Color(0xFFECF3FF),
|
||||
accentBorderStrong = Color(0xFF184DAF),
|
||||
success = Color(0xFF2F8C5A),
|
||||
successSoft = Color(0xFFEEF9F3),
|
||||
warning = Color(0xFFC8841A),
|
||||
warningSoft = Color(0xFFFFF8EC),
|
||||
danger = Color(0xFFD04B4B),
|
||||
dangerSoft = Color(0xFFFFF2F2),
|
||||
codeBg = Color(0xFF15171B),
|
||||
codeText = Color(0xFFE8EAEE),
|
||||
codeBorder = Color(0xFF2B2E35),
|
||||
codeAccent = Color(0xFF3FC97A),
|
||||
chipBorderConnected = Color(0xFFCFEBD8),
|
||||
chipBorderConnecting = Color(0xFFD5E2FA),
|
||||
chipBorderWarning = Color(0xFFEED8B8),
|
||||
chipBorderError = Color(0xFFF3C8C8),
|
||||
)
|
||||
|
||||
internal val mobileSurface = Color(0xFFF6F7FA)
|
||||
internal val mobileSurfaceStrong = Color(0xFFECEEF3)
|
||||
internal val mobileBorder = Color(0xFFE5E7EC)
|
||||
internal val mobileBorderStrong = Color(0xFFD6DAE2)
|
||||
internal val mobileText = Color(0xFF17181C)
|
||||
internal val mobileTextSecondary = Color(0xFF5D6472)
|
||||
internal val mobileTextTertiary = Color(0xFF99A0AE)
|
||||
internal val mobileAccent = Color(0xFF1D5DD8)
|
||||
internal val mobileAccentSoft = Color(0xFFECF3FF)
|
||||
internal val mobileSuccess = Color(0xFF2F8C5A)
|
||||
internal val mobileSuccessSoft = Color(0xFFEEF9F3)
|
||||
internal val mobileWarning = Color(0xFFC8841A)
|
||||
internal val mobileWarningSoft = Color(0xFFFFF8EC)
|
||||
internal val mobileDanger = Color(0xFFD04B4B)
|
||||
internal val mobileDangerSoft = Color(0xFFFFF2F2)
|
||||
internal val mobileCodeBg = Color(0xFF15171B)
|
||||
internal val mobileCodeText = Color(0xFFE8EAEE)
|
||||
internal fun darkMobileColors() =
|
||||
MobileColors(
|
||||
surface = Color(0xFF1A1C20),
|
||||
surfaceStrong = Color(0xFF24262B),
|
||||
cardSurface = Color(0xFF1E2024),
|
||||
border = Color(0xFF2E3038),
|
||||
borderStrong = Color(0xFF3A3D46),
|
||||
text = Color(0xFFE4E5EA),
|
||||
textSecondary = Color(0xFFA0A6B4),
|
||||
textTertiary = Color(0xFF6B7280),
|
||||
accent = Color(0xFF6EA8FF),
|
||||
accentSoft = Color(0xFF1A2A44),
|
||||
accentBorderStrong = Color(0xFF5B93E8),
|
||||
success = Color(0xFF5FBB85),
|
||||
successSoft = Color(0xFF152E22),
|
||||
warning = Color(0xFFE8A844),
|
||||
warningSoft = Color(0xFF2E2212),
|
||||
danger = Color(0xFFE87070),
|
||||
dangerSoft = Color(0xFF2E1616),
|
||||
codeBg = Color(0xFF111317),
|
||||
codeText = Color(0xFFE8EAEE),
|
||||
codeBorder = Color(0xFF2B2E35),
|
||||
codeAccent = Color(0xFF3FC97A),
|
||||
chipBorderConnected = Color(0xFF1E4A30),
|
||||
chipBorderConnecting = Color(0xFF1E3358),
|
||||
chipBorderWarning = Color(0xFF3E3018),
|
||||
chipBorderError = Color(0xFF3E1E1E),
|
||||
)
|
||||
|
||||
internal val LocalMobileColors = staticCompositionLocalOf { lightMobileColors() }
|
||||
|
||||
internal object MobileColorsAccessor {
|
||||
val current: MobileColors
|
||||
@Composable get() = LocalMobileColors.current
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backward-compatible top-level accessors (composable getters)
|
||||
// ---------------------------------------------------------------------------
|
||||
// These allow existing call sites to keep using `mobileSurface`, `mobileText`, etc.
|
||||
// without converting every file at once. Each resolves to the themed value.
|
||||
|
||||
internal val mobileSurface: Color @Composable get() = LocalMobileColors.current.surface
|
||||
internal val mobileSurfaceStrong: Color @Composable get() = LocalMobileColors.current.surfaceStrong
|
||||
internal val mobileCardSurface: Color @Composable get() = LocalMobileColors.current.cardSurface
|
||||
internal val mobileBorder: Color @Composable get() = LocalMobileColors.current.border
|
||||
internal val mobileBorderStrong: Color @Composable get() = LocalMobileColors.current.borderStrong
|
||||
internal val mobileText: Color @Composable get() = LocalMobileColors.current.text
|
||||
internal val mobileTextSecondary: Color @Composable get() = LocalMobileColors.current.textSecondary
|
||||
internal val mobileTextTertiary: Color @Composable get() = LocalMobileColors.current.textTertiary
|
||||
internal val mobileAccent: Color @Composable get() = LocalMobileColors.current.accent
|
||||
internal val mobileAccentSoft: Color @Composable get() = LocalMobileColors.current.accentSoft
|
||||
internal val mobileAccentBorderStrong: Color @Composable get() = LocalMobileColors.current.accentBorderStrong
|
||||
internal val mobileSuccess: Color @Composable get() = LocalMobileColors.current.success
|
||||
internal val mobileSuccessSoft: Color @Composable get() = LocalMobileColors.current.successSoft
|
||||
internal val mobileWarning: Color @Composable get() = LocalMobileColors.current.warning
|
||||
internal val mobileWarningSoft: Color @Composable get() = LocalMobileColors.current.warningSoft
|
||||
internal val mobileDanger: Color @Composable get() = LocalMobileColors.current.danger
|
||||
internal val mobileDangerSoft: Color @Composable get() = LocalMobileColors.current.dangerSoft
|
||||
internal val mobileCodeBg: Color @Composable get() = LocalMobileColors.current.codeBg
|
||||
internal val mobileCodeText: Color @Composable get() = LocalMobileColors.current.codeText
|
||||
internal val mobileCodeBorder: Color @Composable get() = LocalMobileColors.current.codeBorder
|
||||
internal val mobileCodeAccent: Color @Composable get() = LocalMobileColors.current.codeAccent
|
||||
|
||||
// Background gradient – light fades white→gray, dark fades near-black→dark-gray
|
||||
internal val mobileBackgroundGradient: Brush
|
||||
@Composable get() {
|
||||
val colors = LocalMobileColors.current
|
||||
return Brush.verticalGradient(
|
||||
listOf(
|
||||
colors.surface,
|
||||
colors.surfaceStrong,
|
||||
colors.surfaceStrong,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Typography tokens (theme-independent)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
internal val mobileFontFamily =
|
||||
FontFamily(
|
||||
@@ -44,6 +161,15 @@ internal val mobileFontFamily =
|
||||
Font(resId = R.font.manrope_700_bold, weight = FontWeight.Bold),
|
||||
)
|
||||
|
||||
internal val mobileDisplay =
|
||||
TextStyle(
|
||||
fontFamily = mobileFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 34.sp,
|
||||
lineHeight = 40.sp,
|
||||
letterSpacing = (-0.8).sp,
|
||||
)
|
||||
|
||||
internal val mobileTitle1 =
|
||||
TextStyle(
|
||||
fontFamily = mobileFontFamily,
|
||||
|
||||
@@ -81,7 +81,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
@@ -94,7 +93,6 @@ import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import ai.openclaw.app.LocationMode
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.R
|
||||
import ai.openclaw.app.node.DeviceNotificationListenerService
|
||||
import com.google.mlkit.vision.barcode.common.Barcode
|
||||
import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions
|
||||
@@ -129,95 +127,80 @@ private enum class SpecialAccessToggle {
|
||||
NotificationListener,
|
||||
}
|
||||
|
||||
private val onboardingBackgroundGradient =
|
||||
listOf(
|
||||
Color(0xFFFFFFFF),
|
||||
Color(0xFFF7F8FA),
|
||||
Color(0xFFEFF1F5),
|
||||
)
|
||||
private val onboardingSurface = Color(0xFFF6F7FA)
|
||||
private val onboardingBorder = Color(0xFFE5E7EC)
|
||||
private val onboardingBorderStrong = Color(0xFFD6DAE2)
|
||||
private val onboardingText = Color(0xFF17181C)
|
||||
private val onboardingTextSecondary = Color(0xFF4D5563)
|
||||
private val onboardingTextTertiary = Color(0xFF8A92A2)
|
||||
private val onboardingAccent = Color(0xFF1D5DD8)
|
||||
private val onboardingAccentSoft = Color(0xFFECF3FF)
|
||||
private val onboardingSuccess = Color(0xFF2F8C5A)
|
||||
private val onboardingWarning = Color(0xFFC8841A)
|
||||
private val onboardingCommandBg = Color(0xFF15171B)
|
||||
private val onboardingCommandBorder = Color(0xFF2B2E35)
|
||||
private val onboardingCommandAccent = Color(0xFF3FC97A)
|
||||
private val onboardingCommandText = Color(0xFFE8EAEE)
|
||||
private val onboardingBackgroundGradient: Brush
|
||||
@Composable get() = mobileBackgroundGradient
|
||||
|
||||
private val onboardingFontFamily =
|
||||
FontFamily(
|
||||
Font(resId = R.font.manrope_400_regular, weight = FontWeight.Normal),
|
||||
Font(resId = R.font.manrope_500_medium, weight = FontWeight.Medium),
|
||||
Font(resId = R.font.manrope_600_semibold, weight = FontWeight.SemiBold),
|
||||
Font(resId = R.font.manrope_700_bold, weight = FontWeight.Bold),
|
||||
)
|
||||
private val onboardingSurface: Color
|
||||
@Composable get() = mobileCardSurface
|
||||
|
||||
private val onboardingDisplayStyle =
|
||||
TextStyle(
|
||||
fontFamily = onboardingFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 34.sp,
|
||||
lineHeight = 40.sp,
|
||||
letterSpacing = (-0.8).sp,
|
||||
)
|
||||
private val onboardingBorder: Color
|
||||
@Composable get() = mobileBorder
|
||||
|
||||
private val onboardingTitle1Style =
|
||||
TextStyle(
|
||||
fontFamily = onboardingFontFamily,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 30.sp,
|
||||
letterSpacing = (-0.5).sp,
|
||||
)
|
||||
private val onboardingBorderStrong: Color
|
||||
@Composable get() = mobileBorderStrong
|
||||
|
||||
private val onboardingHeadlineStyle =
|
||||
TextStyle(
|
||||
fontFamily = onboardingFontFamily,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 22.sp,
|
||||
letterSpacing = (-0.1).sp,
|
||||
)
|
||||
private val onboardingText: Color
|
||||
@Composable get() = mobileText
|
||||
|
||||
private val onboardingBodyStyle =
|
||||
TextStyle(
|
||||
fontFamily = onboardingFontFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 22.sp,
|
||||
)
|
||||
private val onboardingTextSecondary: Color
|
||||
@Composable get() = mobileTextSecondary
|
||||
|
||||
private val onboardingCalloutStyle =
|
||||
TextStyle(
|
||||
fontFamily = onboardingFontFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
)
|
||||
private val onboardingTextTertiary: Color
|
||||
@Composable get() = mobileTextTertiary
|
||||
|
||||
private val onboardingCaption1Style =
|
||||
TextStyle(
|
||||
fontFamily = onboardingFontFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.2.sp,
|
||||
)
|
||||
private val onboardingAccent: Color
|
||||
@Composable get() = mobileAccent
|
||||
|
||||
private val onboardingCaption2Style =
|
||||
TextStyle(
|
||||
fontFamily = onboardingFontFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 14.sp,
|
||||
letterSpacing = 0.4.sp,
|
||||
)
|
||||
private val onboardingAccentSoft: Color
|
||||
@Composable get() = mobileAccentSoft
|
||||
|
||||
private val onboardingAccentBorderStrong: Color
|
||||
@Composable get() = mobileAccentBorderStrong
|
||||
|
||||
private val onboardingSuccess: Color
|
||||
@Composable get() = mobileSuccess
|
||||
|
||||
private val onboardingSuccessSoft: Color
|
||||
@Composable get() = mobileSuccessSoft
|
||||
|
||||
private val onboardingWarning: Color
|
||||
@Composable get() = mobileWarning
|
||||
|
||||
private val onboardingWarningSoft: Color
|
||||
@Composable get() = mobileWarningSoft
|
||||
|
||||
private val onboardingCommandBg: Color
|
||||
@Composable get() = mobileCodeBg
|
||||
|
||||
private val onboardingCommandBorder: Color
|
||||
@Composable get() = mobileCodeBorder
|
||||
|
||||
private val onboardingCommandAccent: Color
|
||||
@Composable get() = mobileCodeAccent
|
||||
|
||||
private val onboardingCommandText: Color
|
||||
@Composable get() = mobileCodeText
|
||||
|
||||
private val onboardingDisplayStyle: TextStyle
|
||||
get() = mobileDisplay
|
||||
|
||||
private val onboardingTitle1Style: TextStyle
|
||||
get() = mobileTitle1
|
||||
|
||||
private val onboardingHeadlineStyle: TextStyle
|
||||
get() = mobileHeadline
|
||||
|
||||
private val onboardingBodyStyle: TextStyle
|
||||
get() = mobileBody
|
||||
|
||||
private val onboardingCalloutStyle: TextStyle
|
||||
get() = mobileCallout
|
||||
|
||||
private val onboardingCaption1Style: TextStyle
|
||||
get() = mobileCaption1
|
||||
|
||||
private val onboardingCaption2Style: TextStyle
|
||||
get() = mobileCaption2
|
||||
|
||||
@Composable
|
||||
fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
@@ -495,7 +478,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.background(Brush.verticalGradient(onboardingBackgroundGradient)),
|
||||
.background(onboardingBackgroundGradient),
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
@@ -755,13 +738,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
onClick = { step = OnboardingStep.Gateway },
|
||||
modifier = Modifier.weight(1f).height(52.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = onboardingAccent,
|
||||
contentColor = Color.White,
|
||||
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
|
||||
disabledContentColor = Color.White,
|
||||
),
|
||||
colors = onboardingPrimaryButtonColors(),
|
||||
) {
|
||||
Text("Next", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
@@ -807,13 +784,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
},
|
||||
modifier = Modifier.weight(1f).height(52.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = onboardingAccent,
|
||||
contentColor = Color.White,
|
||||
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
|
||||
disabledContentColor = Color.White,
|
||||
),
|
||||
colors = onboardingPrimaryButtonColors(),
|
||||
) {
|
||||
Text("Next", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
@@ -827,13 +798,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
},
|
||||
modifier = Modifier.weight(1f).height(52.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = onboardingAccent,
|
||||
contentColor = Color.White,
|
||||
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
|
||||
disabledContentColor = Color.White,
|
||||
),
|
||||
colors = onboardingPrimaryButtonColors(),
|
||||
) {
|
||||
Text("Next", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
@@ -844,13 +809,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
onClick = { viewModel.setOnboardingCompleted(true) },
|
||||
modifier = Modifier.weight(1f).height(52.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = onboardingAccent,
|
||||
contentColor = Color.White,
|
||||
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
|
||||
disabledContentColor = Color.White,
|
||||
),
|
||||
colors = onboardingPrimaryButtonColors(),
|
||||
) {
|
||||
Text("Finish", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
@@ -883,13 +842,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
},
|
||||
modifier = Modifier.weight(1f).height(52.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = onboardingAccent,
|
||||
contentColor = Color.White,
|
||||
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
|
||||
disabledContentColor = Color.White,
|
||||
),
|
||||
colors = onboardingPrimaryButtonColors(),
|
||||
) {
|
||||
Text("Connect", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
@@ -901,6 +854,36 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun onboardingPrimaryButtonColors() =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = onboardingAccent,
|
||||
contentColor = Color.White,
|
||||
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
|
||||
disabledContentColor = Color.White.copy(alpha = 0.9f),
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun onboardingTextFieldColors() =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = onboardingSurface,
|
||||
unfocusedContainerColor = onboardingSurface,
|
||||
focusedBorderColor = onboardingAccent,
|
||||
unfocusedBorderColor = onboardingBorder,
|
||||
focusedTextColor = onboardingText,
|
||||
unfocusedTextColor = onboardingText,
|
||||
cursorColor = onboardingAccent,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun onboardingSwitchColors() =
|
||||
SwitchDefaults.colors(
|
||||
checkedTrackColor = onboardingAccent,
|
||||
uncheckedTrackColor = onboardingBorderStrong,
|
||||
checkedThumbColor = Color.White,
|
||||
uncheckedThumbColor = Color.White,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun StepRail(current: OnboardingStep) {
|
||||
val steps = OnboardingStep.entries
|
||||
@@ -1005,11 +988,7 @@ private fun GatewayStep(
|
||||
onClick = onScanQrClick,
|
||||
modifier = Modifier.fillMaxWidth().height(48.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = onboardingAccent,
|
||||
contentColor = Color.White,
|
||||
),
|
||||
colors = onboardingPrimaryButtonColors(),
|
||||
) {
|
||||
Text("Scan QR code", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
|
||||
}
|
||||
@@ -1059,15 +1038,7 @@ private fun GatewayStep(
|
||||
textStyle = onboardingBodyStyle.copy(fontFamily = FontFamily.Monospace, color = onboardingText),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = onboardingSurface,
|
||||
unfocusedContainerColor = onboardingSurface,
|
||||
focusedBorderColor = onboardingAccent,
|
||||
unfocusedBorderColor = onboardingBorder,
|
||||
focusedTextColor = onboardingText,
|
||||
unfocusedTextColor = onboardingText,
|
||||
cursorColor = onboardingAccent,
|
||||
),
|
||||
onboardingTextFieldColors(),
|
||||
)
|
||||
if (!resolvedEndpoint.isNullOrBlank()) {
|
||||
ResolvedEndpoint(endpoint = resolvedEndpoint)
|
||||
@@ -1097,15 +1068,7 @@ private fun GatewayStep(
|
||||
textStyle = onboardingBodyStyle.copy(color = onboardingText),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = onboardingSurface,
|
||||
unfocusedContainerColor = onboardingSurface,
|
||||
focusedBorderColor = onboardingAccent,
|
||||
unfocusedBorderColor = onboardingBorder,
|
||||
focusedTextColor = onboardingText,
|
||||
unfocusedTextColor = onboardingText,
|
||||
cursorColor = onboardingAccent,
|
||||
),
|
||||
onboardingTextFieldColors(),
|
||||
)
|
||||
|
||||
Text("PORT", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
|
||||
@@ -1119,15 +1082,7 @@ private fun GatewayStep(
|
||||
textStyle = onboardingBodyStyle.copy(fontFamily = FontFamily.Monospace, color = onboardingText),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = onboardingSurface,
|
||||
unfocusedContainerColor = onboardingSurface,
|
||||
focusedBorderColor = onboardingAccent,
|
||||
unfocusedBorderColor = onboardingBorder,
|
||||
focusedTextColor = onboardingText,
|
||||
unfocusedTextColor = onboardingText,
|
||||
cursorColor = onboardingAccent,
|
||||
),
|
||||
onboardingTextFieldColors(),
|
||||
)
|
||||
|
||||
Row(
|
||||
@@ -1143,12 +1098,7 @@ private fun GatewayStep(
|
||||
checked = manualTls,
|
||||
onCheckedChange = onManualTlsChange,
|
||||
colors =
|
||||
SwitchDefaults.colors(
|
||||
checkedTrackColor = onboardingAccent,
|
||||
uncheckedTrackColor = onboardingBorderStrong,
|
||||
checkedThumbColor = Color.White,
|
||||
uncheckedThumbColor = Color.White,
|
||||
),
|
||||
onboardingSwitchColors(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1163,15 +1113,7 @@ private fun GatewayStep(
|
||||
textStyle = onboardingBodyStyle.copy(color = onboardingText),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = onboardingSurface,
|
||||
unfocusedContainerColor = onboardingSurface,
|
||||
focusedBorderColor = onboardingAccent,
|
||||
unfocusedBorderColor = onboardingBorder,
|
||||
focusedTextColor = onboardingText,
|
||||
unfocusedTextColor = onboardingText,
|
||||
cursorColor = onboardingAccent,
|
||||
),
|
||||
onboardingTextFieldColors(),
|
||||
)
|
||||
|
||||
Text("PASSWORD (OPTIONAL)", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
|
||||
@@ -1185,15 +1127,7 @@ private fun GatewayStep(
|
||||
textStyle = onboardingBodyStyle.copy(color = onboardingText),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = onboardingSurface,
|
||||
unfocusedContainerColor = onboardingSurface,
|
||||
focusedBorderColor = onboardingAccent,
|
||||
unfocusedBorderColor = onboardingBorder,
|
||||
focusedTextColor = onboardingText,
|
||||
unfocusedTextColor = onboardingText,
|
||||
cursorColor = onboardingAccent,
|
||||
),
|
||||
onboardingTextFieldColors(),
|
||||
)
|
||||
|
||||
if (!manualResolvedEndpoint.isNullOrBlank()) {
|
||||
@@ -1261,7 +1195,7 @@ private fun GatewayModeChip(
|
||||
containerColor = if (active) onboardingAccent else onboardingSurface,
|
||||
contentColor = if (active) Color.White else onboardingText,
|
||||
),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, if (active) Color(0xFF184DAF) else onboardingBorderStrong),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, if (active) onboardingAccentBorderStrong else onboardingBorderStrong),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
@@ -1524,13 +1458,7 @@ private fun PermissionToggleRow(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
enabled = enabled,
|
||||
colors =
|
||||
SwitchDefaults.colors(
|
||||
checkedTrackColor = onboardingAccent,
|
||||
uncheckedTrackColor = onboardingBorderStrong,
|
||||
checkedThumbColor = Color.White,
|
||||
uncheckedThumbColor = Color.White,
|
||||
),
|
||||
colors = onboardingSwitchColors(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1605,7 +1533,7 @@ private fun FinalStep(
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = Color(0xFFEEF9F3),
|
||||
color = onboardingSuccessSoft,
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, onboardingSuccess.copy(alpha = 0.2f)),
|
||||
) {
|
||||
Row(
|
||||
@@ -1641,7 +1569,7 @@ private fun FinalStep(
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = Color(0xFFFFF8EC),
|
||||
color = onboardingWarningSoft,
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, onboardingWarning.copy(alpha = 0.2f)),
|
||||
) {
|
||||
Column(
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
|
||||
@@ -13,8 +14,11 @@ fun OpenClawTheme(content: @Composable () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val colorScheme = if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
val mobileColors = if (isDark) darkMobileColors() else lightMobileColors()
|
||||
|
||||
MaterialTheme(colorScheme = colorScheme, content = content)
|
||||
CompositionLocalProvider(LocalMobileColors provides mobileColors) {
|
||||
MaterialTheme(colorScheme = colorScheme, content = content)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -159,28 +159,28 @@ private fun TopStatusBar(
|
||||
mobileSuccessSoft,
|
||||
mobileSuccess,
|
||||
mobileSuccess,
|
||||
Color(0xFFCFEBD8),
|
||||
LocalMobileColors.current.chipBorderConnected,
|
||||
)
|
||||
StatusVisual.Connecting ->
|
||||
listOf(
|
||||
mobileAccentSoft,
|
||||
mobileAccent,
|
||||
mobileAccent,
|
||||
Color(0xFFD5E2FA),
|
||||
LocalMobileColors.current.chipBorderConnecting,
|
||||
)
|
||||
StatusVisual.Warning ->
|
||||
listOf(
|
||||
mobileWarningSoft,
|
||||
mobileWarning,
|
||||
mobileWarning,
|
||||
Color(0xFFEED8B8),
|
||||
LocalMobileColors.current.chipBorderWarning,
|
||||
)
|
||||
StatusVisual.Error ->
|
||||
listOf(
|
||||
mobileDangerSoft,
|
||||
mobileDanger,
|
||||
mobileDanger,
|
||||
Color(0xFFF3C8C8),
|
||||
LocalMobileColors.current.chipBorderError,
|
||||
)
|
||||
StatusVisual.Offline ->
|
||||
listOf(
|
||||
@@ -249,7 +249,7 @@ private fun BottomTabBar(
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = Color.White.copy(alpha = 0.97f),
|
||||
color = mobileCardSurface.copy(alpha = 0.97f),
|
||||
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
|
||||
border = BorderStroke(1.dp, mobileBorder),
|
||||
shadowElevation = 6.dp,
|
||||
@@ -270,7 +270,7 @@ private fun BottomTabBar(
|
||||
modifier = Modifier.weight(1f).heightIn(min = 58.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = if (active) mobileAccentSoft else Color.Transparent,
|
||||
border = if (active) BorderStroke(1.dp, Color(0xFFD5E2FA)) else null,
|
||||
border = if (active) BorderStroke(1.dp, LocalMobileColors.current.chipBorderConnecting) else null,
|
||||
shadowElevation = 0.dp,
|
||||
) {
|
||||
Column(
|
||||
|
||||
@@ -736,11 +736,12 @@ private fun settingsTextFieldColors() =
|
||||
cursorColor = mobileAccent,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun Modifier.settingsRowModifier() =
|
||||
this
|
||||
.fillMaxWidth()
|
||||
.border(width = 1.dp, color = mobileBorder, shape = RoundedCornerShape(14.dp))
|
||||
.background(Color.White, RoundedCornerShape(14.dp))
|
||||
.background(mobileCardSurface, RoundedCornerShape(14.dp))
|
||||
|
||||
@Composable
|
||||
private fun settingsPrimaryButtonColors() =
|
||||
|
||||
@@ -363,7 +363,7 @@ private fun VoiceTurnBubble(entry: VoiceConversationEntry) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(0.90f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = if (isUser) mobileAccentSoft else Color.White,
|
||||
color = if (isUser) mobileAccentSoft else mobileCardSurface,
|
||||
border = BorderStroke(1.dp, if (isUser) mobileAccent else mobileBorderStrong),
|
||||
) {
|
||||
Column(
|
||||
@@ -391,7 +391,7 @@ private fun VoiceThinkingBubble() {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(0.68f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = Color.White,
|
||||
color = mobileCardSurface,
|
||||
border = BorderStroke(1.dp, mobileBorderStrong),
|
||||
) {
|
||||
Row(
|
||||
|
||||
@@ -46,11 +46,13 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import ai.openclaw.app.ui.mobileAccent
|
||||
import ai.openclaw.app.ui.mobileAccentBorderStrong
|
||||
import ai.openclaw.app.ui.mobileAccentSoft
|
||||
import ai.openclaw.app.ui.mobileBorder
|
||||
import ai.openclaw.app.ui.mobileBorderStrong
|
||||
import ai.openclaw.app.ui.mobileCallout
|
||||
import ai.openclaw.app.ui.mobileCaption1
|
||||
import ai.openclaw.app.ui.mobileCardSurface
|
||||
import ai.openclaw.app.ui.mobileHeadline
|
||||
import ai.openclaw.app.ui.mobileSurface
|
||||
import ai.openclaw.app.ui.mobileText
|
||||
@@ -110,7 +112,7 @@ fun ChatComposer(
|
||||
Surface(
|
||||
onClick = { showThinkingMenu = true },
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = Color.White,
|
||||
color = mobileCardSurface,
|
||||
border = BorderStroke(1.dp, mobileBorderStrong),
|
||||
) {
|
||||
Row(
|
||||
@@ -177,7 +179,7 @@ fun ChatComposer(
|
||||
disabledContainerColor = mobileBorderStrong,
|
||||
disabledContentColor = mobileTextTertiary,
|
||||
),
|
||||
border = BorderStroke(1.dp, if (canSend) Color(0xFF154CAD) else mobileBorderStrong),
|
||||
border = BorderStroke(1.dp, if (canSend) mobileAccentBorderStrong else mobileBorderStrong),
|
||||
) {
|
||||
if (sendBusy) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp, color = Color.White)
|
||||
@@ -211,9 +213,9 @@ private fun SecondaryActionButton(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = Color.White,
|
||||
containerColor = mobileCardSurface,
|
||||
contentColor = mobileTextSecondary,
|
||||
disabledContainerColor = Color.White,
|
||||
disabledContainerColor = mobileCardSurface,
|
||||
disabledContentColor = mobileTextTertiary,
|
||||
),
|
||||
border = BorderStroke(1.dp, mobileBorderStrong),
|
||||
@@ -303,7 +305,7 @@ private fun AttachmentChip(fileName: String, onRemove: () -> Unit) {
|
||||
Surface(
|
||||
onClick = onRemove,
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
color = Color.White,
|
||||
color = mobileCardSurface,
|
||||
border = BorderStroke(1.dp, mobileBorderStrong),
|
||||
) {
|
||||
Text(
|
||||
|
||||
@@ -94,7 +94,7 @@ private val markdownParser: Parser by lazy {
|
||||
@Composable
|
||||
fun ChatMarkdown(text: String, textColor: Color) {
|
||||
val document = remember(text) { markdownParser.parse(text) as Document }
|
||||
val inlineStyles = InlineStyles(inlineCodeBg = mobileCodeBg, inlineCodeColor = mobileCodeText)
|
||||
val inlineStyles = InlineStyles(inlineCodeBg = mobileCodeBg, inlineCodeColor = mobileCodeText, linkColor = mobileAccent, baseCallout = mobileCallout)
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
RenderMarkdownBlocks(
|
||||
@@ -124,7 +124,7 @@ private fun RenderMarkdownBlocks(
|
||||
val headingText = remember(current) { buildInlineMarkdown(current.firstChild, inlineStyles) }
|
||||
Text(
|
||||
text = headingText,
|
||||
style = headingStyle(current.level),
|
||||
style = headingStyle(current.level, inlineStyles.baseCallout),
|
||||
color = textColor,
|
||||
)
|
||||
}
|
||||
@@ -231,7 +231,7 @@ private fun RenderParagraph(
|
||||
|
||||
Text(
|
||||
text = annotated,
|
||||
style = mobileCallout,
|
||||
style = inlineStyles.baseCallout,
|
||||
color = textColor,
|
||||
)
|
||||
}
|
||||
@@ -315,7 +315,7 @@ private fun RenderListItem(
|
||||
) {
|
||||
Text(
|
||||
text = marker,
|
||||
style = mobileCallout.copy(fontWeight = FontWeight.SemiBold),
|
||||
style = inlineStyles.baseCallout.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = textColor,
|
||||
modifier = Modifier.width(24.dp),
|
||||
)
|
||||
@@ -360,7 +360,7 @@ private fun RenderTableBlock(
|
||||
val cell = row.cells.getOrNull(index) ?: AnnotatedString("")
|
||||
Text(
|
||||
text = cell,
|
||||
style = if (row.isHeader) mobileCaption1.copy(fontWeight = FontWeight.SemiBold) else mobileCallout,
|
||||
style = if (row.isHeader) mobileCaption1.copy(fontWeight = FontWeight.SemiBold) else inlineStyles.baseCallout,
|
||||
color = textColor,
|
||||
modifier = Modifier
|
||||
.border(1.dp, mobileTextSecondary.copy(alpha = 0.22f))
|
||||
@@ -417,6 +417,7 @@ private fun buildInlineMarkdown(start: Node?, inlineStyles: InlineStyles): Annot
|
||||
node = start,
|
||||
inlineCodeBg = inlineStyles.inlineCodeBg,
|
||||
inlineCodeColor = inlineStyles.inlineCodeColor,
|
||||
linkColor = inlineStyles.linkColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -425,6 +426,7 @@ private fun AnnotatedString.Builder.appendInlineNode(
|
||||
node: Node?,
|
||||
inlineCodeBg: Color,
|
||||
inlineCodeColor: Color,
|
||||
linkColor: Color,
|
||||
) {
|
||||
var current = node
|
||||
while (current != null) {
|
||||
@@ -445,27 +447,27 @@ private fun AnnotatedString.Builder.appendInlineNode(
|
||||
}
|
||||
is Emphasis -> {
|
||||
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor, linkColor = linkColor)
|
||||
}
|
||||
}
|
||||
is StrongEmphasis -> {
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) {
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor, linkColor = linkColor)
|
||||
}
|
||||
}
|
||||
is Strikethrough -> {
|
||||
withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) {
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor, linkColor = linkColor)
|
||||
}
|
||||
}
|
||||
is Link -> {
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
color = mobileAccent,
|
||||
color = linkColor,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
),
|
||||
) {
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor, linkColor = linkColor)
|
||||
}
|
||||
}
|
||||
is MarkdownImage -> {
|
||||
@@ -482,7 +484,7 @@ private fun AnnotatedString.Builder.appendInlineNode(
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor)
|
||||
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor, linkColor = linkColor)
|
||||
}
|
||||
}
|
||||
current = current.next
|
||||
@@ -519,19 +521,21 @@ private fun parseDataImageDestination(destination: String?): ParsedDataImage? {
|
||||
return ParsedDataImage(mimeType = "image/$subtype", base64 = base64)
|
||||
}
|
||||
|
||||
private fun headingStyle(level: Int): TextStyle {
|
||||
private fun headingStyle(level: Int, baseCallout: TextStyle): TextStyle {
|
||||
return when (level.coerceIn(1, 6)) {
|
||||
1 -> mobileCallout.copy(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold)
|
||||
2 -> mobileCallout.copy(fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold)
|
||||
3 -> mobileCallout.copy(fontSize = 18.sp, lineHeight = 24.sp, fontWeight = FontWeight.SemiBold)
|
||||
4 -> mobileCallout.copy(fontSize = 16.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold)
|
||||
else -> mobileCallout.copy(fontWeight = FontWeight.SemiBold)
|
||||
1 -> baseCallout.copy(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold)
|
||||
2 -> baseCallout.copy(fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold)
|
||||
3 -> baseCallout.copy(fontSize = 18.sp, lineHeight = 24.sp, fontWeight = FontWeight.SemiBold)
|
||||
4 -> baseCallout.copy(fontSize = 16.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold)
|
||||
else -> baseCallout.copy(fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
|
||||
private data class InlineStyles(
|
||||
val inlineCodeBg: Color,
|
||||
val inlineCodeColor: Color,
|
||||
val linkColor: Color,
|
||||
val baseCallout: TextStyle,
|
||||
)
|
||||
|
||||
private data class TableRenderRow(
|
||||
|
||||
@@ -19,6 +19,7 @@ import ai.openclaw.app.chat.ChatMessage
|
||||
import ai.openclaw.app.chat.ChatPendingToolCall
|
||||
import ai.openclaw.app.ui.mobileBorder
|
||||
import ai.openclaw.app.ui.mobileCallout
|
||||
import ai.openclaw.app.ui.mobileCardSurface
|
||||
import ai.openclaw.app.ui.mobileHeadline
|
||||
import ai.openclaw.app.ui.mobileText
|
||||
import ai.openclaw.app.ui.mobileTextSecondary
|
||||
@@ -85,7 +86,7 @@ private fun EmptyChatHint(modifier: Modifier = Modifier, healthOk: Boolean) {
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.9f),
|
||||
color = mobileCardSurface.copy(alpha = 0.9f),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, mobileBorder),
|
||||
) {
|
||||
androidx.compose.foundation.layout.Column(
|
||||
|
||||
@@ -36,7 +36,9 @@ import ai.openclaw.app.ui.mobileBorderStrong
|
||||
import ai.openclaw.app.ui.mobileCallout
|
||||
import ai.openclaw.app.ui.mobileCaption1
|
||||
import ai.openclaw.app.ui.mobileCaption2
|
||||
import ai.openclaw.app.ui.mobileCardSurface
|
||||
import ai.openclaw.app.ui.mobileCodeBg
|
||||
import ai.openclaw.app.ui.mobileCodeBorder
|
||||
import ai.openclaw.app.ui.mobileCodeText
|
||||
import ai.openclaw.app.ui.mobileHeadline
|
||||
import ai.openclaw.app.ui.mobileText
|
||||
@@ -194,6 +196,7 @@ fun ChatStreamingAssistantBubble(text: String) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun bubbleStyle(role: String): ChatBubbleStyle {
|
||||
return when (role) {
|
||||
"user" ->
|
||||
@@ -215,7 +218,7 @@ private fun bubbleStyle(role: String): ChatBubbleStyle {
|
||||
else ->
|
||||
ChatBubbleStyle(
|
||||
alignEnd = false,
|
||||
containerColor = Color.White,
|
||||
containerColor = mobileCardSurface,
|
||||
borderColor = mobileBorderStrong,
|
||||
roleColor = mobileTextSecondary,
|
||||
)
|
||||
@@ -239,7 +242,7 @@ private fun ChatBase64Image(base64: String, mimeType: String?) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
border = BorderStroke(1.dp, mobileBorder),
|
||||
color = Color.White,
|
||||
color = mobileCardSurface,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Image(
|
||||
@@ -277,7 +280,7 @@ fun ChatCodeBlock(code: String, language: String?) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = mobileCodeBg,
|
||||
border = BorderStroke(1.dp, Color(0xFF2B2E35)),
|
||||
border = BorderStroke(1.dp, mobileCodeBorder),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
|
||||
@@ -36,12 +36,15 @@ import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.chat.ChatSessionEntry
|
||||
import ai.openclaw.app.chat.OutgoingAttachment
|
||||
import ai.openclaw.app.ui.mobileAccent
|
||||
import ai.openclaw.app.ui.mobileAccentBorderStrong
|
||||
import ai.openclaw.app.ui.mobileBorder
|
||||
import ai.openclaw.app.ui.mobileBorderStrong
|
||||
import ai.openclaw.app.ui.mobileCallout
|
||||
import ai.openclaw.app.ui.mobileCardSurface
|
||||
import ai.openclaw.app.ui.mobileCaption1
|
||||
import ai.openclaw.app.ui.mobileCaption2
|
||||
import ai.openclaw.app.ui.mobileDanger
|
||||
import ai.openclaw.app.ui.mobileDangerSoft
|
||||
import ai.openclaw.app.ui.mobileText
|
||||
import ai.openclaw.app.ui.mobileTextSecondary
|
||||
import java.io.ByteArrayOutputStream
|
||||
@@ -168,8 +171,8 @@ private fun ChatThreadSelector(
|
||||
Surface(
|
||||
onClick = { onSelectSession(entry.key) },
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = if (active) mobileAccent else Color.White,
|
||||
border = BorderStroke(1.dp, if (active) Color(0xFF154CAD) else mobileBorderStrong),
|
||||
color = if (active) mobileAccent else mobileCardSurface,
|
||||
border = BorderStroke(1.dp, if (active) mobileAccentBorderStrong else mobileBorderStrong),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp,
|
||||
) {
|
||||
@@ -190,7 +193,7 @@ private fun ChatThreadSelector(
|
||||
private fun ChatErrorRail(errorText: String) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = androidx.compose.ui.graphics.Color.White,
|
||||
color = mobileDangerSoft,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, mobileDanger),
|
||||
) {
|
||||
|
||||
8
apps/android/app/src/main/res/values-night/themes.xml
Normal file
8
apps/android/app/src/main/res/values-night/themes.xml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.OpenClawNode" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
8
docs/.generated/README.md
Normal file
8
docs/.generated/README.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Generated Docs Artifacts
|
||||
|
||||
These baseline artifacts are generated from the repo-owned OpenClaw config schema and bundled channel/plugin metadata.
|
||||
|
||||
- Do not edit `config-baseline.json` by hand.
|
||||
- Do not edit `config-baseline.jsonl` by hand.
|
||||
- Regenerate it with `pnpm config:docs:gen`.
|
||||
- Validate it in CI or locally with `pnpm config:docs:check`.
|
||||
49887
docs/.generated/config-baseline.json
Normal file
49887
docs/.generated/config-baseline.json
Normal file
File diff suppressed because it is too large
Load Diff
4734
docs/.generated/config-baseline.jsonl
Normal file
4734
docs/.generated/config-baseline.jsonl
Normal file
File diff suppressed because it is too large
Load Diff
@@ -186,20 +186,15 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider:
|
||||
|
||||
Kimi K2 model IDs:
|
||||
|
||||
<!-- markdownlint-disable MD037 -->
|
||||
|
||||
{/_ moonshot-kimi-k2-model-refs:start _/ && null}
|
||||
|
||||
<!-- markdownlint-enable MD037 -->
|
||||
[//]: # "moonshot-kimi-k2-model-refs:start"
|
||||
|
||||
- `moonshot/kimi-k2.5`
|
||||
- `moonshot/kimi-k2-0905-preview`
|
||||
- `moonshot/kimi-k2-turbo-preview`
|
||||
- `moonshot/kimi-k2-thinking`
|
||||
- `moonshot/kimi-k2-thinking-turbo`
|
||||
<!-- markdownlint-disable MD037 -->
|
||||
{/_ moonshot-kimi-k2-model-refs:end _/ && null}
|
||||
<!-- markdownlint-enable MD037 -->
|
||||
|
||||
[//]: # "moonshot-kimi-k2-model-refs:end"
|
||||
|
||||
```json5
|
||||
{
|
||||
|
||||
@@ -1242,7 +1242,6 @@
|
||||
"group": "Security",
|
||||
"pages": [
|
||||
"security/formal-verification",
|
||||
"security/README",
|
||||
"security/THREAT-MODEL-ATLAS",
|
||||
"security/CONTRIBUTING-THREAT-MODEL"
|
||||
]
|
||||
@@ -1598,7 +1597,6 @@
|
||||
"zh-CN/tools/apply-patch",
|
||||
"zh-CN/brave-search",
|
||||
"zh-CN/perplexity",
|
||||
"zh-CN/tools/diffs",
|
||||
"zh-CN/tools/elevated",
|
||||
"zh-CN/tools/exec",
|
||||
"zh-CN/tools/exec-approvals",
|
||||
|
||||
@@ -975,6 +975,7 @@ Periodic heartbeat runs.
|
||||
model: "openai/gpt-5.2-mini",
|
||||
includeReasoning: false,
|
||||
lightContext: false, // default: false; true keeps only HEARTBEAT.md from workspace bootstrap files
|
||||
isolatedSession: false, // default: false; true runs each heartbeat in a fresh session (no conversation history)
|
||||
session: "main",
|
||||
to: "+15555550123",
|
||||
directPolicy: "allow", // allow (default) | block
|
||||
@@ -992,6 +993,7 @@ Periodic heartbeat runs.
|
||||
- `suppressToolErrorWarnings`: when true, suppresses tool error warning payloads during heartbeat runs.
|
||||
- `directPolicy`: direct/DM delivery policy. `allow` (default) permits direct-target delivery. `block` suppresses direct-target delivery and emits `reason=dm-blocked`.
|
||||
- `lightContext`: when true, heartbeat runs use lightweight bootstrap context and keep only `HEARTBEAT.md` from workspace bootstrap files.
|
||||
- `isolatedSession`: when true, each heartbeat runs in a fresh session with no prior conversation history. Same isolation pattern as cron `sessionTarget: "isolated"`. Reduces per-heartbeat token cost from ~100K to ~2-5K tokens.
|
||||
- Per-agent: set `agents.list[].heartbeat`. When any agent defines `heartbeat`, **only those agents** run heartbeats.
|
||||
- Heartbeats run full agent turns — shorter intervals burn more tokens.
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@ Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting)
|
||||
3. Decide where heartbeat messages should go (`target: "none"` is the default; set `target: "last"` to route to the last contact).
|
||||
4. Optional: enable heartbeat reasoning delivery for transparency.
|
||||
5. Optional: use lightweight bootstrap context if heartbeat runs only need `HEARTBEAT.md`.
|
||||
6. Optional: restrict heartbeats to active hours (local time).
|
||||
6. Optional: enable isolated sessions to avoid sending full conversation history each heartbeat.
|
||||
7. Optional: restrict heartbeats to active hours (local time).
|
||||
|
||||
Example config:
|
||||
|
||||
@@ -35,6 +36,7 @@ Example config:
|
||||
target: "last", // explicit delivery to last contact (default is "none")
|
||||
directPolicy: "allow", // default: allow direct/DM targets; set "block" to suppress
|
||||
lightContext: true, // optional: only inject HEARTBEAT.md from bootstrap files
|
||||
isolatedSession: true, // optional: fresh session each run (no conversation history)
|
||||
// activeHours: { start: "08:00", end: "24:00" },
|
||||
// includeReasoning: true, // optional: send separate `Reasoning:` message too
|
||||
},
|
||||
@@ -91,6 +93,7 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
includeReasoning: false, // default: false (deliver separate Reasoning: message when available)
|
||||
lightContext: false, // default: false; true keeps only HEARTBEAT.md from workspace bootstrap files
|
||||
isolatedSession: false, // default: false; true runs each heartbeat in a fresh session (no conversation history)
|
||||
target: "last", // default: none | options: last | none | <channel id> (core or plugin, e.g. "bluebubbles")
|
||||
to: "+15551234567", // optional channel-specific override
|
||||
accountId: "ops-bot", // optional multi-account channel id
|
||||
@@ -212,6 +215,7 @@ Use `accountId` to target a specific account on multi-account channels like Tele
|
||||
- `model`: optional model override for heartbeat runs (`provider/model`).
|
||||
- `includeReasoning`: when enabled, also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`).
|
||||
- `lightContext`: when true, heartbeat runs use lightweight bootstrap context and keep only `HEARTBEAT.md` from workspace bootstrap files.
|
||||
- `isolatedSession`: when true, each heartbeat runs in a fresh session with no prior conversation history. Uses the same isolation pattern as cron `sessionTarget: "isolated"`. Dramatically reduces per-heartbeat token cost. Combine with `lightContext: true` for maximum savings. Delivery routing still uses the main session context.
|
||||
- `session`: optional session key for heartbeat runs.
|
||||
- `main` (default): agent main session.
|
||||
- Explicit session key (copy from `openclaw sessions --json` or the [sessions CLI](/cli/sessions)).
|
||||
@@ -380,6 +384,10 @@ off in group chats.
|
||||
|
||||
## Cost awareness
|
||||
|
||||
Heartbeats run full agent turns. Shorter intervals burn more tokens. Keep
|
||||
`HEARTBEAT.md` small and consider a cheaper `model` or `target: "none"` if you
|
||||
only want internal state updates.
|
||||
Heartbeats run full agent turns. Shorter intervals burn more tokens. To reduce cost:
|
||||
|
||||
- Use `isolatedSession: true` to avoid sending full conversation history (~100K tokens down to ~2-5K per run).
|
||||
- Use `lightContext: true` to limit bootstrap files to just `HEARTBEAT.md`.
|
||||
- Set a cheaper `model` (e.g. `ollama/llama3.2:1b`).
|
||||
- Keep `HEARTBEAT.md` small.
|
||||
- Use `target: "none"` if you only want internal state updates.
|
||||
|
||||
@@ -289,7 +289,7 @@ Look for:
|
||||
|
||||
- Valid browser executable path.
|
||||
- CDP profile reachability.
|
||||
- Extension relay tab attachment for `profile="chrome-relay"`.
|
||||
- Extension relay tab attachment (if an extension relay profile is configured).
|
||||
|
||||
Common signatures:
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ When you touch tests or want extra confidence:
|
||||
|
||||
- Coverage gate: `pnpm test:coverage`
|
||||
- E2E suite: `pnpm test:e2e`
|
||||
- Targeted local repro: `pnpm test -- <path/to/test>` or `pnpm test:macmini -- <path/to/test>` on lower-memory hosts
|
||||
|
||||
When debugging real providers/models (requires real creds):
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ If you use `OPENROUTER_API_KEY`, an `sk-or-...` key in `tools.web.search.perplex
|
||||
|
||||
## Getting a Perplexity API key
|
||||
|
||||
1. Create a Perplexity account at <https://www.perplexity.ai/settings/api>
|
||||
1. Create a Perplexity account at [perplexity.ai/settings/api](https://www.perplexity.ai/settings/api)
|
||||
2. Generate an API key in the dashboard
|
||||
3. Store the key in config or set `PERPLEXITY_API_KEY` in the Gateway environment.
|
||||
|
||||
|
||||
@@ -15,20 +15,15 @@ Kimi Coding with `kimi-coding/k2p5`.
|
||||
|
||||
Current Kimi K2 model IDs:
|
||||
|
||||
<!-- markdownlint-disable MD037 -->
|
||||
|
||||
{/_ moonshot-kimi-k2-ids:start _/ && null}
|
||||
|
||||
<!-- markdownlint-enable MD037 -->
|
||||
[//]: # "moonshot-kimi-k2-ids:start"
|
||||
|
||||
- `kimi-k2.5`
|
||||
- `kimi-k2-0905-preview`
|
||||
- `kimi-k2-turbo-preview`
|
||||
- `kimi-k2-thinking`
|
||||
- `kimi-k2-thinking-turbo`
|
||||
<!-- markdownlint-disable MD037 -->
|
||||
{/_ moonshot-kimi-k2-ids:end _/ && null}
|
||||
<!-- markdownlint-enable MD037 -->
|
||||
|
||||
[//]: # "moonshot-kimi-k2-ids:end"
|
||||
|
||||
```bash
|
||||
openclaw onboard --auth-choice moonshot-api-key
|
||||
|
||||
@@ -201,7 +201,7 @@ Notes:
|
||||
|
||||
- Where to host SDK types: separate package or core export?
|
||||
- Runtime type distribution: in SDK (types only) or in core?
|
||||
- How to expose docs links from one plugin-owned metadata path regardless of provenance?
|
||||
- How to expose docs links for bundled vs external plugins?
|
||||
- Do we allow limited direct core imports for in-repo plugins during transition?
|
||||
|
||||
## Success criteria
|
||||
|
||||
@@ -29,6 +29,10 @@ Current OpenClaw releases use date-based versioning.
|
||||
- Beta prerelease version: `YYYY.M.D-beta.N`
|
||||
- Git tag: `vYYYY.M.D-beta.N`
|
||||
- Examples from repo history: `v2026.2.15-beta.1`, `v2026.3.8-beta.1`
|
||||
- Fallback correction tag: `vYYYY.M.D-N`
|
||||
- Use only as a last-resort recovery tag when a published immutable release burned the original stable tag and you cannot reuse it.
|
||||
- The npm package version stays `YYYY.M.D`; the `-N` suffix is only for the git tag and GitHub release.
|
||||
- Prefer betas for normal pre-release iteration, then cut a clean stable tag once ready.
|
||||
- Use the same version string everywhere, minus the leading `v` where Git tags are not used:
|
||||
- `package.json`: `2026.3.8`
|
||||
- Git tag: `v2026.3.8`
|
||||
@@ -38,12 +42,12 @@ Current OpenClaw releases use date-based versioning.
|
||||
- `latest` = stable
|
||||
- `beta` = prerelease/testing
|
||||
- Dev is the moving head of `main`, not a normal git-tagged release.
|
||||
- The tag-triggered preview run enforces the current stable/beta tag formats and rejects versions whose CalVer date is more than 2 UTC calendar days away from the release date.
|
||||
- The tag-triggered preview run accepts stable, beta, and fallback correction tags, and rejects versions whose CalVer date is more than 2 UTC calendar days away from the release date.
|
||||
|
||||
Historical note:
|
||||
|
||||
- Older tags such as `v2026.1.11-1`, `v2026.2.6-3`, and `v2.0.0-beta2` exist in repo history.
|
||||
- Treat those as legacy tag patterns. New releases should use `vYYYY.M.D` for stable and `vYYYY.M.D-beta.N` for beta.
|
||||
- Treat correction tags as a fallback-only escape hatch. New releases should still use `vYYYY.M.D` for stable and `vYYYY.M.D-beta.N` for beta.
|
||||
|
||||
1. **Version & metadata**
|
||||
|
||||
@@ -72,6 +76,7 @@ Historical note:
|
||||
- [ ] `pnpm check`
|
||||
- [ ] `pnpm test` (or `pnpm test:coverage` if you need coverage output)
|
||||
- [ ] `pnpm release:check` (verifies npm pack contents)
|
||||
- [ ] If `pnpm config:docs:check` fails as part of release validation and the config-surface change is intentional, run `pnpm config:docs:gen`, review `docs/.generated/config-baseline.json` and `docs/.generated/config-baseline.jsonl`, commit the updated baselines, then rerun `pnpm release:check`.
|
||||
- [ ] `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke` (Docker install smoke test, fast path; required before release)
|
||||
- If the immediate previous npm release is known broken, set `OPENCLAW_INSTALL_SMOKE_PREVIOUS=<last-good-version>` or `OPENCLAW_INSTALL_SMOKE_SKIP_PREVIOUS=1` for the preinstall step.
|
||||
- [ ] (Optional) Full installer smoke (adds non-root + CLI coverage): `pnpm test:install:smoke`
|
||||
@@ -99,7 +104,9 @@ Historical note:
|
||||
- [ ] Run `OpenClaw NPM Release` manually with the same tag to publish after `npm-release` environment approval.
|
||||
- Stable tags publish to npm `latest`.
|
||||
- Beta tags publish to npm `beta`.
|
||||
- Both the preview run and the manual publish run reject tags that do not match `package.json`, are not on `main`, or whose CalVer date is more than 2 UTC calendar days away from the release date.
|
||||
- Fallback correction tags like `v2026.3.13-1` map to npm version `2026.3.13`.
|
||||
- Both the preview run and the manual publish run reject tags that do not map back to `package.json`, are not on `main`, or whose CalVer date is more than 2 UTC calendar days away from the release date.
|
||||
- If `openclaw@YYYY.M.D` is already published, a fallback correction tag is still useful for GitHub release and Docker recovery, but npm publish will not republish that version.
|
||||
- [ ] Verify the registry: `npm view openclaw version`, `npm view openclaw dist-tags`, and `npx -y openclaw@X.Y.Z --version` (or `--help`).
|
||||
|
||||
### Troubleshooting (notes from 2.0.0-beta2 release)
|
||||
@@ -109,8 +116,9 @@ Historical note:
|
||||
- `NPM_CONFIG_AUTH_TYPE=legacy npm dist-tag add openclaw@X.Y.Z latest`
|
||||
- **`npx` verification fails with `ECOMPROMISED: Lock compromised`**: retry with a fresh cache:
|
||||
- `NPM_CONFIG_CACHE=/tmp/npm-cache-$(date +%s) npx -y openclaw@X.Y.Z --version`
|
||||
- **Tag needs repointing after a late fix**: force-update and push the tag, then ensure the GitHub release assets still match:
|
||||
- `git tag -f vX.Y.Z && git push -f origin vX.Y.Z`
|
||||
- **Tag needs recovery after a late fix**: if the original stable tag is tied to an immutable GitHub release, mint a fallback correction tag like `vX.Y.Z-1` instead of trying to force-update `vX.Y.Z`.
|
||||
- Keep the npm package version at `X.Y.Z`; the correction suffix is for the git tag and GitHub release only.
|
||||
- Use this only as a last resort. For normal iteration, prefer beta tags and then cut a clean stable release.
|
||||
|
||||
7. **GitHub release + appcast**
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ For local PR land/gate checks, run:
|
||||
- `pnpm test`
|
||||
- `pnpm check:docs`
|
||||
|
||||
If `pnpm test` flakes on a loaded host, rerun once before treating it as a regression, then isolate with `pnpm test -- <path/to/test>` so the repo test wrapper still applies the intended config/profile logic. On lower-memory hosts, prefer `pnpm test:macmini -- <path/to/test>`. For memory-constrained full-suite runs, use:
|
||||
If `pnpm test` flakes on a loaded host, rerun once before treating it as a regression, then isolate with `pnpm vitest run <path/to/test>`. For memory-constrained hosts, use:
|
||||
|
||||
- `OPENCLAW_TEST_PROFILE=low OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test`
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
# OpenClaw Security & Trust
|
||||
|
||||
**Live:** [trust.openclaw.ai](https://trust.openclaw.ai)
|
||||
|
||||
## Documents
|
||||
|
||||
- [Threat Model](/security/THREAT-MODEL-ATLAS) - MITRE ATLAS-based threat model for the OpenClaw ecosystem
|
||||
- [Contributing to the Threat Model](/security/CONTRIBUTING-THREAT-MODEL) - How to add threats, mitigations, and attack chains
|
||||
|
||||
## Reporting Vulnerabilities
|
||||
|
||||
See the [Trust page](https://trust.openclaw.ai) for full reporting instructions covering all repos.
|
||||
|
||||
## Contact
|
||||
|
||||
- **Jamieson O'Reilly** ([@theonejvo](https://twitter.com/theonejvo)) - Security & Trust
|
||||
- Discord: #security channel
|
||||
@@ -25,7 +25,7 @@ Note, selecting 'chromium-browser' instead of 'chromium'
|
||||
chromium-browser is already the newest version (2:1snap1-0ubuntu2).
|
||||
```
|
||||
|
||||
This is NOT a real browser — it's just a wrapper.
|
||||
This is NOT a real browser - it's just a wrapper.
|
||||
|
||||
### Solution 1: Install Google Chrome (Recommended)
|
||||
|
||||
@@ -123,7 +123,7 @@ curl -s http://127.0.0.1:18791/tabs
|
||||
|
||||
### Problem: "Chrome extension relay is running, but no tab is connected"
|
||||
|
||||
You’re using the `chrome-relay` profile (extension relay). It expects the OpenClaw
|
||||
You're using an extension relay profile. It expects the OpenClaw
|
||||
browser extension to be attached to a live tab.
|
||||
|
||||
Fix options:
|
||||
|
||||
@@ -62,19 +62,14 @@ After upgrading OpenClaw:
|
||||
|
||||
## Use it (set gateway token once)
|
||||
|
||||
OpenClaw ships with a built-in browser profile named `chrome-relay` that targets the extension relay on the default port.
|
||||
To use the extension relay, create a browser profile for it:
|
||||
|
||||
Before first attach, open extension Options and set:
|
||||
|
||||
- `Port` (default `18792`)
|
||||
- `Gateway token` (must match `gateway.auth.token` / `OPENCLAW_GATEWAY_TOKEN`)
|
||||
|
||||
Use it:
|
||||
|
||||
- CLI: `openclaw browser --browser-profile chrome-relay tabs`
|
||||
- Agent tool: `browser` with `profile="chrome-relay"`
|
||||
|
||||
If you want a different name or a different relay port, create your own profile:
|
||||
Then create a profile:
|
||||
|
||||
```bash
|
||||
openclaw browser create-profile \
|
||||
@@ -84,6 +79,11 @@ openclaw browser create-profile \
|
||||
--color "#00AA00"
|
||||
```
|
||||
|
||||
Use it:
|
||||
|
||||
- CLI: `openclaw browser --browser-profile my-chrome tabs`
|
||||
- Agent tool: `browser` with `profile="my-chrome"`
|
||||
|
||||
### Custom Gateway ports
|
||||
|
||||
If you're using a custom gateway port, the extension relay port is automatically derived:
|
||||
|
||||
@@ -160,13 +160,14 @@ Long options are validated fail-closed in safe-bin mode: unknown flags and ambig
|
||||
abbreviations are rejected.
|
||||
Denied flags by safe-bin profile:
|
||||
|
||||
<!-- SAFE_BIN_DENIED_FLAGS:START -->
|
||||
[//]: # "SAFE_BIN_DENIED_FLAGS:START"
|
||||
|
||||
- `grep`: `--dereference-recursive`, `--directories`, `--exclude-from`, `--file`, `--recursive`, `-R`, `-d`, `-f`, `-r`
|
||||
- `jq`: `--argfile`, `--from-file`, `--library-path`, `--rawfile`, `--slurpfile`, `-L`, `-f`
|
||||
- `sort`: `--compress-program`, `--files0-from`, `--output`, `--random-source`, `--temporary-directory`, `-T`, `-o`
|
||||
- `wc`: `--files0-from`
|
||||
<!-- SAFE_BIN_DENIED_FLAGS:END -->
|
||||
|
||||
[//]: # "SAFE_BIN_DENIED_FLAGS:END"
|
||||
|
||||
Safe bins also force argv tokens to be treated as **literal text** at execution time (no globbing
|
||||
and no `$VARS` expansion) for stdin-only segments, so patterns like `*` or `$HOME/...` cannot be
|
||||
|
||||
@@ -274,13 +274,6 @@ Compatibility note:
|
||||
- New and migrated bundled plugins should use channel or extension-specific
|
||||
subpaths; use `core` for generic surfaces and `compat` only when broader
|
||||
shared helpers are required.
|
||||
- Plugin code under `extensions/**` must not import OpenClaw core internals
|
||||
directly. Allowed imports are:
|
||||
- files inside the same extension
|
||||
- `openclaw/plugin-sdk` and `openclaw/plugin-sdk/*`
|
||||
- Node builtins and third-party packages
|
||||
- Direct imports into `src/**`, `openclaw/src/**`, or another extension's source
|
||||
tree are treated as plugin boundary violations and rejected by repo checks.
|
||||
|
||||
## Read-only channel inspection
|
||||
|
||||
|
||||
@@ -149,7 +149,11 @@ Lark(国际版)请使用 https://open.larksuite.com/app,并在配置中设
|
||||
在 **事件订阅** 页面:
|
||||
|
||||
1. 选择 **使用长连接接收事件**(WebSocket 模式)
|
||||
2. 添加事件:`im.message.receive_v1`(接收消息)
|
||||
2. 添加事件:
|
||||
- `im.message.receive_v1`
|
||||
- `im.message.reaction.created_v1`
|
||||
- `im.message.reaction.deleted_v1`
|
||||
- `application.bot.menu_v6`
|
||||
|
||||
⚠️ **注意**:如果网关未启动或渠道未添加,长连接设置将保存失败。
|
||||
|
||||
@@ -435,7 +439,7 @@ openclaw pairing list feishu
|
||||
| `/reset` | 重置对话会话 |
|
||||
| `/model` | 查看/切换模型 |
|
||||
|
||||
> 注意:飞书目前不支持原生命令菜单,命令需要以文本形式发送。
|
||||
飞书机器人菜单建议直接在飞书开放平台的机器人能力页面配置。OpenClaw 当前支持接收 `application.bot.menu_v6` 事件,并把点击事件转换成普通文本命令(例如 `/menu <eventKey>`)继续走现有消息路由,但不通过渠道配置自动创建或同步菜单。
|
||||
|
||||
## 网关管理命令
|
||||
|
||||
@@ -526,7 +530,11 @@ openclaw pairing list feishu
|
||||
channels: {
|
||||
feishu: {
|
||||
streaming: true, // 启用流式卡片输出(默认 true)
|
||||
blockStreaming: true, // 启用块级流式(默认 true)
|
||||
blockStreamingCoalesce: {
|
||||
enabled: true,
|
||||
minDelayMs: 50,
|
||||
maxDelayMs: 250,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -534,6 +542,40 @@ openclaw pairing list feishu
|
||||
|
||||
如需禁用流式输出(等待完整回复后一次性发送),可设置 `streaming: false`。
|
||||
|
||||
### 交互式卡片
|
||||
|
||||
OpenClaw 默认会在需要时发送 Markdown 卡片;如果你需要完整的 Feishu 原生交互式卡片,也可以显式发送原始 `card` payload。
|
||||
|
||||
- 默认路径:文本自动渲染或 Markdown 卡片
|
||||
- 显式卡片:通过消息动作的 `card` 参数发送原始交互卡片
|
||||
- 更新卡片:同一消息支持后续 patch/update
|
||||
|
||||
卡片按钮回调当前走文本回退路径:
|
||||
|
||||
- 若 `action.value.text` 存在,则作为入站文本继续处理
|
||||
- 若 `action.value.command` 存在,则作为命令文本继续处理
|
||||
- 其他对象值会序列化为 JSON 文本
|
||||
|
||||
这样可以保持与现有消息/命令路由兼容,而不要求下游先理解 Feishu 专有的交互 payload。
|
||||
|
||||
### 表情反应
|
||||
|
||||
飞书渠道现已完整支持表情反应生命周期:
|
||||
|
||||
- 接收 `reaction created`
|
||||
- 接收 `reaction deleted`
|
||||
- 主动添加反应
|
||||
- 主动删除自身反应
|
||||
- 查询消息上的反应列表
|
||||
|
||||
是否把入站反应转成内部消息,可通过 `reactionNotifications` 控制:
|
||||
|
||||
| 值 | 行为 |
|
||||
| ----- | ---------------------------- |
|
||||
| `off` | 不生成反应通知 |
|
||||
| `own` | 仅当反应发生在机器人消息上时 |
|
||||
| `all` | 所有可验证的反应都生成通知 |
|
||||
|
||||
### 消息引用
|
||||
|
||||
在群聊中,机器人的回复可以引用用户发送的原始消息,让对话上下文更加清晰。
|
||||
@@ -653,14 +695,19 @@ openclaw pairing list feishu
|
||||
| `channels.feishu.accounts.<id>.domain` | 单账号 API 域名覆盖 | `feishu` |
|
||||
| `channels.feishu.dmPolicy` | 私聊策略 | `pairing` |
|
||||
| `channels.feishu.allowFrom` | 私聊白名单(open_id 列表) | - |
|
||||
| `channels.feishu.groupPolicy` | 群组策略 | `open` |
|
||||
| `channels.feishu.groupPolicy` | 群组策略 | `allowlist` |
|
||||
| `channels.feishu.groupAllowFrom` | 群组白名单 | - |
|
||||
| `channels.feishu.groups.<chat_id>.requireMention` | 是否需要 @提及 | `true` |
|
||||
| `channels.feishu.groups.<chat_id>.enabled` | 是否启用该群组 | `true` |
|
||||
| `channels.feishu.replyInThread` | 群聊回复是否进入飞书话题线程 | `disabled` |
|
||||
| `channels.feishu.groupSessionScope` | 群聊会话隔离粒度 | `group` |
|
||||
| `channels.feishu.textChunkLimit` | 消息分块大小 | `2000` |
|
||||
| `channels.feishu.mediaMaxMb` | 媒体大小限制 | `30` |
|
||||
| `channels.feishu.streaming` | 启用流式卡片输出 | `true` |
|
||||
| `channels.feishu.blockStreaming` | 启用块级流式 | `true` |
|
||||
| `channels.feishu.blockStreamingCoalesce.enabled` | 启用块级流式合并 | `true` |
|
||||
| `channels.feishu.typingIndicator` | 发送“正在输入”状态 | `true` |
|
||||
| `channels.feishu.resolveSenderNames` | 拉取发送者名称 | `true` |
|
||||
| `channels.feishu.reactionNotifications` | 入站反应通知策略 | `own` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -57,6 +57,10 @@ export type BlueBubblesAccountConfig = {
|
||||
allowPrivateNetwork?: boolean;
|
||||
/** Per-group configuration keyed by chat GUID or identifier. */
|
||||
groups?: Record<string, BlueBubblesGroupConfig>;
|
||||
/** Channel health monitor overrides for this channel/account. */
|
||||
healthMonitor?: {
|
||||
enabled?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type BlueBubblesActionConfig = {
|
||||
|
||||
@@ -407,7 +407,12 @@ export default function register(api: OpenClawPluginApi) {
|
||||
|
||||
const payload: SetupPayload = {
|
||||
url: urlResult.url,
|
||||
bootstrapToken: (await issueDeviceBootstrapToken()).token,
|
||||
bootstrapToken: (
|
||||
await issueDeviceBootstrapToken({
|
||||
role: "node",
|
||||
scopes: [],
|
||||
})
|
||||
).token,
|
||||
};
|
||||
|
||||
if (action === "qr") {
|
||||
|
||||
@@ -80,18 +80,6 @@ describe("diffs plugin registration", () => {
|
||||
registerHttpRoute(params: RegisteredHttpRouteParams) {
|
||||
registeredHttpRouteHandler = params.handler;
|
||||
},
|
||||
registerChannel() {},
|
||||
registerGatewayMethod() {},
|
||||
registerCli() {},
|
||||
registerService() {},
|
||||
registerProvider() {},
|
||||
registerSearchProvider() {},
|
||||
registerCommand() {},
|
||||
registerContextEngine() {},
|
||||
resolvePath(input: string) {
|
||||
return input;
|
||||
},
|
||||
on() {},
|
||||
});
|
||||
|
||||
plugin.register?.(api as unknown as OpenClawPluginApi);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
|
||||
import {
|
||||
readNumberParam,
|
||||
readStringArrayParam,
|
||||
@@ -9,6 +8,7 @@ import { readDiscordParentIdParam } from "../../../../src/agents/tools/discord-a
|
||||
import { handleDiscordAction } from "../../../../src/agents/tools/discord-actions.js";
|
||||
import { resolveReactionMessageId } from "../../../../src/channels/plugins/actions/reaction-message-id.js";
|
||||
import type { ChannelMessageActionContext } from "../../../../src/channels/plugins/types.js";
|
||||
import { readBooleanParam } from "../../../../src/plugin-sdk/boolean-param.js";
|
||||
import { resolveDiscordChannelId } from "../targets.js";
|
||||
import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js";
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Guild, User } from "@buape/carbon";
|
||||
import { evaluateGroupRouteAccessForPolicy } from "openclaw/plugin-sdk/group-access";
|
||||
import type { AllowlistMatch } from "../../../../src/channels/allowlist-match.js";
|
||||
import {
|
||||
buildChannelKeyCandidates,
|
||||
@@ -7,6 +6,7 @@ import {
|
||||
resolveChannelMatchConfig,
|
||||
type ChannelMatchSource,
|
||||
} from "../../../../src/channels/channel-config.js";
|
||||
import { evaluateGroupRouteAccessForPolicy } from "../../../../src/plugin-sdk/group-access.js";
|
||||
import { formatDiscordUserTag } from "./format.js";
|
||||
|
||||
export type DiscordAllowList = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
|
||||
import { createRunStateMachine } from "../../../../src/channels/run-state-machine.js";
|
||||
import { danger } from "../../../../src/globals.js";
|
||||
import { formatDurationSeconds } from "../../../../src/infra/format-time/format-duration.ts";
|
||||
import { KeyedAsyncQueue } from "../../../../src/plugin-sdk/keyed-async-queue.js";
|
||||
import { materializeDiscordInboundJob, type DiscordInboundJob } from "./inbound-job.js";
|
||||
import type { RuntimeEnv } from "./message-handler.preflight.types.js";
|
||||
import { processDiscordMessage } from "./message-handler.process.js";
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { readJsonFileWithFallback, writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store";
|
||||
import { normalizeProviderId } from "../../../../src/agents/model-selection.js";
|
||||
import { resolveStateDir } from "../../../../src/config/paths.js";
|
||||
import { withFileLock } from "../../../../src/infra/file-lock.js";
|
||||
import { resolveRequiredHomeDir } from "../../../../src/infra/home-dir.js";
|
||||
import {
|
||||
readJsonFileWithFallback,
|
||||
writeJsonFileAtomically,
|
||||
} from "../../../../src/plugin-sdk/json-store.js";
|
||||
import { normalizeAccountId as normalizeSharedAccountId } from "../../../../src/routing/account-id.js";
|
||||
|
||||
const MODEL_PICKER_PREFERENCES_LOCK_OPTIONS = {
|
||||
|
||||
@@ -15,9 +15,12 @@ const {
|
||||
mockCreateFeishuReplyDispatcher,
|
||||
mockSendMessageFeishu,
|
||||
mockGetMessageFeishu,
|
||||
mockListFeishuThreadMessages,
|
||||
mockDownloadMessageResourceFeishu,
|
||||
mockCreateFeishuClient,
|
||||
mockResolveAgentRoute,
|
||||
mockReadSessionUpdatedAt,
|
||||
mockResolveStorePath,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCreateFeishuReplyDispatcher: vi.fn(() => ({
|
||||
dispatcher: vi.fn(),
|
||||
@@ -26,6 +29,7 @@ const {
|
||||
})),
|
||||
mockSendMessageFeishu: vi.fn().mockResolvedValue({ messageId: "pairing-msg", chatId: "oc-dm" }),
|
||||
mockGetMessageFeishu: vi.fn().mockResolvedValue(null),
|
||||
mockListFeishuThreadMessages: vi.fn().mockResolvedValue([]),
|
||||
mockDownloadMessageResourceFeishu: vi.fn().mockResolvedValue({
|
||||
buffer: Buffer.from("video"),
|
||||
contentType: "video/mp4",
|
||||
@@ -40,6 +44,8 @@ const {
|
||||
mainSessionKey: "agent:main:main",
|
||||
matchedBy: "default",
|
||||
})),
|
||||
mockReadSessionUpdatedAt: vi.fn(),
|
||||
mockResolveStorePath: vi.fn(() => "/tmp/feishu-sessions.json"),
|
||||
}));
|
||||
|
||||
vi.mock("./reply-dispatcher.js", () => ({
|
||||
@@ -49,6 +55,7 @@ vi.mock("./reply-dispatcher.js", () => ({
|
||||
vi.mock("./send.js", () => ({
|
||||
sendMessageFeishu: mockSendMessageFeishu,
|
||||
getMessageFeishu: mockGetMessageFeishu,
|
||||
listFeishuThreadMessages: mockListFeishuThreadMessages,
|
||||
}));
|
||||
|
||||
vi.mock("./media.js", () => ({
|
||||
@@ -70,11 +77,13 @@ function createRuntimeEnv(): RuntimeEnv {
|
||||
}
|
||||
|
||||
async function dispatchMessage(params: { cfg: ClawdbotConfig; event: FeishuMessageEvent }) {
|
||||
const runtime = createRuntimeEnv();
|
||||
await handleFeishuMessage({
|
||||
cfg: params.cfg,
|
||||
event: params.event,
|
||||
runtime: createRuntimeEnv(),
|
||||
runtime,
|
||||
});
|
||||
return runtime;
|
||||
}
|
||||
|
||||
describe("buildFeishuAgentBody", () => {
|
||||
@@ -140,6 +149,10 @@ describe("handleFeishuMessage command authorization", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockShouldComputeCommandAuthorized.mockReset().mockReturnValue(true);
|
||||
mockGetMessageFeishu.mockReset().mockResolvedValue(null);
|
||||
mockListFeishuThreadMessages.mockReset().mockResolvedValue([]);
|
||||
mockReadSessionUpdatedAt.mockReturnValue(undefined);
|
||||
mockResolveStorePath.mockReturnValue("/tmp/feishu-sessions.json");
|
||||
mockResolveAgentRoute.mockReturnValue({
|
||||
agentId: "main",
|
||||
channel: "feishu",
|
||||
@@ -166,6 +179,12 @@ describe("handleFeishuMessage command authorization", () => {
|
||||
resolveAgentRoute:
|
||||
mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
|
||||
},
|
||||
session: {
|
||||
readSessionUpdatedAt:
|
||||
mockReadSessionUpdatedAt as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"],
|
||||
resolveStorePath:
|
||||
mockResolveStorePath as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"],
|
||||
},
|
||||
reply: {
|
||||
resolveEnvelopeFormatOptions: vi.fn(
|
||||
() => ({}),
|
||||
@@ -1709,6 +1728,193 @@ describe("handleFeishuMessage command authorization", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("bootstraps topic thread context only for a new thread session", async () => {
|
||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||
mockGetMessageFeishu.mockResolvedValue({
|
||||
messageId: "om_topic_root",
|
||||
chatId: "oc-group",
|
||||
content: "root starter",
|
||||
contentType: "text",
|
||||
threadId: "omt_topic_1",
|
||||
});
|
||||
mockListFeishuThreadMessages.mockResolvedValue([
|
||||
{
|
||||
messageId: "om_bot_reply",
|
||||
senderId: "app_1",
|
||||
senderType: "app",
|
||||
content: "assistant reply",
|
||||
contentType: "text",
|
||||
createTime: 1710000000000,
|
||||
},
|
||||
{
|
||||
messageId: "om_follow_up",
|
||||
senderId: "ou-topic-user",
|
||||
senderType: "user",
|
||||
content: "follow-up question",
|
||||
contentType: "text",
|
||||
createTime: 1710000001000,
|
||||
},
|
||||
]);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
groups: {
|
||||
"oc-group": {
|
||||
requireMention: false,
|
||||
groupSessionScope: "group_topic",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const event: FeishuMessageEvent = {
|
||||
sender: { sender_id: { open_id: "ou-topic-user" } },
|
||||
message: {
|
||||
message_id: "om_topic_followup_existing_session",
|
||||
root_id: "om_topic_root",
|
||||
chat_id: "oc-group",
|
||||
chat_type: "group",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "current turn" }),
|
||||
},
|
||||
};
|
||||
|
||||
await dispatchMessage({ cfg, event });
|
||||
|
||||
expect(mockReadSessionUpdatedAt).toHaveBeenCalledWith({
|
||||
storePath: "/tmp/feishu-sessions.json",
|
||||
sessionKey: "agent:main:feishu:dm:ou-attacker",
|
||||
});
|
||||
expect(mockListFeishuThreadMessages).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rootMessageId: "om_topic_root",
|
||||
}),
|
||||
);
|
||||
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ThreadStarterBody: "root starter",
|
||||
ThreadHistoryBody: "assistant reply\n\nfollow-up question",
|
||||
ThreadLabel: "Feishu thread in oc-group",
|
||||
MessageThreadId: "om_topic_root",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips topic thread bootstrap when the thread session already exists", async () => {
|
||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||
mockReadSessionUpdatedAt.mockReturnValue(1710000000000);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
groups: {
|
||||
"oc-group": {
|
||||
requireMention: false,
|
||||
groupSessionScope: "group_topic",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const event: FeishuMessageEvent = {
|
||||
sender: { sender_id: { open_id: "ou-topic-user" } },
|
||||
message: {
|
||||
message_id: "om_topic_followup",
|
||||
root_id: "om_topic_root",
|
||||
chat_id: "oc-group",
|
||||
chat_type: "group",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "current turn" }),
|
||||
},
|
||||
};
|
||||
|
||||
await dispatchMessage({ cfg, event });
|
||||
|
||||
expect(mockGetMessageFeishu).not.toHaveBeenCalled();
|
||||
expect(mockListFeishuThreadMessages).not.toHaveBeenCalled();
|
||||
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ThreadStarterBody: undefined,
|
||||
ThreadHistoryBody: undefined,
|
||||
ThreadLabel: "Feishu thread in oc-group",
|
||||
MessageThreadId: "om_topic_root",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps sender-scoped thread history when the inbound event and thread history use different sender ids", async () => {
|
||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||
mockGetMessageFeishu.mockResolvedValue({
|
||||
messageId: "om_topic_root",
|
||||
chatId: "oc-group",
|
||||
content: "root starter",
|
||||
contentType: "text",
|
||||
threadId: "omt_topic_1",
|
||||
});
|
||||
mockListFeishuThreadMessages.mockResolvedValue([
|
||||
{
|
||||
messageId: "om_bot_reply",
|
||||
senderId: "app_1",
|
||||
senderType: "app",
|
||||
content: "assistant reply",
|
||||
contentType: "text",
|
||||
createTime: 1710000000000,
|
||||
},
|
||||
{
|
||||
messageId: "om_follow_up",
|
||||
senderId: "user_topic_1",
|
||||
senderType: "user",
|
||||
content: "follow-up question",
|
||||
contentType: "text",
|
||||
createTime: 1710000001000,
|
||||
},
|
||||
]);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
groups: {
|
||||
"oc-group": {
|
||||
requireMention: false,
|
||||
groupSessionScope: "group_topic_sender",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const event: FeishuMessageEvent = {
|
||||
sender: {
|
||||
sender_id: {
|
||||
open_id: "ou-topic-user",
|
||||
user_id: "user_topic_1",
|
||||
},
|
||||
},
|
||||
message: {
|
||||
message_id: "om_topic_followup_mixed_ids",
|
||||
root_id: "om_topic_root",
|
||||
chat_id: "oc-group",
|
||||
chat_type: "group",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "current turn" }),
|
||||
},
|
||||
};
|
||||
|
||||
await dispatchMessage({ cfg, event });
|
||||
|
||||
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ThreadStarterBody: "root starter",
|
||||
ThreadHistoryBody: "assistant reply\n\nfollow-up question",
|
||||
ThreadLabel: "Feishu thread in oc-group",
|
||||
MessageThreadId: "om_topic_root",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not dispatch twice for the same image message_id (concurrent dedupe)", async () => {
|
||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
issuePairingChallenge,
|
||||
normalizeAgentId,
|
||||
recordPendingHistoryEntryIfEnabled,
|
||||
resolveAgentOutboundIdentity,
|
||||
resolveOpenProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
@@ -29,7 +30,7 @@ import {
|
||||
import { parsePostContent } from "./post.js";
|
||||
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
|
||||
import { getFeishuRuntime } from "./runtime.js";
|
||||
import { getMessageFeishu, sendMessageFeishu } from "./send.js";
|
||||
import { getMessageFeishu, listFeishuThreadMessages, sendMessageFeishu } from "./send.js";
|
||||
import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js";
|
||||
import type { DynamicAgentCreationConfig } from "./types.js";
|
||||
|
||||
@@ -1239,16 +1240,17 @@ export async function handleFeishuMessage(params: {
|
||||
const mediaPayload = buildAgentMediaPayload(mediaList);
|
||||
|
||||
// Fetch quoted/replied message content if parentId exists
|
||||
let quotedMessageInfo: Awaited<ReturnType<typeof getMessageFeishu>> = null;
|
||||
let quotedContent: string | undefined;
|
||||
if (ctx.parentId) {
|
||||
try {
|
||||
const quotedMsg = await getMessageFeishu({
|
||||
quotedMessageInfo = await getMessageFeishu({
|
||||
cfg,
|
||||
messageId: ctx.parentId,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
if (quotedMsg) {
|
||||
quotedContent = quotedMsg.content;
|
||||
if (quotedMessageInfo) {
|
||||
quotedContent = quotedMessageInfo.content;
|
||||
log(
|
||||
`feishu[${account.accountId}]: fetched quoted message: ${quotedContent?.slice(0, 100)}`,
|
||||
);
|
||||
@@ -1258,6 +1260,11 @@ export async function handleFeishuMessage(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const isTopicSessionForThread =
|
||||
isGroup &&
|
||||
(groupSession?.groupSessionScope === "group_topic" ||
|
||||
groupSession?.groupSessionScope === "group_topic_sender");
|
||||
|
||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
||||
const messageBody = buildFeishuAgentBody({
|
||||
ctx,
|
||||
@@ -1309,13 +1316,150 @@ export async function handleFeishuMessage(params: {
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
const threadContextBySessionKey = new Map<
|
||||
string,
|
||||
{
|
||||
threadStarterBody?: string;
|
||||
threadHistoryBody?: string;
|
||||
threadLabel?: string;
|
||||
}
|
||||
>();
|
||||
let rootMessageInfo: Awaited<ReturnType<typeof getMessageFeishu>> | undefined;
|
||||
let rootMessageFetched = false;
|
||||
const getRootMessageInfo = async () => {
|
||||
if (!ctx.rootId) {
|
||||
return null;
|
||||
}
|
||||
if (!rootMessageFetched) {
|
||||
rootMessageFetched = true;
|
||||
if (ctx.rootId === ctx.parentId && quotedMessageInfo) {
|
||||
rootMessageInfo = quotedMessageInfo;
|
||||
} else {
|
||||
try {
|
||||
rootMessageInfo = await getMessageFeishu({
|
||||
cfg,
|
||||
messageId: ctx.rootId,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
} catch (err) {
|
||||
log(`feishu[${account.accountId}]: failed to fetch root message: ${String(err)}`);
|
||||
rootMessageInfo = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return rootMessageInfo ?? null;
|
||||
};
|
||||
const resolveThreadContextForAgent = async (agentId: string, agentSessionKey: string) => {
|
||||
const cached = threadContextBySessionKey.get(agentSessionKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const threadContext: {
|
||||
threadStarterBody?: string;
|
||||
threadHistoryBody?: string;
|
||||
threadLabel?: string;
|
||||
} = {
|
||||
threadLabel:
|
||||
(ctx.rootId || ctx.threadId) && isTopicSessionForThread
|
||||
? `Feishu thread in ${ctx.chatId}`
|
||||
: undefined,
|
||||
};
|
||||
|
||||
if (!(ctx.rootId || ctx.threadId) || !isTopicSessionForThread) {
|
||||
threadContextBySessionKey.set(agentSessionKey, threadContext);
|
||||
return threadContext;
|
||||
}
|
||||
|
||||
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { agentId });
|
||||
const previousThreadSessionTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: agentSessionKey,
|
||||
});
|
||||
if (previousThreadSessionTimestamp) {
|
||||
log(
|
||||
`feishu[${account.accountId}]: skipping thread bootstrap for existing session ${agentSessionKey}`,
|
||||
);
|
||||
threadContextBySessionKey.set(agentSessionKey, threadContext);
|
||||
return threadContext;
|
||||
}
|
||||
|
||||
const rootMsg = await getRootMessageInfo();
|
||||
let feishuThreadId = ctx.threadId ?? rootMsg?.threadId;
|
||||
if (feishuThreadId) {
|
||||
log(`feishu[${account.accountId}]: resolved thread ID: ${feishuThreadId}`);
|
||||
}
|
||||
if (!feishuThreadId) {
|
||||
log(
|
||||
`feishu[${account.accountId}]: no threadId found for root message ${ctx.rootId ?? "none"}, skipping thread history`,
|
||||
);
|
||||
threadContextBySessionKey.set(agentSessionKey, threadContext);
|
||||
return threadContext;
|
||||
}
|
||||
|
||||
try {
|
||||
const threadMessages = await listFeishuThreadMessages({
|
||||
cfg,
|
||||
threadId: feishuThreadId,
|
||||
currentMessageId: ctx.messageId,
|
||||
rootMessageId: ctx.rootId,
|
||||
limit: 20,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const senderScoped = groupSession?.groupSessionScope === "group_topic_sender";
|
||||
const senderIds = new Set(
|
||||
[ctx.senderOpenId, senderUserId]
|
||||
.map((id) => id?.trim())
|
||||
.filter((id): id is string => id !== undefined && id.length > 0),
|
||||
);
|
||||
const relevantMessages =
|
||||
(senderScoped
|
||||
? threadMessages.filter(
|
||||
(msg) =>
|
||||
msg.senderType === "app" ||
|
||||
(msg.senderId !== undefined && senderIds.has(msg.senderId.trim())),
|
||||
)
|
||||
: threadMessages) ?? [];
|
||||
|
||||
const threadStarterBody = rootMsg?.content ?? relevantMessages[0]?.content;
|
||||
const includeStarterInHistory = Boolean(rootMsg?.content || ctx.rootId);
|
||||
const historyMessages = includeStarterInHistory
|
||||
? relevantMessages
|
||||
: relevantMessages.slice(1);
|
||||
const historyParts = historyMessages.map((msg) => {
|
||||
const role = msg.senderType === "app" ? "assistant" : "user";
|
||||
return core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Feishu",
|
||||
from: `${msg.senderId ?? "Unknown"} (${role})`,
|
||||
timestamp: msg.createTime,
|
||||
body: msg.content,
|
||||
envelope: envelopeOptions,
|
||||
});
|
||||
});
|
||||
|
||||
threadContext.threadStarterBody = threadStarterBody;
|
||||
threadContext.threadHistoryBody =
|
||||
historyParts.length > 0 ? historyParts.join("\n\n") : undefined;
|
||||
log(
|
||||
`feishu[${account.accountId}]: populated thread bootstrap with starter=${threadStarterBody ? "yes" : "no"} history=${historyMessages.length}`,
|
||||
);
|
||||
} catch (err) {
|
||||
log(`feishu[${account.accountId}]: failed to fetch thread history: ${String(err)}`);
|
||||
}
|
||||
|
||||
threadContextBySessionKey.set(agentSessionKey, threadContext);
|
||||
return threadContext;
|
||||
};
|
||||
|
||||
// --- Shared context builder for dispatch ---
|
||||
const buildCtxPayloadForAgent = (
|
||||
const buildCtxPayloadForAgent = async (
|
||||
agentId: string,
|
||||
agentSessionKey: string,
|
||||
agentAccountId: string,
|
||||
wasMentioned: boolean,
|
||||
) =>
|
||||
core.channel.reply.finalizeInboundContext({
|
||||
) => {
|
||||
const threadContext = await resolveThreadContextForAgent(agentId, agentSessionKey);
|
||||
return core.channel.reply.finalizeInboundContext({
|
||||
Body: combinedBody,
|
||||
BodyForAgent: messageBody,
|
||||
InboundHistory: inboundHistory,
|
||||
@@ -1335,6 +1479,12 @@ export async function handleFeishuMessage(params: {
|
||||
Surface: "feishu" as const,
|
||||
MessageSid: ctx.messageId,
|
||||
ReplyToBody: quotedContent ?? undefined,
|
||||
ThreadStarterBody: threadContext.threadStarterBody,
|
||||
ThreadHistoryBody: threadContext.threadHistoryBody,
|
||||
ThreadLabel: threadContext.threadLabel,
|
||||
// Only use rootId (om_* message anchor) — threadId (omt_*) is a container
|
||||
// ID and would produce invalid reply targets downstream.
|
||||
MessageThreadId: ctx.rootId && isTopicSessionForThread ? ctx.rootId : undefined,
|
||||
Timestamp: Date.now(),
|
||||
WasMentioned: wasMentioned,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
@@ -1343,6 +1493,7 @@ export async function handleFeishuMessage(params: {
|
||||
GroupSystemPrompt: isGroup ? groupConfig?.systemPrompt?.trim() || undefined : undefined,
|
||||
...mediaPayload,
|
||||
});
|
||||
};
|
||||
|
||||
// Parse message create_time (Feishu uses millisecond epoch string).
|
||||
const messageCreateTimeMs = event.message.create_time
|
||||
@@ -1402,7 +1553,8 @@ export async function handleFeishuMessage(params: {
|
||||
}
|
||||
|
||||
const agentSessionKey = buildBroadcastSessionKey(route.sessionKey, route.agentId, agentId);
|
||||
const agentCtx = buildCtxPayloadForAgent(
|
||||
const agentCtx = await buildCtxPayloadForAgent(
|
||||
agentId,
|
||||
agentSessionKey,
|
||||
route.accountId,
|
||||
ctx.mentionedBot && agentId === activeAgentId,
|
||||
@@ -1410,6 +1562,7 @@ export async function handleFeishuMessage(params: {
|
||||
|
||||
if (agentId === activeAgentId) {
|
||||
// Active agent: real Feishu dispatcher (responds on Feishu)
|
||||
const identity = resolveAgentOutboundIdentity(cfg, agentId);
|
||||
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
|
||||
cfg,
|
||||
agentId,
|
||||
@@ -1422,6 +1575,7 @@ export async function handleFeishuMessage(params: {
|
||||
threadReply,
|
||||
mentionTargets: ctx.mentionTargets,
|
||||
accountId: account.accountId,
|
||||
identity,
|
||||
messageCreateTimeMs,
|
||||
});
|
||||
|
||||
@@ -1502,12 +1656,14 @@ export async function handleFeishuMessage(params: {
|
||||
);
|
||||
} else {
|
||||
// --- Single-agent dispatch (existing behavior) ---
|
||||
const ctxPayload = buildCtxPayloadForAgent(
|
||||
const ctxPayload = await buildCtxPayloadForAgent(
|
||||
route.agentId,
|
||||
route.sessionKey,
|
||||
route.accountId,
|
||||
ctx.mentionedBot,
|
||||
);
|
||||
|
||||
const identity = resolveAgentOutboundIdentity(cfg, route.agentId);
|
||||
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
@@ -1520,6 +1676,7 @@ export async function handleFeishuMessage(params: {
|
||||
threadReply,
|
||||
mentionTargets: ctx.mentionTargets,
|
||||
accountId: account.accountId,
|
||||
identity,
|
||||
messageCreateTimeMs,
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,20 @@ export type FeishuCardActionEvent = {
|
||||
};
|
||||
};
|
||||
|
||||
function buildCardActionTextFallback(event: FeishuCardActionEvent): string {
|
||||
const actionValue = event.action.value;
|
||||
if (typeof actionValue === "object" && actionValue !== null) {
|
||||
if ("text" in actionValue && typeof actionValue.text === "string") {
|
||||
return actionValue.text;
|
||||
}
|
||||
if ("command" in actionValue && typeof actionValue.command === "string") {
|
||||
return actionValue.command;
|
||||
}
|
||||
return JSON.stringify(actionValue);
|
||||
}
|
||||
return String(actionValue);
|
||||
}
|
||||
|
||||
export async function handleFeishuCardAction(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
event: FeishuCardActionEvent;
|
||||
@@ -30,21 +44,7 @@ export async function handleFeishuCardAction(params: {
|
||||
const { cfg, event, runtime, accountId } = params;
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
const log = runtime?.log ?? console.log;
|
||||
|
||||
// Extract action value
|
||||
const actionValue = event.action.value;
|
||||
let content = "";
|
||||
if (typeof actionValue === "object" && actionValue !== null) {
|
||||
if ("text" in actionValue && typeof actionValue.text === "string") {
|
||||
content = actionValue.text;
|
||||
} else if ("command" in actionValue && typeof actionValue.command === "string") {
|
||||
content = actionValue.command;
|
||||
} else {
|
||||
content = JSON.stringify(actionValue);
|
||||
}
|
||||
} else {
|
||||
content = String(actionValue);
|
||||
}
|
||||
const content = buildCardActionTextFallback(event);
|
||||
|
||||
// Construct a synthetic message event
|
||||
const messageEvent: FeishuMessageEvent = {
|
||||
|
||||
@@ -2,11 +2,18 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/feishu";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const probeFeishuMock = vi.hoisted(() => vi.fn());
|
||||
const listReactionsFeishuMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./probe.js", () => ({
|
||||
probeFeishu: probeFeishuMock,
|
||||
}));
|
||||
|
||||
vi.mock("./reactions.js", () => ({
|
||||
addReactionFeishu: vi.fn(),
|
||||
listReactionsFeishu: listReactionsFeishuMock,
|
||||
removeReactionFeishu: vi.fn(),
|
||||
}));
|
||||
|
||||
import { feishuPlugin } from "./channel.js";
|
||||
|
||||
describe("feishuPlugin.status.probeAccount", () => {
|
||||
@@ -46,3 +53,114 @@ describe("feishuPlugin.status.probeAccount", () => {
|
||||
expect(result).toMatchObject({ ok: true, appId: "cli_main" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("feishuPlugin actions", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
appId: "cli_main",
|
||||
appSecret: "secret_main",
|
||||
actions: {
|
||||
reactions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
it("does not advertise reactions when disabled via actions config", () => {
|
||||
const disabledCfg = {
|
||||
channels: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
appId: "cli_main",
|
||||
appSecret: "secret_main",
|
||||
actions: {
|
||||
reactions: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(feishuPlugin.actions?.listActions?.({ cfg: disabledCfg })).toEqual([]);
|
||||
});
|
||||
|
||||
it("advertises reactions when any enabled configured account allows them", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
defaultAccount: "main",
|
||||
actions: {
|
||||
reactions: false,
|
||||
},
|
||||
accounts: {
|
||||
main: {
|
||||
appId: "cli_main",
|
||||
appSecret: "secret_main",
|
||||
enabled: true,
|
||||
actions: {
|
||||
reactions: false,
|
||||
},
|
||||
},
|
||||
secondary: {
|
||||
appId: "cli_secondary",
|
||||
appSecret: "secret_secondary",
|
||||
enabled: true,
|
||||
actions: {
|
||||
reactions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(feishuPlugin.actions?.listActions?.({ cfg })).toEqual(["react", "reactions"]);
|
||||
});
|
||||
|
||||
it("requires clearAll=true before removing all bot reactions", async () => {
|
||||
await expect(
|
||||
feishuPlugin.actions?.handleAction?.({
|
||||
action: "react",
|
||||
params: { messageId: "om_msg1" },
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
} as never),
|
||||
).rejects.toThrow(
|
||||
"Emoji is required to add a Feishu reaction. Set clearAll=true to remove all bot reactions.",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws for unsupported Feishu send actions without card payload", async () => {
|
||||
await expect(
|
||||
feishuPlugin.actions?.handleAction?.({
|
||||
action: "send",
|
||||
params: { to: "chat:oc_group_1", message: "hello" },
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
} as never),
|
||||
).rejects.toThrow('Unsupported Feishu action: "send"');
|
||||
});
|
||||
|
||||
it("allows explicit clearAll=true when removing all bot reactions", async () => {
|
||||
listReactionsFeishuMock.mockResolvedValueOnce([
|
||||
{ reactionId: "r1", operatorType: "app" },
|
||||
{ reactionId: "r2", operatorType: "app" },
|
||||
]);
|
||||
|
||||
const result = await feishuPlugin.actions?.handleAction?.({
|
||||
action: "react",
|
||||
params: { messageId: "om_msg1", clearAll: true },
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
} as never);
|
||||
|
||||
expect(listReactionsFeishuMock).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
messageId: "om_msg1",
|
||||
accountId: undefined,
|
||||
});
|
||||
expect(result?.details).toMatchObject({ ok: true, removed: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,18 +5,23 @@ import {
|
||||
} from "openclaw/plugin-sdk/compat";
|
||||
import type { ChannelMeta, ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
|
||||
import {
|
||||
buildChannelConfigSchema,
|
||||
buildProbeChannelStatusSummary,
|
||||
createActionGate,
|
||||
buildRuntimeAccountStatusSnapshot,
|
||||
createDefaultChannelRuntimeState,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
} from "openclaw/plugin-sdk/feishu";
|
||||
import type { ChannelMessageActionName } from "openclaw/plugin-sdk/feishu";
|
||||
import {
|
||||
resolveFeishuAccount,
|
||||
resolveFeishuCredentials,
|
||||
listFeishuAccountIds,
|
||||
listEnabledFeishuAccounts,
|
||||
resolveDefaultFeishuAccountId,
|
||||
} from "./accounts.js";
|
||||
import { FeishuConfigSchema } from "./config-schema.js";
|
||||
import {
|
||||
listFeishuDirectoryPeers,
|
||||
listFeishuDirectoryGroups,
|
||||
@@ -27,7 +32,8 @@ import { feishuOnboardingAdapter } from "./onboarding.js";
|
||||
import { feishuOutbound } from "./outbound.js";
|
||||
import { resolveFeishuGroupToolPolicy } from "./policy.js";
|
||||
import { probeFeishu } from "./probe.js";
|
||||
import { sendMessageFeishu } from "./send.js";
|
||||
import { addReactionFeishu, listReactionsFeishu, removeReactionFeishu } from "./reactions.js";
|
||||
import { sendCardFeishu, sendMessageFeishu } from "./send.js";
|
||||
import { normalizeFeishuTarget, looksLikeFeishuId, formatFeishuTarget } from "./targets.js";
|
||||
import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js";
|
||||
|
||||
@@ -42,22 +48,6 @@ const meta: ChannelMeta = {
|
||||
order: 70,
|
||||
};
|
||||
|
||||
const secretInputJsonSchema = {
|
||||
oneOf: [
|
||||
{ type: "string" },
|
||||
{
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["source", "provider", "id"],
|
||||
properties: {
|
||||
source: { type: "string", enum: ["env", "file", "exec"] },
|
||||
provider: { type: "string", minLength: 1 },
|
||||
id: { type: "string", minLength: 1 },
|
||||
},
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
function setFeishuNamedAccountEnabled(
|
||||
cfg: ClawdbotConfig,
|
||||
accountId: string,
|
||||
@@ -82,6 +72,32 @@ function setFeishuNamedAccountEnabled(
|
||||
};
|
||||
}
|
||||
|
||||
function isFeishuReactionsActionEnabled(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
account: ResolvedFeishuAccount;
|
||||
}): boolean {
|
||||
if (!params.account.enabled || !params.account.configured) {
|
||||
return false;
|
||||
}
|
||||
const gate = createActionGate(
|
||||
(params.account.config.actions ??
|
||||
(params.cfg.channels?.feishu as { actions?: unknown } | undefined)?.actions) as Record<
|
||||
string,
|
||||
boolean | undefined
|
||||
>,
|
||||
);
|
||||
return gate("reactions");
|
||||
}
|
||||
|
||||
function areAnyFeishuReactionActionsEnabled(cfg: ClawdbotConfig): boolean {
|
||||
for (const account of listEnabledFeishuAccounts(cfg)) {
|
||||
if (isFeishuReactionsActionEnabled({ cfg, account })) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
||||
id: "feishu",
|
||||
meta: {
|
||||
@@ -120,69 +136,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
||||
stripPatterns: () => ['<at user_id="[^"]*">[^<]*</at>'],
|
||||
},
|
||||
reload: { configPrefixes: ["channels.feishu"] },
|
||||
configSchema: {
|
||||
schema: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
enabled: { type: "boolean" },
|
||||
defaultAccount: { type: "string" },
|
||||
appId: { type: "string" },
|
||||
appSecret: secretInputJsonSchema,
|
||||
encryptKey: secretInputJsonSchema,
|
||||
verificationToken: secretInputJsonSchema,
|
||||
domain: {
|
||||
oneOf: [
|
||||
{ type: "string", enum: ["feishu", "lark"] },
|
||||
{ type: "string", format: "uri", pattern: "^https://" },
|
||||
],
|
||||
},
|
||||
connectionMode: { type: "string", enum: ["websocket", "webhook"] },
|
||||
webhookPath: { type: "string" },
|
||||
webhookHost: { type: "string" },
|
||||
webhookPort: { type: "integer", minimum: 1 },
|
||||
dmPolicy: { type: "string", enum: ["open", "pairing", "allowlist"] },
|
||||
allowFrom: { type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }] } },
|
||||
groupPolicy: { type: "string", enum: ["open", "allowlist", "disabled"] },
|
||||
groupAllowFrom: {
|
||||
type: "array",
|
||||
items: { oneOf: [{ type: "string" }, { type: "number" }] },
|
||||
},
|
||||
requireMention: { type: "boolean" },
|
||||
groupSessionScope: {
|
||||
type: "string",
|
||||
enum: ["group", "group_sender", "group_topic", "group_topic_sender"],
|
||||
},
|
||||
topicSessionMode: { type: "string", enum: ["disabled", "enabled"] },
|
||||
replyInThread: { type: "string", enum: ["disabled", "enabled"] },
|
||||
historyLimit: { type: "integer", minimum: 0 },
|
||||
dmHistoryLimit: { type: "integer", minimum: 0 },
|
||||
textChunkLimit: { type: "integer", minimum: 1 },
|
||||
chunkMode: { type: "string", enum: ["length", "newline"] },
|
||||
mediaMaxMb: { type: "number", minimum: 0 },
|
||||
renderMode: { type: "string", enum: ["auto", "raw", "card"] },
|
||||
accounts: {
|
||||
type: "object",
|
||||
additionalProperties: {
|
||||
type: "object",
|
||||
properties: {
|
||||
enabled: { type: "boolean" },
|
||||
name: { type: "string" },
|
||||
appId: { type: "string" },
|
||||
appSecret: secretInputJsonSchema,
|
||||
encryptKey: secretInputJsonSchema,
|
||||
verificationToken: secretInputJsonSchema,
|
||||
domain: { type: "string", enum: ["feishu", "lark"] },
|
||||
connectionMode: { type: "string", enum: ["websocket", "webhook"] },
|
||||
webhookHost: { type: "string" },
|
||||
webhookPath: { type: "string" },
|
||||
webhookPort: { type: "integer", minimum: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
configSchema: buildChannelConfigSchema(FeishuConfigSchema),
|
||||
config: {
|
||||
listAccountIds: (cfg) => listFeishuAccountIds(cfg),
|
||||
resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }),
|
||||
@@ -255,6 +209,172 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
||||
},
|
||||
formatAllowFrom: ({ allowFrom }) => formatAllowFromLowercase({ allowFrom }),
|
||||
},
|
||||
actions: {
|
||||
listActions: ({ cfg }) => {
|
||||
if (listEnabledFeishuAccounts(cfg).length === 0) {
|
||||
return [];
|
||||
}
|
||||
const actions = new Set<ChannelMessageActionName>();
|
||||
if (areAnyFeishuReactionActionsEnabled(cfg)) {
|
||||
actions.add("react");
|
||||
actions.add("reactions");
|
||||
}
|
||||
return Array.from(actions);
|
||||
},
|
||||
supportsCards: ({ cfg }) => {
|
||||
return (
|
||||
cfg.channels?.feishu?.enabled !== false &&
|
||||
Boolean(resolveFeishuCredentials(cfg.channels?.feishu as FeishuConfig | undefined))
|
||||
);
|
||||
},
|
||||
handleAction: async (ctx) => {
|
||||
const account = resolveFeishuAccount({ cfg: ctx.cfg, accountId: ctx.accountId ?? undefined });
|
||||
if (
|
||||
(ctx.action === "react" || ctx.action === "reactions") &&
|
||||
!isFeishuReactionsActionEnabled({ cfg: ctx.cfg, account })
|
||||
) {
|
||||
throw new Error("Feishu reactions are disabled via actions.reactions.");
|
||||
}
|
||||
if (ctx.action === "send" && ctx.params.card) {
|
||||
const card = ctx.params.card as Record<string, unknown>;
|
||||
const to =
|
||||
typeof ctx.params.to === "string"
|
||||
? ctx.params.to.trim()
|
||||
: typeof ctx.params.target === "string"
|
||||
? ctx.params.target.trim()
|
||||
: "";
|
||||
if (!to) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: "text" as const, text: "Feishu card send requires a target (to)." }],
|
||||
details: { error: "Feishu card send requires a target (to)." },
|
||||
};
|
||||
}
|
||||
const replyToMessageId =
|
||||
typeof ctx.params.replyTo === "string"
|
||||
? ctx.params.replyTo.trim() || undefined
|
||||
: undefined;
|
||||
const result = await sendCardFeishu({
|
||||
cfg: ctx.cfg,
|
||||
to,
|
||||
card,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
replyToMessageId,
|
||||
});
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({ ok: true, channel: "feishu", ...result }),
|
||||
},
|
||||
],
|
||||
details: { ok: true, channel: "feishu", ...result },
|
||||
};
|
||||
}
|
||||
|
||||
if (ctx.action === "react") {
|
||||
const messageId =
|
||||
(typeof ctx.params.messageId === "string" && ctx.params.messageId.trim()) ||
|
||||
(typeof ctx.params.message_id === "string" && ctx.params.message_id.trim()) ||
|
||||
undefined;
|
||||
if (!messageId) {
|
||||
throw new Error("Feishu reaction requires messageId.");
|
||||
}
|
||||
const emoji = typeof ctx.params.emoji === "string" ? ctx.params.emoji.trim() : "";
|
||||
const remove = ctx.params.remove === true;
|
||||
const clearAll = ctx.params.clearAll === true;
|
||||
if (remove) {
|
||||
if (!emoji) {
|
||||
throw new Error("Emoji is required to remove a Feishu reaction.");
|
||||
}
|
||||
const matches = await listReactionsFeishu({
|
||||
cfg: ctx.cfg,
|
||||
messageId,
|
||||
emojiType: emoji,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
});
|
||||
const ownReaction = matches.find((entry) => entry.operatorType === "app");
|
||||
if (!ownReaction) {
|
||||
return {
|
||||
content: [
|
||||
{ type: "text" as const, text: JSON.stringify({ ok: true, removed: null }) },
|
||||
],
|
||||
details: { ok: true, removed: null },
|
||||
};
|
||||
}
|
||||
await removeReactionFeishu({
|
||||
cfg: ctx.cfg,
|
||||
messageId,
|
||||
reactionId: ownReaction.reactionId,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
});
|
||||
return {
|
||||
content: [
|
||||
{ type: "text" as const, text: JSON.stringify({ ok: true, removed: emoji }) },
|
||||
],
|
||||
details: { ok: true, removed: emoji },
|
||||
};
|
||||
}
|
||||
if (!emoji) {
|
||||
if (!clearAll) {
|
||||
throw new Error(
|
||||
"Emoji is required to add a Feishu reaction. Set clearAll=true to remove all bot reactions.",
|
||||
);
|
||||
}
|
||||
const reactions = await listReactionsFeishu({
|
||||
cfg: ctx.cfg,
|
||||
messageId,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
});
|
||||
let removed = 0;
|
||||
for (const reaction of reactions.filter((entry) => entry.operatorType === "app")) {
|
||||
await removeReactionFeishu({
|
||||
cfg: ctx.cfg,
|
||||
messageId,
|
||||
reactionId: reaction.reactionId,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
});
|
||||
removed += 1;
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, removed }) }],
|
||||
details: { ok: true, removed },
|
||||
};
|
||||
}
|
||||
await addReactionFeishu({
|
||||
cfg: ctx.cfg,
|
||||
messageId,
|
||||
emojiType: emoji,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, added: emoji }) }],
|
||||
details: { ok: true, added: emoji },
|
||||
};
|
||||
}
|
||||
|
||||
if (ctx.action === "reactions") {
|
||||
const messageId =
|
||||
(typeof ctx.params.messageId === "string" && ctx.params.messageId.trim()) ||
|
||||
(typeof ctx.params.message_id === "string" && ctx.params.message_id.trim()) ||
|
||||
undefined;
|
||||
if (!messageId) {
|
||||
throw new Error("Feishu reactions lookup requires messageId.");
|
||||
}
|
||||
const reactions = await listReactionsFeishu({
|
||||
cfg: ctx.cfg,
|
||||
messageId,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, reactions }) }],
|
||||
details: { ok: true, reactions },
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported Feishu action: "${String(ctx.action)}"`);
|
||||
},
|
||||
},
|
||||
security: {
|
||||
collectWarnings: ({ cfg, accountId }) => {
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
|
||||
@@ -217,6 +217,26 @@ describe("FeishuConfigSchema optimization flags", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("FeishuConfigSchema actions", () => {
|
||||
it("accepts top-level reactions action gate", () => {
|
||||
const result = FeishuConfigSchema.parse({
|
||||
actions: { reactions: false },
|
||||
});
|
||||
expect(result.actions?.reactions).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts account-level reactions action gate", () => {
|
||||
const result = FeishuConfigSchema.parse({
|
||||
accounts: {
|
||||
main: {
|
||||
actions: { reactions: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.accounts?.main?.actions?.reactions).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FeishuConfigSchema defaultAccount", () => {
|
||||
it("accepts defaultAccount when it matches an account key", () => {
|
||||
const result = FeishuConfigSchema.safeParse({
|
||||
|
||||
@@ -3,6 +3,13 @@ import { z } from "zod";
|
||||
export { z };
|
||||
import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js";
|
||||
|
||||
const ChannelActionsSchema = z
|
||||
.object({
|
||||
reactions: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
const DmPolicySchema = z.enum(["open", "pairing", "allowlist"]);
|
||||
const GroupPolicySchema = z.union([
|
||||
z.enum(["open", "allowlist", "disabled"]),
|
||||
@@ -170,6 +177,7 @@ const FeishuSharedConfigShape = {
|
||||
renderMode: RenderModeSchema,
|
||||
streaming: StreamingModeSchema,
|
||||
tools: FeishuToolsConfigSchema,
|
||||
actions: ChannelActionsSchema,
|
||||
replyInThread: ReplyInThreadSchema,
|
||||
reactionNotifications: ReactionNotificationModeSchema,
|
||||
typingIndicator: z.boolean().optional(),
|
||||
|
||||
@@ -38,6 +38,10 @@ export type FeishuReactionCreatedEvent = {
|
||||
action_time?: string;
|
||||
};
|
||||
|
||||
export type FeishuReactionDeletedEvent = FeishuReactionCreatedEvent & {
|
||||
reaction_id?: string;
|
||||
};
|
||||
|
||||
type ResolveReactionSyntheticEventParams = {
|
||||
cfg: ClawdbotConfig;
|
||||
accountId: string;
|
||||
@@ -47,6 +51,7 @@ type ResolveReactionSyntheticEventParams = {
|
||||
verificationTimeoutMs?: number;
|
||||
logger?: (message: string) => void;
|
||||
uuid?: () => string;
|
||||
action?: "created" | "deleted";
|
||||
};
|
||||
|
||||
export async function resolveReactionSyntheticEvent(
|
||||
@@ -61,6 +66,7 @@ export async function resolveReactionSyntheticEvent(
|
||||
verificationTimeoutMs = FEISHU_REACTION_VERIFY_TIMEOUT_MS,
|
||||
logger,
|
||||
uuid = () => crypto.randomUUID(),
|
||||
action = "created",
|
||||
} = params;
|
||||
|
||||
const emoji = event.reaction_type?.emoji_type;
|
||||
@@ -129,7 +135,10 @@ export async function resolveReactionSyntheticEvent(
|
||||
chat_type: syntheticChatType,
|
||||
message_type: "text",
|
||||
content: JSON.stringify({
|
||||
text: `[reacted with ${emoji} to message ${messageId}]`,
|
||||
text:
|
||||
action === "deleted"
|
||||
? `[removed reaction ${emoji} from message ${messageId}]`
|
||||
: `[reacted with ${emoji} to message ${messageId}]`,
|
||||
}),
|
||||
},
|
||||
};
|
||||
@@ -253,6 +262,19 @@ function registerEventHandlers(
|
||||
const log = runtime?.log ?? console.log;
|
||||
const error = runtime?.error ?? console.error;
|
||||
const enqueue = createChatQueue();
|
||||
const runFeishuHandler = async (params: { task: () => Promise<void>; errorMessage: string }) => {
|
||||
if (fireAndForget) {
|
||||
void params.task().catch((err) => {
|
||||
error(`${params.errorMessage}: ${String(err)}`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await params.task();
|
||||
} catch (err) {
|
||||
error(`${params.errorMessage}: ${String(err)}`);
|
||||
}
|
||||
};
|
||||
const dispatchFeishuMessage = async (event: FeishuMessageEvent) => {
|
||||
const chatId = event.message.chat_id?.trim() || "unknown";
|
||||
const task = () =>
|
||||
@@ -428,23 +450,102 @@ function registerEventHandlers(
|
||||
}
|
||||
},
|
||||
"im.message.reaction.created_v1": async (data) => {
|
||||
const processReaction = async () => {
|
||||
const event = data as FeishuReactionCreatedEvent;
|
||||
const myBotId = botOpenIds.get(accountId);
|
||||
const syntheticEvent = await resolveReactionSyntheticEvent({
|
||||
cfg,
|
||||
accountId,
|
||||
event,
|
||||
botOpenId: myBotId,
|
||||
logger: log,
|
||||
});
|
||||
if (!syntheticEvent) {
|
||||
await runFeishuHandler({
|
||||
errorMessage: `feishu[${accountId}]: error handling reaction event`,
|
||||
task: async () => {
|
||||
const event = data as FeishuReactionCreatedEvent;
|
||||
const myBotId = botOpenIds.get(accountId);
|
||||
const syntheticEvent = await resolveReactionSyntheticEvent({
|
||||
cfg,
|
||||
accountId,
|
||||
event,
|
||||
botOpenId: myBotId,
|
||||
logger: log,
|
||||
});
|
||||
if (!syntheticEvent) {
|
||||
return;
|
||||
}
|
||||
const promise = handleFeishuMessage({
|
||||
cfg,
|
||||
event: syntheticEvent,
|
||||
botOpenId: myBotId,
|
||||
botName: botNames.get(accountId),
|
||||
runtime,
|
||||
chatHistories,
|
||||
accountId,
|
||||
});
|
||||
await promise;
|
||||
},
|
||||
});
|
||||
},
|
||||
"im.message.reaction.deleted_v1": async (data) => {
|
||||
await runFeishuHandler({
|
||||
errorMessage: `feishu[${accountId}]: error handling reaction removal event`,
|
||||
task: async () => {
|
||||
const event = data as FeishuReactionDeletedEvent;
|
||||
const myBotId = botOpenIds.get(accountId);
|
||||
const syntheticEvent = await resolveReactionSyntheticEvent({
|
||||
cfg,
|
||||
accountId,
|
||||
event,
|
||||
botOpenId: myBotId,
|
||||
logger: log,
|
||||
action: "deleted",
|
||||
});
|
||||
if (!syntheticEvent) {
|
||||
return;
|
||||
}
|
||||
const promise = handleFeishuMessage({
|
||||
cfg,
|
||||
event: syntheticEvent,
|
||||
botOpenId: myBotId,
|
||||
botName: botNames.get(accountId),
|
||||
runtime,
|
||||
chatHistories,
|
||||
accountId,
|
||||
});
|
||||
await promise;
|
||||
},
|
||||
});
|
||||
},
|
||||
"application.bot.menu_v6": async (data) => {
|
||||
try {
|
||||
const event = data as {
|
||||
event_key?: string;
|
||||
timestamp?: number;
|
||||
operator?: {
|
||||
operator_name?: string;
|
||||
operator_id?: { open_id?: string; user_id?: string; union_id?: string };
|
||||
};
|
||||
};
|
||||
const operatorOpenId = event.operator?.operator_id?.open_id?.trim();
|
||||
const eventKey = event.event_key?.trim();
|
||||
if (!operatorOpenId || !eventKey) {
|
||||
return;
|
||||
}
|
||||
const syntheticEvent: FeishuMessageEvent = {
|
||||
sender: {
|
||||
sender_id: {
|
||||
open_id: operatorOpenId,
|
||||
user_id: event.operator?.operator_id?.user_id,
|
||||
union_id: event.operator?.operator_id?.union_id,
|
||||
},
|
||||
sender_type: "user",
|
||||
},
|
||||
message: {
|
||||
message_id: `bot-menu:${eventKey}:${event.timestamp ?? Date.now()}`,
|
||||
chat_id: `p2p:${operatorOpenId}`,
|
||||
chat_type: "p2p",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({
|
||||
text: `/menu ${eventKey}`,
|
||||
}),
|
||||
},
|
||||
};
|
||||
const promise = handleFeishuMessage({
|
||||
cfg,
|
||||
event: syntheticEvent,
|
||||
botOpenId: myBotId,
|
||||
botOpenId: botOpenIds.get(accountId),
|
||||
botName: botNames.get(accountId),
|
||||
runtime,
|
||||
chatHistories,
|
||||
@@ -452,29 +553,15 @@ function registerEventHandlers(
|
||||
});
|
||||
if (fireAndForget) {
|
||||
promise.catch((err) => {
|
||||
error(`feishu[${accountId}]: error handling reaction: ${String(err)}`);
|
||||
error(`feishu[${accountId}]: error handling bot menu event: ${String(err)}`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
await promise;
|
||||
};
|
||||
|
||||
if (fireAndForget) {
|
||||
void processReaction().catch((err) => {
|
||||
error(`feishu[${accountId}]: error handling reaction event: ${String(err)}`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await processReaction();
|
||||
} catch (err) {
|
||||
error(`feishu[${accountId}]: error handling reaction event: ${String(err)}`);
|
||||
error(`feishu[${accountId}]: error handling bot menu event: ${String(err)}`);
|
||||
}
|
||||
},
|
||||
"im.message.reaction.deleted_v1": async () => {
|
||||
// Ignore reaction removals
|
||||
},
|
||||
"card.action.trigger": async (data: unknown) => {
|
||||
try {
|
||||
const event = data as unknown as FeishuCardActionEvent;
|
||||
|
||||
67
extensions/feishu/src/monitor.reaction.lifecycle.test.ts
Normal file
67
extensions/feishu/src/monitor.reaction.lifecycle.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveReactionSyntheticEvent,
|
||||
type FeishuReactionCreatedEvent,
|
||||
} from "./monitor.account.js";
|
||||
|
||||
const cfg = {} as ClawdbotConfig;
|
||||
|
||||
function makeReactionEvent(
|
||||
overrides: Partial<FeishuReactionCreatedEvent> = {},
|
||||
): FeishuReactionCreatedEvent {
|
||||
return {
|
||||
message_id: "om_msg1",
|
||||
reaction_type: { emoji_type: "THUMBSUP" },
|
||||
operator_type: "user",
|
||||
user_id: { open_id: "ou_user1" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Feishu reaction lifecycle", () => {
|
||||
it("builds a created synthetic interaction payload", async () => {
|
||||
const result = await resolveReactionSyntheticEvent({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
event: makeReactionEvent(),
|
||||
botOpenId: "ou_bot",
|
||||
fetchMessage: async () => ({
|
||||
messageId: "om_msg1",
|
||||
chatId: "oc_group_1",
|
||||
chatType: "group",
|
||||
senderOpenId: "ou_bot",
|
||||
senderType: "app",
|
||||
content: "hello",
|
||||
contentType: "text",
|
||||
}),
|
||||
uuid: () => "fixed-uuid",
|
||||
});
|
||||
|
||||
expect(result?.message.content).toBe('{"text":"[reacted with THUMBSUP to message om_msg1]"}');
|
||||
});
|
||||
|
||||
it("builds a deleted synthetic interaction payload", async () => {
|
||||
const result = await resolveReactionSyntheticEvent({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
event: makeReactionEvent(),
|
||||
botOpenId: "ou_bot",
|
||||
fetchMessage: async () => ({
|
||||
messageId: "om_msg1",
|
||||
chatId: "oc_group_1",
|
||||
chatType: "group",
|
||||
senderOpenId: "ou_bot",
|
||||
senderType: "app",
|
||||
content: "hello",
|
||||
contentType: "text",
|
||||
}),
|
||||
uuid: () => "fixed-uuid",
|
||||
action: "deleted",
|
||||
});
|
||||
|
||||
expect(result?.message.content).toBe(
|
||||
'{"text":"[removed reaction THUMBSUP from message om_msg1]"}',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
|
||||
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
|
||||
const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
|
||||
const sendStructuredCardFeishuMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./media.js", () => ({
|
||||
sendMediaFeishu: sendMediaFeishuMock,
|
||||
@@ -14,6 +15,7 @@ vi.mock("./media.js", () => ({
|
||||
vi.mock("./send.js", () => ({
|
||||
sendMessageFeishu: sendMessageFeishuMock,
|
||||
sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
|
||||
sendStructuredCardFeishu: sendStructuredCardFeishuMock,
|
||||
}));
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
@@ -33,6 +35,7 @@ function resetOutboundMocks() {
|
||||
vi.clearAllMocks();
|
||||
sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
|
||||
sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
|
||||
sendStructuredCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
|
||||
sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
|
||||
}
|
||||
|
||||
@@ -132,7 +135,7 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
|
||||
accountId: "main",
|
||||
});
|
||||
|
||||
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
|
||||
expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "chat_1",
|
||||
text: "| a | b |\n| - | - |",
|
||||
@@ -207,7 +210,7 @@ describe("feishuOutbound.sendText replyToId forwarding", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards replyToId to sendMarkdownCardFeishu when renderMode=card", async () => {
|
||||
it("forwards replyToId to sendStructuredCardFeishu when renderMode=card", async () => {
|
||||
await sendText({
|
||||
cfg: {
|
||||
channels: {
|
||||
@@ -222,7 +225,7 @@ describe("feishuOutbound.sendText replyToId forwarding", () => {
|
||||
accountId: "main",
|
||||
});
|
||||
|
||||
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
|
||||
expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replyToMessageId: "om_reply_target",
|
||||
}),
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/feishu";
|
||||
import { resolveFeishuAccount } from "./accounts.js";
|
||||
import { sendMediaFeishu } from "./media.js";
|
||||
import { getFeishuRuntime } from "./runtime.js";
|
||||
import { sendMarkdownCardFeishu, sendMessageFeishu } from "./send.js";
|
||||
import { sendMarkdownCardFeishu, sendMessageFeishu, sendStructuredCardFeishu } from "./send.js";
|
||||
|
||||
function normalizePossibleLocalImagePath(text: string | undefined): string | null {
|
||||
const raw = text?.trim();
|
||||
@@ -81,7 +81,16 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
||||
chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
sendText: async ({ cfg, to, text, accountId, replyToId, threadId, mediaLocalRoots }) => {
|
||||
sendText: async ({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
accountId,
|
||||
replyToId,
|
||||
threadId,
|
||||
mediaLocalRoots,
|
||||
identity,
|
||||
}) => {
|
||||
const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId });
|
||||
// Scheme A compatibility shim:
|
||||
// when upstream accidentally returns a local image path as plain text,
|
||||
@@ -104,6 +113,29 @@ export const feishuOutbound: ChannelOutboundAdapter = {
|
||||
}
|
||||
}
|
||||
|
||||
const account = resolveFeishuAccount({ cfg, accountId: accountId ?? undefined });
|
||||
const renderMode = account.config?.renderMode ?? "auto";
|
||||
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
|
||||
if (useCard) {
|
||||
const header = identity
|
||||
? {
|
||||
title: identity.emoji
|
||||
? `${identity.emoji} ${identity.name ?? ""}`.trim()
|
||||
: (identity.name ?? ""),
|
||||
template: "blue" as const,
|
||||
}
|
||||
: undefined;
|
||||
const result = await sendStructuredCardFeishu({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
replyToMessageId,
|
||||
replyInThread: threadId != null && !replyToId,
|
||||
accountId: accountId ?? undefined,
|
||||
header: header?.title ? header : undefined,
|
||||
});
|
||||
return { channel: "feishu", ...result };
|
||||
}
|
||||
const result = await sendOutboundText({
|
||||
cfg,
|
||||
to,
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 sendStructuredCardFeishuMock = vi.hoisted(() => vi.fn());
|
||||
const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
|
||||
const createFeishuClientMock = vi.hoisted(() => vi.fn());
|
||||
const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
|
||||
@@ -17,6 +18,7 @@ vi.mock("./runtime.js", () => ({ getFeishuRuntime: getFeishuRuntimeMock }));
|
||||
vi.mock("./send.js", () => ({
|
||||
sendMessageFeishu: sendMessageFeishuMock,
|
||||
sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
|
||||
sendStructuredCardFeishu: sendStructuredCardFeishuMock,
|
||||
}));
|
||||
vi.mock("./media.js", () => ({ sendMediaFeishu: sendMediaFeishuMock }));
|
||||
vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock }));
|
||||
@@ -56,6 +58,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
vi.clearAllMocks();
|
||||
streamingInstances.length = 0;
|
||||
sendMediaFeishuMock.mockResolvedValue(undefined);
|
||||
sendStructuredCardFeishuMock.mockResolvedValue(undefined);
|
||||
|
||||
resolveFeishuAccountMock.mockReturnValue({
|
||||
accountId: "main",
|
||||
@@ -255,11 +258,17 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
|
||||
expect(streamingInstances).toHaveLength(1);
|
||||
expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
|
||||
expect(streamingInstances[0].start).toHaveBeenCalledWith("oc_chat", "chat_id", {
|
||||
replyToMessageId: undefined,
|
||||
replyInThread: undefined,
|
||||
rootId: "om_root_topic",
|
||||
});
|
||||
expect(streamingInstances[0].start).toHaveBeenCalledWith(
|
||||
"oc_chat",
|
||||
"chat_id",
|
||||
expect.objectContaining({
|
||||
replyToMessageId: undefined,
|
||||
replyInThread: undefined,
|
||||
rootId: "om_root_topic",
|
||||
header: { title: "agent", template: "blue" },
|
||||
note: "Agent: agent",
|
||||
}),
|
||||
);
|
||||
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
||||
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
||||
@@ -275,7 +284,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
expect(streamingInstances).toHaveLength(1);
|
||||
expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
|
||||
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
||||
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\npartial answer\n```");
|
||||
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\npartial answer\n```", {
|
||||
note: "Agent: agent",
|
||||
});
|
||||
});
|
||||
|
||||
it("delivers distinct final payloads after streaming close", async () => {
|
||||
@@ -287,9 +298,16 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
|
||||
expect(streamingInstances).toHaveLength(2);
|
||||
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
||||
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n完整回复第一段\n```");
|
||||
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n完整回复第一段\n```", {
|
||||
note: "Agent: agent",
|
||||
});
|
||||
expect(streamingInstances[1].close).toHaveBeenCalledTimes(1);
|
||||
expect(streamingInstances[1].close).toHaveBeenCalledWith("```md\n完整回复第一段 + 第二段\n```");
|
||||
expect(streamingInstances[1].close).toHaveBeenCalledWith(
|
||||
"```md\n完整回复第一段 + 第二段\n```",
|
||||
{
|
||||
note: "Agent: agent",
|
||||
},
|
||||
);
|
||||
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
||||
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -303,7 +321,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
|
||||
expect(streamingInstances).toHaveLength(1);
|
||||
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
||||
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n同一条回复\n```");
|
||||
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n同一条回复\n```", {
|
||||
note: "Agent: agent",
|
||||
});
|
||||
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
||||
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -367,7 +387,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
|
||||
expect(streamingInstances).toHaveLength(1);
|
||||
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
||||
expect(streamingInstances[0].close).toHaveBeenCalledWith("hellolo world");
|
||||
expect(streamingInstances[0].close).toHaveBeenCalledWith("hellolo world", {
|
||||
note: "Agent: agent",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends media-only payloads as attachments", async () => {
|
||||
@@ -436,7 +458,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("passes replyInThread to sendMarkdownCardFeishu for card text", async () => {
|
||||
it("passes replyInThread to sendStructuredCardFeishu for card text", async () => {
|
||||
resolveFeishuAccountMock.mockReturnValue({
|
||||
accountId: "main",
|
||||
appId: "app_id",
|
||||
@@ -454,7 +476,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
});
|
||||
await options.deliver({ text: "card text" }, { kind: "final" });
|
||||
|
||||
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
|
||||
expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replyToMessageId: "om_msg",
|
||||
replyInThread: true,
|
||||
@@ -462,6 +484,126 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("streams reasoning content as blockquote before answer", async () => {
|
||||
const { result, options } = createDispatcherHarness({
|
||||
runtime: createRuntimeLogger(),
|
||||
});
|
||||
|
||||
await options.onReplyStart?.();
|
||||
// Core agent sends pre-formatted text from formatReasoningMessage
|
||||
result.replyOptions.onReasoningStream?.({ text: "Reasoning:\n_thinking step 1_" });
|
||||
result.replyOptions.onReasoningStream?.({
|
||||
text: "Reasoning:\n_thinking step 1_\n_step 2_",
|
||||
});
|
||||
result.replyOptions.onPartialReply?.({ text: "answer part" });
|
||||
result.replyOptions.onReasoningEnd?.();
|
||||
await options.deliver({ text: "answer part final" }, { kind: "final" });
|
||||
|
||||
expect(streamingInstances).toHaveLength(1);
|
||||
const updateCalls = streamingInstances[0].update.mock.calls.map((c: unknown[]) => c[0]);
|
||||
const reasoningUpdate = updateCalls.find((c: string) => c.includes("Thinking"));
|
||||
expect(reasoningUpdate).toContain("> 💭 **Thinking**");
|
||||
// formatReasoningPrefix strips "Reasoning:" prefix and italic markers
|
||||
expect(reasoningUpdate).toContain("> thinking step");
|
||||
expect(reasoningUpdate).not.toContain("Reasoning:");
|
||||
expect(reasoningUpdate).not.toMatch(/> _.*_/);
|
||||
|
||||
const combinedUpdate = updateCalls.find(
|
||||
(c: string) => c.includes("Thinking") && c.includes("---"),
|
||||
);
|
||||
expect(combinedUpdate).toBeDefined();
|
||||
|
||||
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
||||
const closeArg = streamingInstances[0].close.mock.calls[0][0] as string;
|
||||
expect(closeArg).toContain("> 💭 **Thinking**");
|
||||
expect(closeArg).toContain("---");
|
||||
expect(closeArg).toContain("answer part final");
|
||||
});
|
||||
|
||||
it("provides onReasoningStream and onReasoningEnd when streaming is enabled", () => {
|
||||
const { result } = createDispatcherHarness({
|
||||
runtime: createRuntimeLogger(),
|
||||
});
|
||||
|
||||
expect(result.replyOptions.onReasoningStream).toBeTypeOf("function");
|
||||
expect(result.replyOptions.onReasoningEnd).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
it("omits reasoning callbacks when streaming is disabled", () => {
|
||||
resolveFeishuAccountMock.mockReturnValue({
|
||||
accountId: "main",
|
||||
appId: "app_id",
|
||||
appSecret: "app_secret",
|
||||
domain: "feishu",
|
||||
config: {
|
||||
renderMode: "auto",
|
||||
streaming: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = createDispatcherHarness({
|
||||
runtime: createRuntimeLogger(),
|
||||
});
|
||||
|
||||
expect(result.replyOptions.onReasoningStream).toBeUndefined();
|
||||
expect(result.replyOptions.onReasoningEnd).toBeUndefined();
|
||||
});
|
||||
|
||||
it("renders reasoning-only card when no answer text arrives", async () => {
|
||||
const { result, options } = createDispatcherHarness({
|
||||
runtime: createRuntimeLogger(),
|
||||
});
|
||||
|
||||
await options.onReplyStart?.();
|
||||
result.replyOptions.onReasoningStream?.({ text: "Reasoning:\n_deep thought_" });
|
||||
result.replyOptions.onReasoningEnd?.();
|
||||
await options.onIdle?.();
|
||||
|
||||
expect(streamingInstances).toHaveLength(1);
|
||||
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
||||
const closeArg = streamingInstances[0].close.mock.calls[0][0] as string;
|
||||
expect(closeArg).toContain("> 💭 **Thinking**");
|
||||
expect(closeArg).toContain("> deep thought");
|
||||
expect(closeArg).not.toContain("Reasoning:");
|
||||
expect(closeArg).not.toContain("---");
|
||||
});
|
||||
|
||||
it("ignores empty reasoning payloads", async () => {
|
||||
const { result, options } = createDispatcherHarness({
|
||||
runtime: createRuntimeLogger(),
|
||||
});
|
||||
|
||||
await options.onReplyStart?.();
|
||||
result.replyOptions.onReasoningStream?.({ text: "" });
|
||||
result.replyOptions.onPartialReply?.({ text: "```ts\ncode\n```" });
|
||||
await options.deliver({ text: "```ts\ncode\n```" }, { kind: "final" });
|
||||
|
||||
expect(streamingInstances).toHaveLength(1);
|
||||
const closeArg = streamingInstances[0].close.mock.calls[0][0] as string;
|
||||
expect(closeArg).not.toContain("Thinking");
|
||||
expect(closeArg).toBe("```ts\ncode\n```");
|
||||
});
|
||||
|
||||
it("deduplicates final text by raw answer payload, not combined card text", async () => {
|
||||
const { result, options } = createDispatcherHarness({
|
||||
runtime: createRuntimeLogger(),
|
||||
});
|
||||
|
||||
await options.onReplyStart?.();
|
||||
result.replyOptions.onReasoningStream?.({ text: "Reasoning:\n_thought_" });
|
||||
result.replyOptions.onReasoningEnd?.();
|
||||
await options.deliver({ text: "```ts\nfinal answer\n```" }, { kind: "final" });
|
||||
|
||||
expect(streamingInstances).toHaveLength(1);
|
||||
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Deliver the same raw answer text again — should be deduped
|
||||
await options.deliver({ text: "```ts\nfinal answer\n```" }, { kind: "final" });
|
||||
|
||||
// No second streaming session since the raw answer text matches
|
||||
expect(streamingInstances).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("passes replyToMessageId and replyInThread to streaming.start()", async () => {
|
||||
const { options } = createDispatcherHarness({
|
||||
runtime: createRuntimeLogger(),
|
||||
@@ -471,10 +613,16 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
|
||||
|
||||
expect(streamingInstances).toHaveLength(1);
|
||||
expect(streamingInstances[0].start).toHaveBeenCalledWith("oc_chat", "chat_id", {
|
||||
replyToMessageId: "om_msg",
|
||||
replyInThread: true,
|
||||
});
|
||||
expect(streamingInstances[0].start).toHaveBeenCalledWith(
|
||||
"oc_chat",
|
||||
"chat_id",
|
||||
expect.objectContaining({
|
||||
replyToMessageId: "om_msg",
|
||||
replyInThread: true,
|
||||
header: { title: "agent", template: "blue" },
|
||||
note: "Agent: agent",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("disables streaming for thread replies and keeps reply metadata", async () => {
|
||||
@@ -488,7 +636,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
|
||||
|
||||
expect(streamingInstances).toHaveLength(0);
|
||||
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
|
||||
expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replyToMessageId: "om_msg",
|
||||
replyInThread: true,
|
||||
@@ -510,4 +658,50 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("recovers streaming after start() throws (HTTP 400)", async () => {
|
||||
const errorMock = vi.fn();
|
||||
let shouldFailStart = true;
|
||||
|
||||
// Intercept streaming instance creation to make first start() reject
|
||||
const origPush = streamingInstances.push;
|
||||
streamingInstances.push = function (this: any[], ...args: any[]) {
|
||||
if (shouldFailStart) {
|
||||
args[0].start = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("Create card request failed with HTTP 400"));
|
||||
shouldFailStart = false;
|
||||
}
|
||||
return origPush.apply(this, args);
|
||||
} as any;
|
||||
|
||||
try {
|
||||
createFeishuReplyDispatcher({
|
||||
cfg: {} as never,
|
||||
agentId: "agent",
|
||||
runtime: { log: vi.fn(), error: errorMock } as never,
|
||||
chatId: "oc_chat",
|
||||
});
|
||||
|
||||
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
||||
|
||||
// First deliver with markdown triggers startStreaming - which will fail
|
||||
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "block" });
|
||||
|
||||
// Wait for the async error to propagate
|
||||
await vi.waitFor(() => {
|
||||
expect(errorMock).toHaveBeenCalledWith(expect.stringContaining("streaming start failed"));
|
||||
});
|
||||
|
||||
// Second deliver should create a NEW streaming session (not stuck)
|
||||
await options.deliver({ text: "```ts\nconst y = 2\n```" }, { kind: "final" });
|
||||
|
||||
// Two instances created: first failed, second succeeded and closed
|
||||
expect(streamingInstances).toHaveLength(2);
|
||||
expect(streamingInstances[1].start).toHaveBeenCalled();
|
||||
expect(streamingInstances[1].close).toHaveBeenCalled();
|
||||
} finally {
|
||||
streamingInstances.push = origPush;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
createTypingCallbacks,
|
||||
logTypingFailure,
|
||||
type ClawdbotConfig,
|
||||
type OutboundIdentity,
|
||||
type ReplyPayload,
|
||||
type RuntimeEnv,
|
||||
} from "openclaw/plugin-sdk/feishu";
|
||||
@@ -12,7 +13,12 @@ import { sendMediaFeishu } from "./media.js";
|
||||
import type { MentionTarget } from "./mention.js";
|
||||
import { buildMentionedCardContent } from "./mention.js";
|
||||
import { getFeishuRuntime } from "./runtime.js";
|
||||
import { sendMarkdownCardFeishu, sendMessageFeishu } from "./send.js";
|
||||
import {
|
||||
sendMarkdownCardFeishu,
|
||||
sendMessageFeishu,
|
||||
sendStructuredCardFeishu,
|
||||
type CardHeaderConfig,
|
||||
} from "./send.js";
|
||||
import { FeishuStreamingSession, mergeStreamingText } from "./streaming-card.js";
|
||||
import { resolveReceiveIdType } from "./targets.js";
|
||||
import { addTypingIndicator, removeTypingIndicator, type TypingIndicatorState } from "./typing.js";
|
||||
@@ -36,6 +42,36 @@ function normalizeEpochMs(timestamp: number | undefined): number | undefined {
|
||||
return timestamp < MS_EPOCH_MIN ? timestamp * 1000 : timestamp;
|
||||
}
|
||||
|
||||
/** Build a card header from agent identity config. */
|
||||
function resolveCardHeader(
|
||||
agentId: string,
|
||||
identity: OutboundIdentity | undefined,
|
||||
): CardHeaderConfig {
|
||||
const name = identity?.name?.trim() || agentId;
|
||||
const emoji = identity?.emoji?.trim();
|
||||
return {
|
||||
title: emoji ? `${emoji} ${name}` : name,
|
||||
template: identity?.theme ?? "blue",
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a card note footer from agent identity and model context. */
|
||||
function resolveCardNote(
|
||||
agentId: string,
|
||||
identity: OutboundIdentity | undefined,
|
||||
prefixCtx: { model?: string; provider?: string },
|
||||
): string {
|
||||
const name = identity?.name?.trim() || agentId;
|
||||
const parts: string[] = [`Agent: ${name}`];
|
||||
if (prefixCtx.model) {
|
||||
parts.push(`Model: ${prefixCtx.model}`);
|
||||
}
|
||||
if (prefixCtx.provider) {
|
||||
parts.push(`Provider: ${prefixCtx.provider}`);
|
||||
}
|
||||
return parts.join(" | ");
|
||||
}
|
||||
|
||||
export type CreateFeishuReplyDispatcherParams = {
|
||||
cfg: ClawdbotConfig;
|
||||
agentId: string;
|
||||
@@ -50,6 +86,7 @@ export type CreateFeishuReplyDispatcherParams = {
|
||||
rootId?: string;
|
||||
mentionTargets?: MentionTarget[];
|
||||
accountId?: string;
|
||||
identity?: OutboundIdentity;
|
||||
/** Epoch ms when the inbound message was created. Used to suppress typing
|
||||
* indicators on old/replayed messages after context compaction (#30418). */
|
||||
messageCreateTimeMs?: number;
|
||||
@@ -68,6 +105,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
rootId,
|
||||
mentionTargets,
|
||||
accountId,
|
||||
identity,
|
||||
} = params;
|
||||
const sendReplyToMessageId = skipReplyToInMessages ? undefined : replyToMessageId;
|
||||
const threadReplyMode = threadReply === true;
|
||||
@@ -143,11 +181,39 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
let streaming: FeishuStreamingSession | null = null;
|
||||
let streamText = "";
|
||||
let lastPartial = "";
|
||||
let reasoningText = "";
|
||||
const deliveredFinalTexts = new Set<string>();
|
||||
let partialUpdateQueue: Promise<void> = Promise.resolve();
|
||||
let streamingStartPromise: Promise<void> | null = null;
|
||||
type StreamTextUpdateMode = "snapshot" | "delta";
|
||||
|
||||
const formatReasoningPrefix = (thinking: string): string => {
|
||||
if (!thinking) return "";
|
||||
const withoutLabel = thinking.replace(/^Reasoning:\n/, "");
|
||||
const plain = withoutLabel.replace(/^_(.*)_$/gm, "$1");
|
||||
const lines = plain.split("\n").map((line) => `> ${line}`);
|
||||
return `> 💭 **Thinking**\n${lines.join("\n")}`;
|
||||
};
|
||||
|
||||
const buildCombinedStreamText = (thinking: string, answer: string): string => {
|
||||
const parts: string[] = [];
|
||||
if (thinking) parts.push(formatReasoningPrefix(thinking));
|
||||
if (thinking && answer) parts.push("\n\n---\n\n");
|
||||
if (answer) parts.push(answer);
|
||||
return parts.join("");
|
||||
};
|
||||
|
||||
const flushStreamingCardUpdate = (combined: string) => {
|
||||
partialUpdateQueue = partialUpdateQueue.then(async () => {
|
||||
if (streamingStartPromise) {
|
||||
await streamingStartPromise;
|
||||
}
|
||||
if (streaming?.isActive()) {
|
||||
await streaming.update(combined);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const queueStreamingUpdate = (
|
||||
nextText: string,
|
||||
options?: {
|
||||
@@ -167,14 +233,13 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
const mode = options?.mode ?? "snapshot";
|
||||
streamText =
|
||||
mode === "delta" ? `${streamText}${nextText}` : mergeStreamingText(streamText, nextText);
|
||||
partialUpdateQueue = partialUpdateQueue.then(async () => {
|
||||
if (streamingStartPromise) {
|
||||
await streamingStartPromise;
|
||||
}
|
||||
if (streaming?.isActive()) {
|
||||
await streaming.update(streamText);
|
||||
}
|
||||
});
|
||||
flushStreamingCardUpdate(buildCombinedStreamText(reasoningText, streamText));
|
||||
};
|
||||
|
||||
const queueReasoningUpdate = (nextThinking: string) => {
|
||||
if (!nextThinking) return;
|
||||
reasoningText = nextThinking;
|
||||
flushStreamingCardUpdate(buildCombinedStreamText(reasoningText, streamText));
|
||||
};
|
||||
|
||||
const startStreaming = () => {
|
||||
@@ -194,14 +259,19 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
params.runtime.log?.(`feishu[${account.accountId}] ${message}`),
|
||||
);
|
||||
try {
|
||||
const cardHeader = resolveCardHeader(agentId, identity);
|
||||
const cardNote = resolveCardNote(agentId, identity, prefixContext.prefixContext);
|
||||
await streaming.start(chatId, resolveReceiveIdType(chatId), {
|
||||
replyToMessageId,
|
||||
replyInThread: effectiveReplyInThread,
|
||||
rootId,
|
||||
header: cardHeader,
|
||||
note: cardNote,
|
||||
});
|
||||
} catch (error) {
|
||||
params.runtime.error?.(`feishu: streaming start failed: ${String(error)}`);
|
||||
streaming = null;
|
||||
streamingStartPromise = null; // allow retry on next deliver
|
||||
}
|
||||
})();
|
||||
};
|
||||
@@ -212,16 +282,18 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
}
|
||||
await partialUpdateQueue;
|
||||
if (streaming?.isActive()) {
|
||||
let text = streamText;
|
||||
let text = buildCombinedStreamText(reasoningText, streamText);
|
||||
if (mentionTargets?.length) {
|
||||
text = buildMentionedCardContent(mentionTargets, text);
|
||||
}
|
||||
await streaming.close(text);
|
||||
const finalNote = resolveCardNote(agentId, identity, prefixContext.prefixContext);
|
||||
await streaming.close(text, { note: finalNote });
|
||||
}
|
||||
streaming = null;
|
||||
streamingStartPromise = null;
|
||||
streamText = "";
|
||||
lastPartial = "";
|
||||
reasoningText = "";
|
||||
};
|
||||
|
||||
const sendChunkedTextReply = async (params: {
|
||||
@@ -291,6 +363,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
|
||||
if (shouldDeliverText) {
|
||||
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
|
||||
let first = true;
|
||||
|
||||
if (info?.kind === "block") {
|
||||
// Drop internal block chunks unless we can safely consume them as
|
||||
@@ -339,7 +412,29 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
}
|
||||
|
||||
if (useCard) {
|
||||
await sendChunkedTextReply({ text, useCard: true, infoKind: info?.kind });
|
||||
const cardHeader = resolveCardHeader(agentId, identity);
|
||||
const cardNote = resolveCardNote(agentId, identity, prefixContext.prefixContext);
|
||||
for (const chunk of core.channel.text.chunkTextWithMode(
|
||||
text,
|
||||
textChunkLimit,
|
||||
chunkMode,
|
||||
)) {
|
||||
await sendStructuredCardFeishu({
|
||||
cfg,
|
||||
to: chatId,
|
||||
text: chunk,
|
||||
replyToMessageId: sendReplyToMessageId,
|
||||
replyInThread: effectiveReplyInThread,
|
||||
mentions: first ? mentionTargets : undefined,
|
||||
accountId,
|
||||
header: cardHeader,
|
||||
note: cardNote,
|
||||
});
|
||||
first = false;
|
||||
}
|
||||
if (info?.kind === "final") {
|
||||
deliveredFinalTexts.add(text);
|
||||
}
|
||||
} else {
|
||||
await sendChunkedTextReply({ text, useCard: false, infoKind: info?.kind });
|
||||
}
|
||||
@@ -391,6 +486,16 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
onReasoningStream: streamingEnabled
|
||||
? (payload: ReplyPayload) => {
|
||||
if (!payload.text) {
|
||||
return;
|
||||
}
|
||||
startStreaming();
|
||||
queueReasoningUpdate(payload.text);
|
||||
}
|
||||
: undefined,
|
||||
onReasoningEnd: streamingEnabled ? () => {} : undefined,
|
||||
},
|
||||
markDispatchIdle,
|
||||
};
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getMessageFeishu } from "./send.js";
|
||||
import {
|
||||
buildStructuredCard,
|
||||
getMessageFeishu,
|
||||
listFeishuThreadMessages,
|
||||
resolveFeishuCardTemplate,
|
||||
} from "./send.js";
|
||||
|
||||
const { mockClientGet, mockCreateFeishuClient, mockResolveFeishuAccount } = vi.hoisted(() => ({
|
||||
mockClientGet: vi.fn(),
|
||||
mockCreateFeishuClient: vi.fn(),
|
||||
mockResolveFeishuAccount: vi.fn(),
|
||||
}));
|
||||
const { mockClientGet, mockClientList, mockCreateFeishuClient, mockResolveFeishuAccount } =
|
||||
vi.hoisted(() => ({
|
||||
mockClientGet: vi.fn(),
|
||||
mockClientList: vi.fn(),
|
||||
mockCreateFeishuClient: vi.fn(),
|
||||
mockResolveFeishuAccount: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
createFeishuClient: mockCreateFeishuClient,
|
||||
@@ -27,6 +34,7 @@ describe("getMessageFeishu", () => {
|
||||
im: {
|
||||
message: {
|
||||
get: mockClientGet,
|
||||
list: mockClientList,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -165,4 +173,98 @@ describe("getMessageFeishu", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reuses the same content parsing for thread history messages", async () => {
|
||||
mockClientList.mockResolvedValueOnce({
|
||||
code: 0,
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
message_id: "om_root",
|
||||
msg_type: "text",
|
||||
body: {
|
||||
content: JSON.stringify({ text: "root starter" }),
|
||||
},
|
||||
},
|
||||
{
|
||||
message_id: "om_card",
|
||||
msg_type: "interactive",
|
||||
body: {
|
||||
content: JSON.stringify({
|
||||
body: {
|
||||
elements: [{ tag: "markdown", content: "hello from card 2.0" }],
|
||||
},
|
||||
}),
|
||||
},
|
||||
sender: {
|
||||
id: "app_1",
|
||||
sender_type: "app",
|
||||
},
|
||||
create_time: "1710000000000",
|
||||
},
|
||||
{
|
||||
message_id: "om_file",
|
||||
msg_type: "file",
|
||||
body: {
|
||||
content: JSON.stringify({ file_key: "file_v3_123" }),
|
||||
},
|
||||
sender: {
|
||||
id: "ou_1",
|
||||
sender_type: "user",
|
||||
},
|
||||
create_time: "1710000001000",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await listFeishuThreadMessages({
|
||||
cfg: {} as ClawdbotConfig,
|
||||
threadId: "omt_1",
|
||||
rootMessageId: "om_root",
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
messageId: "om_file",
|
||||
contentType: "file",
|
||||
content: "[file message]",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
messageId: "om_card",
|
||||
contentType: "interactive",
|
||||
content: "hello from card 2.0",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveFeishuCardTemplate", () => {
|
||||
it("accepts supported Feishu templates", () => {
|
||||
expect(resolveFeishuCardTemplate(" purple ")).toBe("purple");
|
||||
});
|
||||
|
||||
it("drops unsupported free-form identity themes", () => {
|
||||
expect(resolveFeishuCardTemplate("space lobster")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildStructuredCard", () => {
|
||||
it("falls back to blue when the header template is unsupported", () => {
|
||||
const card = buildStructuredCard("hello", {
|
||||
header: {
|
||||
title: "Agent",
|
||||
template: "space lobster",
|
||||
},
|
||||
});
|
||||
|
||||
expect(card).toEqual(
|
||||
expect.objectContaining({
|
||||
header: {
|
||||
title: { tag: "plain_text", content: "Agent" },
|
||||
template: "blue",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,21 @@ import { resolveFeishuSendTarget } from "./send-target.js";
|
||||
import type { FeishuChatType, FeishuMessageInfo, FeishuSendResult } from "./types.js";
|
||||
|
||||
const WITHDRAWN_REPLY_ERROR_CODES = new Set([230011, 231003]);
|
||||
const FEISHU_CARD_TEMPLATES = new Set([
|
||||
"blue",
|
||||
"green",
|
||||
"red",
|
||||
"orange",
|
||||
"purple",
|
||||
"indigo",
|
||||
"wathet",
|
||||
"turquoise",
|
||||
"yellow",
|
||||
"grey",
|
||||
"carmine",
|
||||
"violet",
|
||||
"lime",
|
||||
]);
|
||||
|
||||
function shouldFallbackFromReplyTarget(response: { code?: number; msg?: string }): boolean {
|
||||
if (response.code !== undefined && WITHDRAWN_REPLY_ERROR_CODES.has(response.code)) {
|
||||
@@ -65,6 +80,7 @@ type FeishuMessageGetItem = {
|
||||
message_id?: string;
|
||||
chat_id?: string;
|
||||
chat_type?: FeishuChatType;
|
||||
thread_id?: string;
|
||||
msg_type?: string;
|
||||
body?: { content?: string };
|
||||
sender?: FeishuMessageSender;
|
||||
@@ -151,13 +167,19 @@ function parseInteractiveCardContent(parsed: unknown): string {
|
||||
return "[Interactive Card]";
|
||||
}
|
||||
|
||||
const candidate = parsed as { elements?: unknown };
|
||||
if (!Array.isArray(candidate.elements)) {
|
||||
// Support both schema 1.0 (top-level `elements`) and 2.0 (`body.elements`).
|
||||
const candidate = parsed as { elements?: unknown; body?: { elements?: unknown } };
|
||||
const elements = Array.isArray(candidate.elements)
|
||||
? candidate.elements
|
||||
: Array.isArray(candidate.body?.elements)
|
||||
? candidate.body!.elements
|
||||
: null;
|
||||
if (!elements) {
|
||||
return "[Interactive Card]";
|
||||
}
|
||||
|
||||
const texts: string[] = [];
|
||||
for (const element of candidate.elements) {
|
||||
for (const element of elements) {
|
||||
if (!element || typeof element !== "object") {
|
||||
continue;
|
||||
}
|
||||
@@ -177,7 +199,7 @@ function parseInteractiveCardContent(parsed: unknown): string {
|
||||
return texts.join("\n").trim() || "[Interactive Card]";
|
||||
}
|
||||
|
||||
function parseQuotedMessageContent(rawContent: string, msgType: string): string {
|
||||
function parseFeishuMessageContent(rawContent: string, msgType: string): string {
|
||||
if (!rawContent) {
|
||||
return "";
|
||||
}
|
||||
@@ -218,6 +240,30 @@ function parseQuotedMessageContent(rawContent: string, msgType: string): string
|
||||
return `[${msgType || "unknown"} message]`;
|
||||
}
|
||||
|
||||
function parseFeishuMessageItem(
|
||||
item: FeishuMessageGetItem,
|
||||
fallbackMessageId?: string,
|
||||
): FeishuMessageInfo {
|
||||
const msgType = item.msg_type ?? "text";
|
||||
const rawContent = item.body?.content ?? "";
|
||||
|
||||
return {
|
||||
messageId: item.message_id ?? fallbackMessageId ?? "",
|
||||
chatId: item.chat_id ?? "",
|
||||
chatType:
|
||||
item.chat_type === "group" || item.chat_type === "private" || item.chat_type === "p2p"
|
||||
? item.chat_type
|
||||
: undefined,
|
||||
senderId: item.sender?.id,
|
||||
senderOpenId: item.sender?.id_type === "open_id" ? item.sender?.id : undefined,
|
||||
senderType: item.sender?.sender_type,
|
||||
content: parseFeishuMessageContent(rawContent, msgType),
|
||||
contentType: msgType,
|
||||
createTime: item.create_time ? parseInt(String(item.create_time), 10) : undefined,
|
||||
threadId: item.thread_id || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a message by its ID.
|
||||
* Useful for fetching quoted/replied message content.
|
||||
@@ -255,29 +301,98 @@ export async function getMessageFeishu(params: {
|
||||
return null;
|
||||
}
|
||||
|
||||
const msgType = item.msg_type ?? "text";
|
||||
const rawContent = item.body?.content ?? "";
|
||||
const content = parseQuotedMessageContent(rawContent, msgType);
|
||||
|
||||
return {
|
||||
messageId: item.message_id ?? messageId,
|
||||
chatId: item.chat_id ?? "",
|
||||
chatType:
|
||||
item.chat_type === "group" || item.chat_type === "private" || item.chat_type === "p2p"
|
||||
? item.chat_type
|
||||
: undefined,
|
||||
senderId: item.sender?.id,
|
||||
senderOpenId: item.sender?.id_type === "open_id" ? item.sender?.id : undefined,
|
||||
senderType: item.sender?.sender_type,
|
||||
content,
|
||||
contentType: msgType,
|
||||
createTime: item.create_time ? parseInt(String(item.create_time), 10) : undefined,
|
||||
};
|
||||
return parseFeishuMessageItem(item, messageId);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type FeishuThreadMessageInfo = {
|
||||
messageId: string;
|
||||
senderId?: string;
|
||||
senderType?: string;
|
||||
content: string;
|
||||
contentType: string;
|
||||
createTime?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* List messages in a Feishu thread (topic).
|
||||
* Uses container_id_type=thread to directly query thread messages,
|
||||
* which includes both the root message and all replies (including bot replies).
|
||||
*/
|
||||
export async function listFeishuThreadMessages(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
threadId: string;
|
||||
currentMessageId?: string;
|
||||
/** Exclude the root message (already provided separately as ThreadStarterBody). */
|
||||
rootMessageId?: string;
|
||||
limit?: number;
|
||||
accountId?: string;
|
||||
}): Promise<FeishuThreadMessageInfo[]> {
|
||||
const { cfg, threadId, currentMessageId, rootMessageId, limit = 20, accountId } = params;
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
if (!account.configured) {
|
||||
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
||||
}
|
||||
|
||||
const client = createFeishuClient(account);
|
||||
|
||||
const response = (await client.im.message.list({
|
||||
params: {
|
||||
container_id_type: "thread",
|
||||
container_id: threadId,
|
||||
// Fetch newest messages first so long threads keep the most recent turns.
|
||||
// Results are reversed below to restore chronological order.
|
||||
sort_type: "ByCreateTimeDesc",
|
||||
page_size: Math.min(limit + 1, 50),
|
||||
},
|
||||
})) as {
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: {
|
||||
items?: Array<
|
||||
{
|
||||
message_id?: string;
|
||||
root_id?: string;
|
||||
parent_id?: string;
|
||||
} & FeishuMessageGetItem
|
||||
>;
|
||||
};
|
||||
};
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(
|
||||
`Feishu thread list failed: code=${response.code} msg=${response.msg ?? "unknown"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const items = response.data?.items ?? [];
|
||||
const results: FeishuThreadMessageInfo[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (currentMessageId && item.message_id === currentMessageId) continue;
|
||||
if (rootMessageId && item.message_id === rootMessageId) continue;
|
||||
|
||||
const parsed = parseFeishuMessageItem(item);
|
||||
|
||||
results.push({
|
||||
messageId: parsed.messageId,
|
||||
senderId: parsed.senderId,
|
||||
senderType: parsed.senderType,
|
||||
content: parsed.content,
|
||||
contentType: parsed.contentType,
|
||||
createTime: parsed.createTime,
|
||||
});
|
||||
|
||||
if (results.length >= limit) break;
|
||||
}
|
||||
|
||||
// Restore chronological order (oldest first) since we fetched newest-first.
|
||||
results.reverse();
|
||||
return results;
|
||||
}
|
||||
|
||||
export type SendFeishuMessageParams = {
|
||||
cfg: ClawdbotConfig;
|
||||
to: string;
|
||||
@@ -418,6 +533,77 @@ export function buildMarkdownCard(text: string): Record<string, unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
/** Header configuration for structured Feishu cards. */
|
||||
export type CardHeaderConfig = {
|
||||
/** Header title text, e.g. "💻 Coder" */
|
||||
title: string;
|
||||
/** Feishu header color template (blue, green, red, orange, purple, grey, etc.). Defaults to "blue". */
|
||||
template?: string;
|
||||
};
|
||||
|
||||
export function resolveFeishuCardTemplate(template?: string): string | undefined {
|
||||
const normalized = template?.trim().toLowerCase();
|
||||
if (!normalized || !FEISHU_CARD_TEMPLATES.has(normalized)) {
|
||||
return undefined;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Feishu interactive card with optional header and note footer.
|
||||
* When header/note are omitted, behaves identically to buildMarkdownCard.
|
||||
*/
|
||||
export function buildStructuredCard(
|
||||
text: string,
|
||||
options?: {
|
||||
header?: CardHeaderConfig;
|
||||
note?: string;
|
||||
},
|
||||
): Record<string, unknown> {
|
||||
const elements: Record<string, unknown>[] = [{ tag: "markdown", content: text }];
|
||||
if (options?.note) {
|
||||
elements.push({ tag: "hr" });
|
||||
elements.push({ tag: "markdown", content: `<font color='grey'>${options.note}</font>` });
|
||||
}
|
||||
const card: Record<string, unknown> = {
|
||||
schema: "2.0",
|
||||
config: { wide_screen_mode: true },
|
||||
body: { elements },
|
||||
};
|
||||
if (options?.header) {
|
||||
card.header = {
|
||||
title: { tag: "plain_text", content: options.header.title },
|
||||
template: resolveFeishuCardTemplate(options.header.template) ?? "blue",
|
||||
};
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message as a structured card with optional header and note.
|
||||
*/
|
||||
export async function sendStructuredCardFeishu(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
to: string;
|
||||
text: string;
|
||||
replyToMessageId?: string;
|
||||
/** When true, reply creates a Feishu topic thread instead of an inline reply */
|
||||
replyInThread?: boolean;
|
||||
mentions?: MentionTarget[];
|
||||
accountId?: string;
|
||||
header?: CardHeaderConfig;
|
||||
note?: string;
|
||||
}): Promise<FeishuSendResult> {
|
||||
const { cfg, to, text, replyToMessageId, replyInThread, mentions, accountId, header, note } =
|
||||
params;
|
||||
let cardText = text;
|
||||
if (mentions && mentions.length > 0) {
|
||||
cardText = buildMentionedCardContent(mentions, text);
|
||||
}
|
||||
const card = buildStructuredCard(cardText, { header, note });
|
||||
return sendCardFeishu({ cfg, to, card, replyToMessageId, replyInThread, accountId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message as a markdown card (interactive message).
|
||||
* This renders markdown properly in Feishu (code blocks, tables, bold/italic, etc.)
|
||||
|
||||
@@ -4,10 +4,25 @@
|
||||
|
||||
import type { Client } from "@larksuiteoapi/node-sdk";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/feishu";
|
||||
import { resolveFeishuCardTemplate, type CardHeaderConfig } from "./send.js";
|
||||
import type { FeishuDomain } from "./types.js";
|
||||
|
||||
type Credentials = { appId: string; appSecret: string; domain?: FeishuDomain };
|
||||
type CardState = { cardId: string; messageId: string; sequence: number; currentText: string };
|
||||
type CardState = {
|
||||
cardId: string;
|
||||
messageId: string;
|
||||
sequence: number;
|
||||
currentText: string;
|
||||
hasNote: boolean;
|
||||
};
|
||||
|
||||
/** Options for customising the initial streaming card appearance. */
|
||||
export type StreamingCardOptions = {
|
||||
/** Optional header with title and color template. */
|
||||
header?: CardHeaderConfig;
|
||||
/** Optional grey note footer text. */
|
||||
note?: string;
|
||||
};
|
||||
|
||||
/** Optional header for streaming cards (title bar with color template) */
|
||||
export type StreamingCardHeader = {
|
||||
@@ -152,6 +167,7 @@ export class FeishuStreamingSession {
|
||||
private log?: (msg: string) => void;
|
||||
private lastUpdateTime = 0;
|
||||
private pendingText: string | null = null;
|
||||
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private updateThrottleMs = 100; // Throttle updates to max 10/sec
|
||||
|
||||
constructor(client: Client, creds: Credentials, log?: (msg: string) => void) {
|
||||
@@ -163,13 +179,24 @@ export class FeishuStreamingSession {
|
||||
async start(
|
||||
receiveId: string,
|
||||
receiveIdType: "open_id" | "user_id" | "union_id" | "email" | "chat_id" = "chat_id",
|
||||
options?: StreamingStartOptions,
|
||||
options?: StreamingCardOptions & StreamingStartOptions,
|
||||
): Promise<void> {
|
||||
if (this.state) {
|
||||
return;
|
||||
}
|
||||
|
||||
const apiBase = resolveApiBase(this.creds.domain);
|
||||
const elements: Record<string, unknown>[] = [
|
||||
{ tag: "markdown", content: "⏳ Thinking...", element_id: "content" },
|
||||
];
|
||||
if (options?.note) {
|
||||
elements.push({ tag: "hr" });
|
||||
elements.push({
|
||||
tag: "markdown",
|
||||
content: `<font color='grey'>${options.note}</font>`,
|
||||
element_id: "note",
|
||||
});
|
||||
}
|
||||
const cardJson: Record<string, unknown> = {
|
||||
schema: "2.0",
|
||||
config: {
|
||||
@@ -177,14 +204,12 @@ export class FeishuStreamingSession {
|
||||
summary: { content: "[Generating...]" },
|
||||
streaming_config: { print_frequency_ms: { default: 50 }, print_step: { default: 1 } },
|
||||
},
|
||||
body: {
|
||||
elements: [{ tag: "markdown", content: "⏳ Thinking...", element_id: "content" }],
|
||||
},
|
||||
body: { elements },
|
||||
};
|
||||
if (options?.header) {
|
||||
cardJson.header = {
|
||||
title: { tag: "plain_text", content: options.header.title },
|
||||
template: options.header.template ?? "blue",
|
||||
template: resolveFeishuCardTemplate(options.header.template) ?? "blue",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -257,7 +282,13 @@ export class FeishuStreamingSession {
|
||||
throw new Error(`Send card failed: ${sendRes.msg}`);
|
||||
}
|
||||
|
||||
this.state = { cardId, messageId: sendRes.data.message_id, sequence: 1, currentText: "" };
|
||||
this.state = {
|
||||
cardId,
|
||||
messageId: sendRes.data.message_id,
|
||||
sequence: 1,
|
||||
currentText: "",
|
||||
hasNote: !!options?.note,
|
||||
};
|
||||
this.log?.(`Started streaming: cardId=${cardId}, messageId=${sendRes.data.message_id}`);
|
||||
}
|
||||
|
||||
@@ -307,6 +338,10 @@ export class FeishuStreamingSession {
|
||||
}
|
||||
this.pendingText = null;
|
||||
this.lastUpdateTime = now;
|
||||
if (this.flushTimer) {
|
||||
clearTimeout(this.flushTimer);
|
||||
this.flushTimer = null;
|
||||
}
|
||||
|
||||
this.queue = this.queue.then(async () => {
|
||||
if (!this.state || this.closed) {
|
||||
@@ -322,11 +357,44 @@ export class FeishuStreamingSession {
|
||||
await this.queue;
|
||||
}
|
||||
|
||||
async close(finalText?: string): Promise<void> {
|
||||
private async updateNoteContent(note: string): Promise<void> {
|
||||
if (!this.state || !this.state.hasNote) {
|
||||
return;
|
||||
}
|
||||
const apiBase = resolveApiBase(this.creds.domain);
|
||||
this.state.sequence += 1;
|
||||
await fetchWithSsrFGuard({
|
||||
url: `${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/note/content`,
|
||||
init: {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${await getToken(this.creds)}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: `<font color='grey'>${note}</font>`,
|
||||
sequence: this.state.sequence,
|
||||
uuid: `n_${this.state.cardId}_${this.state.sequence}`,
|
||||
}),
|
||||
},
|
||||
policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
|
||||
auditContext: "feishu.streaming-card.note-update",
|
||||
})
|
||||
.then(async ({ release }) => {
|
||||
await release();
|
||||
})
|
||||
.catch((e) => this.log?.(`Note update failed: ${String(e)}`));
|
||||
}
|
||||
|
||||
async close(finalText?: string, options?: { note?: string }): Promise<void> {
|
||||
if (!this.state || this.closed) {
|
||||
return;
|
||||
}
|
||||
this.closed = true;
|
||||
if (this.flushTimer) {
|
||||
clearTimeout(this.flushTimer);
|
||||
this.flushTimer = null;
|
||||
}
|
||||
await this.queue;
|
||||
|
||||
const pendingMerged = mergeStreamingText(this.state.currentText, this.pendingText ?? undefined);
|
||||
@@ -339,6 +407,11 @@ export class FeishuStreamingSession {
|
||||
this.state.currentText = text;
|
||||
}
|
||||
|
||||
// Update note with final model/provider info
|
||||
if (options?.note) {
|
||||
await this.updateNoteContent(options.note);
|
||||
}
|
||||
|
||||
// Close streaming mode
|
||||
this.state.sequence += 1;
|
||||
await fetchWithSsrFGuard({
|
||||
@@ -364,8 +437,11 @@ export class FeishuStreamingSession {
|
||||
await release();
|
||||
})
|
||||
.catch((e) => this.log?.(`Close failed: ${String(e)}`));
|
||||
const finalState = this.state;
|
||||
this.state = null;
|
||||
this.pendingText = null;
|
||||
|
||||
this.log?.(`Closed streaming: cardId=${this.state.cardId}`);
|
||||
this.log?.(`Closed streaming: cardId=${finalState.cardId}`);
|
||||
}
|
||||
|
||||
isActive(): boolean {
|
||||
|
||||
@@ -72,6 +72,8 @@ export type FeishuMessageInfo = {
|
||||
content: string;
|
||||
contentType: string;
|
||||
createTime?: number;
|
||||
/** Feishu thread ID (omt_xxx) — present when the message belongs to a topic thread. */
|
||||
threadId?: string;
|
||||
};
|
||||
|
||||
export type FeishuProbeResult = BaseProbeResult<string> & {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { isAllowedParsedChatSender } from "openclaw/plugin-sdk/allow-from";
|
||||
import { isAllowedParsedChatSender } from "../../../src/plugin-sdk/allow-from.js";
|
||||
|
||||
export type ServicePrefix<TService extends string> = { prefix: string; service: TService };
|
||||
|
||||
|
||||
@@ -43,7 +43,6 @@ function fakeApi(overrides: Partial<OpenClawPluginApi> = {}): OpenClawPluginApi
|
||||
registerCli() {},
|
||||
registerService() {},
|
||||
registerProvider() {},
|
||||
registerSearchProvider() {},
|
||||
registerHook() {},
|
||||
registerHttpRoute() {},
|
||||
registerCommand() {},
|
||||
|
||||
@@ -31,17 +31,6 @@ function createApi(params: {
|
||||
writeConfigFile: (next: Record<string, unknown>) => params.writeConfig(next),
|
||||
},
|
||||
} as OpenClawPluginApi["runtime"],
|
||||
logger: { info() {}, warn() {}, error() {} },
|
||||
registerTool() {},
|
||||
registerHook() {},
|
||||
registerHttpRoute() {},
|
||||
registerChannel() {},
|
||||
registerGatewayMethod() {},
|
||||
registerCli() {},
|
||||
registerService() {},
|
||||
registerProvider() {},
|
||||
registerSearchProvider() {},
|
||||
registerContextEngine() {},
|
||||
registerCommand: params.registerCommand,
|
||||
}) as OpenClawPluginApi;
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"id": "search-brave",
|
||||
"defaultEnabledWhenBundled": true,
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
},
|
||||
"provides": ["providers.search.brave"]
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"name": "@openclaw/search-brave",
|
||||
"version": "2026.3.12",
|
||||
"private": true,
|
||||
"description": "OpenClaw Brave search plugin",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./src/index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { createBundledBraveSearchProvider } from "./provider.js";
|
||||
|
||||
const plugin = {
|
||||
id: "search-brave",
|
||||
name: "Brave Search",
|
||||
description: "Bundled Brave web search provider for OpenClaw.",
|
||||
register(api: OpenClawPluginApi) {
|
||||
api.registerSearchProvider(createBundledBraveSearchProvider());
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
@@ -1,606 +0,0 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import {
|
||||
CacheEntry,
|
||||
createSearchProviderSetupMetadata,
|
||||
createMissingSearchKeyPayload,
|
||||
formatCliCommand,
|
||||
normalizeCacheKey,
|
||||
normalizeResolvedSecretInputString,
|
||||
normalizeSecretInput,
|
||||
readCache,
|
||||
readResponseText,
|
||||
resolveSearchConfig,
|
||||
resolveSiteName,
|
||||
type OpenClawConfig,
|
||||
type SearchProviderContext,
|
||||
type SearchProviderErrorResult,
|
||||
type SearchProviderExecutionResult,
|
||||
type SearchProviderSetupMetadata,
|
||||
type SearchProviderPlugin,
|
||||
type SearchProviderRequest,
|
||||
withTrustedWebToolsEndpoint,
|
||||
wrapWebContent,
|
||||
writeCache,
|
||||
} from "openclaw/plugin-sdk/web-search";
|
||||
|
||||
const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
|
||||
const BRAVE_LLM_CONTEXT_ENDPOINT = "https://api.search.brave.com/res/v1/llm/context";
|
||||
|
||||
const BRAVE_SEARCH_CACHE = new Map<string, CacheEntry<Record<string, unknown>>>();
|
||||
const BRAVE_FRESHNESS_SHORTCUTS = new Set(["pd", "pw", "pm", "py"]);
|
||||
const BRAVE_FRESHNESS_RANGE = /^(\d{4}-\d{2}-\d{2})to(\d{4}-\d{2}-\d{2})$/;
|
||||
const BRAVE_SEARCH_LANG_CODES = new Set([
|
||||
"ar",
|
||||
"eu",
|
||||
"bn",
|
||||
"bg",
|
||||
"ca",
|
||||
"zh-hans",
|
||||
"zh-hant",
|
||||
"hr",
|
||||
"cs",
|
||||
"da",
|
||||
"nl",
|
||||
"en",
|
||||
"en-gb",
|
||||
"et",
|
||||
"fi",
|
||||
"fr",
|
||||
"gl",
|
||||
"de",
|
||||
"el",
|
||||
"gu",
|
||||
"he",
|
||||
"hi",
|
||||
"hu",
|
||||
"is",
|
||||
"it",
|
||||
"jp",
|
||||
"kn",
|
||||
"ko",
|
||||
"lv",
|
||||
"lt",
|
||||
"ms",
|
||||
"ml",
|
||||
"mr",
|
||||
"nb",
|
||||
"pl",
|
||||
"pt-br",
|
||||
"pt-pt",
|
||||
"pa",
|
||||
"ro",
|
||||
"ru",
|
||||
"sr",
|
||||
"sk",
|
||||
"sl",
|
||||
"es",
|
||||
"sv",
|
||||
"ta",
|
||||
"te",
|
||||
"th",
|
||||
"tr",
|
||||
"uk",
|
||||
"vi",
|
||||
]);
|
||||
const BRAVE_SEARCH_LANG_ALIASES: Record<string, string> = {
|
||||
ja: "jp",
|
||||
zh: "zh-hans",
|
||||
"zh-cn": "zh-hans",
|
||||
"zh-hk": "zh-hant",
|
||||
"zh-sg": "zh-hans",
|
||||
"zh-tw": "zh-hant",
|
||||
};
|
||||
const BRAVE_UI_LANG_LOCALE = /^([a-z]{2})-([a-z]{2})$/i;
|
||||
const MAX_SEARCH_COUNT = 10;
|
||||
|
||||
type BraveSearchResult = {
|
||||
title?: string;
|
||||
url?: string;
|
||||
description?: string;
|
||||
age?: string;
|
||||
};
|
||||
|
||||
type BraveSearchResponse = {
|
||||
web?: {
|
||||
results?: BraveSearchResult[];
|
||||
};
|
||||
};
|
||||
|
||||
type BraveLlmContextResult = { url: string; title: string; snippets: string[] };
|
||||
type BraveLlmContextResponse = {
|
||||
grounding: { generic?: BraveLlmContextResult[] };
|
||||
sources?: { url?: string; hostname?: string; date?: string }[];
|
||||
};
|
||||
|
||||
type BraveConfig = {
|
||||
mode?: string;
|
||||
};
|
||||
|
||||
type WebSearchConfig = NonNullable<OpenClawConfig["tools"]>["web"] extends infer Web
|
||||
? Web extends { search?: infer Search }
|
||||
? Search
|
||||
: undefined
|
||||
: undefined;
|
||||
|
||||
function resolveBraveConfig(search?: WebSearchConfig): BraveConfig {
|
||||
if (!search || typeof search !== "object") {
|
||||
return {};
|
||||
}
|
||||
const brave = "brave" in search ? search.brave : undefined;
|
||||
return brave && typeof brave === "object" ? (brave as BraveConfig) : {};
|
||||
}
|
||||
|
||||
function resolveBraveMode(brave: BraveConfig): "web" | "llm-context" {
|
||||
return brave.mode === "llm-context" ? "llm-context" : "web";
|
||||
}
|
||||
|
||||
function resolveBraveApiKey(search?: WebSearchConfig): string | undefined {
|
||||
const fromConfigRaw = search
|
||||
? normalizeResolvedSecretInputString({
|
||||
value: search.apiKey,
|
||||
path: "tools.web.search.apiKey",
|
||||
})
|
||||
: undefined;
|
||||
const fromConfig = normalizeSecretInput(fromConfigRaw);
|
||||
const fromEnv = normalizeSecretInput(process.env.BRAVE_API_KEY);
|
||||
return fromConfig || fromEnv || undefined;
|
||||
}
|
||||
|
||||
function normalizeBraveSearchLang(value: string | undefined): string | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const canonical = BRAVE_SEARCH_LANG_ALIASES[trimmed.toLowerCase()] ?? trimmed.toLowerCase();
|
||||
if (!BRAVE_SEARCH_LANG_CODES.has(canonical)) {
|
||||
return undefined;
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
|
||||
function normalizeBraveUiLang(value: string | undefined): string | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const match = trimmed.match(BRAVE_UI_LANG_LOCALE);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const [, language, region] = match;
|
||||
return `${language.toLowerCase()}-${region.toUpperCase()}`;
|
||||
}
|
||||
|
||||
function normalizeBraveLanguageParams(params: { search_lang?: string; ui_lang?: string }): {
|
||||
search_lang?: string;
|
||||
ui_lang?: string;
|
||||
invalidField?: "search_lang" | "ui_lang";
|
||||
} {
|
||||
const rawSearchLang = params.search_lang?.trim() || undefined;
|
||||
const rawUiLang = params.ui_lang?.trim() || undefined;
|
||||
let searchLangCandidate = rawSearchLang;
|
||||
let uiLangCandidate = rawUiLang;
|
||||
|
||||
if (normalizeBraveUiLang(rawSearchLang) && normalizeBraveSearchLang(rawUiLang)) {
|
||||
searchLangCandidate = rawUiLang;
|
||||
uiLangCandidate = rawSearchLang;
|
||||
}
|
||||
|
||||
const search_lang = normalizeBraveSearchLang(searchLangCandidate);
|
||||
if (searchLangCandidate && !search_lang) {
|
||||
return { invalidField: "search_lang" };
|
||||
}
|
||||
|
||||
const ui_lang = normalizeBraveUiLang(uiLangCandidate);
|
||||
if (uiLangCandidate && !ui_lang) {
|
||||
return { invalidField: "ui_lang" };
|
||||
}
|
||||
|
||||
return { search_lang, ui_lang };
|
||||
}
|
||||
|
||||
function isValidIsoDate(value: string): boolean {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||
return false;
|
||||
}
|
||||
const [year, month, day] = value.split("-").map((part) => Number.parseInt(part, 10));
|
||||
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) {
|
||||
return false;
|
||||
}
|
||||
const date = new Date(Date.UTC(year, month - 1, day));
|
||||
return (
|
||||
date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeFreshness(value: string | undefined): string | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (BRAVE_FRESHNESS_SHORTCUTS.has(lower)) {
|
||||
return lower;
|
||||
}
|
||||
const match = trimmed.match(BRAVE_FRESHNESS_RANGE);
|
||||
if (match) {
|
||||
const [, start, end] = match;
|
||||
if (isValidIsoDate(start) && isValidIsoDate(end) && start <= end) {
|
||||
return `${start}to${end}`;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildBraveCacheIdentity(params: {
|
||||
query: string;
|
||||
count: number;
|
||||
country?: string;
|
||||
search_lang?: string;
|
||||
ui_lang?: string;
|
||||
freshness?: string;
|
||||
dateAfter?: string;
|
||||
dateBefore?: string;
|
||||
braveMode: "web" | "llm-context";
|
||||
}): string {
|
||||
return [
|
||||
params.query,
|
||||
params.count,
|
||||
params.country || "default",
|
||||
params.search_lang || "default",
|
||||
params.ui_lang || "default",
|
||||
params.freshness || "default",
|
||||
params.dateAfter || "default",
|
||||
params.dateBefore || "default",
|
||||
params.braveMode,
|
||||
].join(":");
|
||||
}
|
||||
|
||||
async function throwBraveApiError(res: Response, label: string): Promise<never> {
|
||||
const detailResult = await readResponseText(res, { maxBytes: 64_000 });
|
||||
const detail = detailResult.text;
|
||||
throw new Error(`${label} API error (${res.status}): ${detail || res.statusText}`);
|
||||
}
|
||||
|
||||
function mapBraveLlmContextResults(
|
||||
data: BraveLlmContextResponse,
|
||||
): { url: string; title: string; snippets: string[]; siteName?: string }[] {
|
||||
const genericResults = Array.isArray(data.grounding?.generic) ? data.grounding.generic : [];
|
||||
return genericResults.map((entry) => ({
|
||||
url: entry.url ?? "",
|
||||
title: entry.title ?? "",
|
||||
snippets: (entry.snippets ?? []).filter((s) => typeof s === "string" && s.length > 0),
|
||||
siteName: resolveSiteName(entry.url) || undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
async function runBraveLlmContextSearch(params: {
|
||||
query: string;
|
||||
apiKey: string;
|
||||
timeoutSeconds: number;
|
||||
country?: string;
|
||||
search_lang?: string;
|
||||
freshness?: string;
|
||||
}) {
|
||||
const url = new URL(BRAVE_LLM_CONTEXT_ENDPOINT);
|
||||
url.searchParams.set("q", params.query);
|
||||
if (params.country) {
|
||||
url.searchParams.set("country", params.country);
|
||||
}
|
||||
if (params.search_lang) {
|
||||
url.searchParams.set("search_lang", params.search_lang);
|
||||
}
|
||||
if (params.freshness) {
|
||||
url.searchParams.set("freshness", params.freshness);
|
||||
}
|
||||
|
||||
return withTrustedWebToolsEndpoint(
|
||||
{
|
||||
url: url.toString(),
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
init: {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"X-Subscription-Token": params.apiKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
async ({ response }) => {
|
||||
if (!response.ok) {
|
||||
return await throwBraveApiError(response, "Brave LLM Context");
|
||||
}
|
||||
const data = (await response.json()) as BraveLlmContextResponse;
|
||||
return { results: mapBraveLlmContextResults(data), sources: data.sources };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function runBraveWebSearch(params: {
|
||||
query: string;
|
||||
count: number;
|
||||
apiKey: string;
|
||||
timeoutSeconds: number;
|
||||
country?: string;
|
||||
search_lang?: string;
|
||||
ui_lang?: string;
|
||||
freshness?: string;
|
||||
dateAfter?: string;
|
||||
dateBefore?: string;
|
||||
}) {
|
||||
const url = new URL(BRAVE_SEARCH_ENDPOINT);
|
||||
url.searchParams.set("q", params.query);
|
||||
url.searchParams.set("count", String(params.count));
|
||||
if (params.country) {
|
||||
url.searchParams.set("country", params.country);
|
||||
}
|
||||
if (params.search_lang) {
|
||||
url.searchParams.set("search_lang", params.search_lang);
|
||||
}
|
||||
if (params.ui_lang) {
|
||||
url.searchParams.set("ui_lang", params.ui_lang);
|
||||
}
|
||||
if (params.freshness) {
|
||||
url.searchParams.set("freshness", params.freshness);
|
||||
} else if (params.dateAfter && params.dateBefore) {
|
||||
url.searchParams.set("freshness", `${params.dateAfter}to${params.dateBefore}`);
|
||||
} else if (params.dateAfter) {
|
||||
url.searchParams.set(
|
||||
"freshness",
|
||||
`${params.dateAfter}to${new Date().toISOString().slice(0, 10)}`,
|
||||
);
|
||||
} else if (params.dateBefore) {
|
||||
url.searchParams.set("freshness", `1970-01-01to${params.dateBefore}`);
|
||||
}
|
||||
|
||||
return withTrustedWebToolsEndpoint(
|
||||
{
|
||||
url: url.toString(),
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
init: {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"X-Subscription-Token": params.apiKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
async ({ response }) => {
|
||||
if (!response.ok) {
|
||||
return await throwBraveApiError(response, "Brave Search");
|
||||
}
|
||||
const data = (await response.json()) as BraveSearchResponse;
|
||||
const results = Array.isArray(data.web?.results) ? (data.web?.results ?? []) : [];
|
||||
return results.map((entry) => {
|
||||
const description = entry.description ?? "";
|
||||
const title = entry.title ?? "";
|
||||
const url = entry.url ?? "";
|
||||
return {
|
||||
title: title ? wrapWebContent(title, "web_search") : "",
|
||||
url,
|
||||
description: description ? wrapWebContent(description, "web_search") : "",
|
||||
published: entry.age || undefined,
|
||||
siteName: resolveSiteName(url) || undefined,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const BRAVE_SEARCH_PROVIDER_METADATA: SearchProviderSetupMetadata =
|
||||
createSearchProviderSetupMetadata({
|
||||
provider: "brave",
|
||||
label: "Brave Search",
|
||||
hint: "Structured results · country/language/time filters",
|
||||
envKeys: ["BRAVE_API_KEY"],
|
||||
placeholder: "BSA...",
|
||||
signupUrl: "https://brave.com/search/api/",
|
||||
apiKeyConfigPath: "tools.web.search.apiKey",
|
||||
autodetectPriority: 10,
|
||||
requestSchema: Type.Object({
|
||||
query: Type.String({ description: "Search query string." }),
|
||||
count: Type.Optional(Type.Number({ minimum: 1, maximum: MAX_SEARCH_COUNT })),
|
||||
country: Type.Optional(Type.String()),
|
||||
language: Type.Optional(Type.String()),
|
||||
freshness: Type.Optional(Type.String()),
|
||||
date_after: Type.Optional(Type.String()),
|
||||
date_before: Type.Optional(Type.String()),
|
||||
search_lang: Type.Optional(Type.String()),
|
||||
ui_lang: Type.Optional(Type.String()),
|
||||
}),
|
||||
readApiKeyValue: (search) =>
|
||||
search && typeof search === "object" && !Array.isArray(search) ? search.apiKey : undefined,
|
||||
writeApiKeyValue: (search, value) => {
|
||||
search.apiKey = value;
|
||||
},
|
||||
});
|
||||
|
||||
export function createBundledBraveSearchProvider(): SearchProviderPlugin {
|
||||
return {
|
||||
id: "brave",
|
||||
name: "Brave Search",
|
||||
description:
|
||||
"Search the web using Brave Search. Supports web and llm-context modes, region-specific search, and localized search parameters.",
|
||||
pluginOwnedExecution: true,
|
||||
docsUrl: "https://brave.com/search/api/",
|
||||
setup: BRAVE_SEARCH_PROVIDER_METADATA,
|
||||
isAvailable: (config) => {
|
||||
const search = config?.tools?.web?.search;
|
||||
return Boolean(
|
||||
resolveBraveApiKey(resolveSearchConfig<WebSearchConfig>(search as Record<string, unknown>)),
|
||||
);
|
||||
},
|
||||
search: async (request, ctx): Promise<SearchProviderExecutionResult> => {
|
||||
const search = resolveSearchConfig<WebSearchConfig>(request.providerConfig);
|
||||
const braveConfig = resolveBraveConfig(search);
|
||||
const braveMode = resolveBraveMode(braveConfig);
|
||||
const apiKey = resolveBraveApiKey(search);
|
||||
|
||||
if (!apiKey) {
|
||||
return createMissingSearchKeyPayload(
|
||||
"missing_brave_api_key",
|
||||
`web_search (brave) needs a Brave Search API key. Run \`${formatCliCommand("openclaw configure --section web")}\` to store it, or set BRAVE_API_KEY in the Gateway environment.`,
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedLanguageParams = normalizeBraveLanguageParams({
|
||||
search_lang: request.search_lang || request.language,
|
||||
ui_lang: request.ui_lang,
|
||||
});
|
||||
if (normalizedLanguageParams.invalidField === "search_lang") {
|
||||
return {
|
||||
error: "invalid_search_lang",
|
||||
message:
|
||||
"search_lang must be a Brave-supported language code like 'en', 'en-gb', 'zh-hans', or 'zh-hant'.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
if (normalizedLanguageParams.invalidField === "ui_lang") {
|
||||
return {
|
||||
error: "invalid_ui_lang",
|
||||
message: "ui_lang must be a language-region locale like 'en-US'.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
if (normalizedLanguageParams.ui_lang && braveMode === "llm-context") {
|
||||
return {
|
||||
error: "unsupported_ui_lang",
|
||||
message:
|
||||
"ui_lang is not supported by Brave llm-context mode. Remove ui_lang or use Brave web mode for locale-based UI hints.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
if (request.freshness && braveMode === "llm-context") {
|
||||
return {
|
||||
error: "unsupported_freshness",
|
||||
message:
|
||||
"freshness filtering is not supported by Brave llm-context mode. Remove freshness or use Brave web mode.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
const normalizedFreshness = request.freshness
|
||||
? normalizeFreshness(request.freshness)
|
||||
: undefined;
|
||||
if (request.freshness && !normalizedFreshness) {
|
||||
return {
|
||||
error: "invalid_freshness",
|
||||
message: "freshness must be day, week, month, or year.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
if ((request.dateAfter || request.dateBefore) && braveMode === "llm-context") {
|
||||
return {
|
||||
error: "unsupported_date_filter",
|
||||
message:
|
||||
"date_after/date_before filtering is not supported by Brave llm-context mode. Use Brave web mode for date filters.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
|
||||
const cacheKey = normalizeCacheKey(
|
||||
`brave:${buildBraveCacheIdentity({
|
||||
query: request.query,
|
||||
count: request.count,
|
||||
country: request.country,
|
||||
search_lang: normalizedLanguageParams.search_lang,
|
||||
ui_lang: normalizedLanguageParams.ui_lang,
|
||||
freshness: normalizedFreshness,
|
||||
dateAfter: request.dateAfter,
|
||||
dateBefore: request.dateBefore,
|
||||
braveMode,
|
||||
})}`,
|
||||
);
|
||||
const cached = readCache(BRAVE_SEARCH_CACHE, cacheKey);
|
||||
if (cached) {
|
||||
return { ...cached.value, cached: true } as Record<
|
||||
string,
|
||||
unknown
|
||||
> as SearchProviderExecutionResult;
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
if (braveMode === "llm-context") {
|
||||
const { results, sources } = await runBraveLlmContextSearch({
|
||||
query: request.query,
|
||||
apiKey,
|
||||
timeoutSeconds: ctx.timeoutSeconds,
|
||||
country: request.country,
|
||||
search_lang: normalizedLanguageParams.search_lang,
|
||||
freshness: normalizedFreshness,
|
||||
});
|
||||
const mappedResults = results.map(
|
||||
(entry: { title: string; url: string; snippets: string[]; siteName?: string }) => ({
|
||||
title: entry.title ? wrapWebContent(entry.title, "web_search") : "",
|
||||
url: entry.url,
|
||||
snippets: entry.snippets.map((s: string) => wrapWebContent(s, "web_search")),
|
||||
siteName: entry.siteName,
|
||||
}),
|
||||
);
|
||||
const payload = {
|
||||
query: request.query,
|
||||
provider: "brave",
|
||||
mode: "llm-context" as const,
|
||||
count: mappedResults.length,
|
||||
tookMs: Date.now() - startedAt,
|
||||
externalContent: {
|
||||
untrusted: true,
|
||||
source: "web_search",
|
||||
provider: "brave",
|
||||
wrapped: true,
|
||||
},
|
||||
results: mappedResults,
|
||||
sources,
|
||||
};
|
||||
writeCache(BRAVE_SEARCH_CACHE, cacheKey, payload, ctx.cacheTtlMs);
|
||||
return payload as Record<string, unknown> as SearchProviderExecutionResult;
|
||||
}
|
||||
|
||||
const results = await runBraveWebSearch({
|
||||
query: request.query,
|
||||
count: request.count,
|
||||
apiKey,
|
||||
timeoutSeconds: ctx.timeoutSeconds,
|
||||
country: request.country,
|
||||
search_lang: normalizedLanguageParams.search_lang,
|
||||
ui_lang: normalizedLanguageParams.ui_lang,
|
||||
freshness: normalizedFreshness,
|
||||
dateAfter: request.dateAfter,
|
||||
dateBefore: request.dateBefore,
|
||||
});
|
||||
const payload = {
|
||||
query: request.query,
|
||||
provider: "brave",
|
||||
count: results.length,
|
||||
tookMs: Date.now() - startedAt,
|
||||
externalContent: {
|
||||
untrusted: true,
|
||||
source: "web_search",
|
||||
provider: "brave",
|
||||
wrapped: true,
|
||||
},
|
||||
results,
|
||||
};
|
||||
writeCache(BRAVE_SEARCH_CACHE, cacheKey, payload, ctx.cacheTtlMs);
|
||||
return payload as Record<string, unknown> as SearchProviderExecutionResult;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
resolveBraveApiKey,
|
||||
resolveBraveMode,
|
||||
normalizeBraveLanguageParams,
|
||||
normalizeFreshness,
|
||||
clearSearchProviderCaches() {
|
||||
BRAVE_SEARCH_CACHE.clear();
|
||||
},
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"id": "search-gemini",
|
||||
"defaultEnabledWhenBundled": true,
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
},
|
||||
"provides": ["providers.search.gemini"]
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"name": "@openclaw/search-gemini",
|
||||
"version": "2026.3.12",
|
||||
"private": true,
|
||||
"description": "OpenClaw Gemini search plugin",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./src/index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { createBundledGeminiSearchProvider } from "./provider.js";
|
||||
|
||||
const plugin = {
|
||||
id: "search-gemini",
|
||||
name: "Gemini Search",
|
||||
description: "Bundled Gemini web search provider for OpenClaw.",
|
||||
register(api: OpenClawPluginApi) {
|
||||
api.registerSearchProvider(createBundledGeminiSearchProvider());
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
@@ -1,261 +0,0 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import {
|
||||
buildSearchRequestCacheIdentity,
|
||||
createSearchProviderSetupMetadata,
|
||||
createMissingSearchKeyPayload,
|
||||
normalizeCacheKey,
|
||||
normalizeSecretInput,
|
||||
readCache,
|
||||
readResponseText,
|
||||
rejectUnsupportedSearchFilters,
|
||||
resolveCitationRedirectUrl,
|
||||
resolveSearchConfig,
|
||||
resolveSearchProviderSectionConfig,
|
||||
type OpenClawConfig,
|
||||
type SearchProviderExecutionResult,
|
||||
type SearchProviderSetupMetadata,
|
||||
type SearchProviderPlugin,
|
||||
withTrustedWebToolsEndpoint,
|
||||
wrapWebContent,
|
||||
writeCache,
|
||||
} from "openclaw/plugin-sdk/web-search";
|
||||
|
||||
const DEFAULT_GEMINI_MODEL = "gemini-2.5-flash";
|
||||
const GEMINI_API_BASE = "https://generativelanguage.googleapis.com/v1beta";
|
||||
|
||||
const GEMINI_SEARCH_CACHE = new Map<
|
||||
string,
|
||||
{ value: Record<string, unknown>; expiresAt: number }
|
||||
>();
|
||||
const MAX_SEARCH_COUNT = 10;
|
||||
|
||||
type WebSearchConfig = NonNullable<OpenClawConfig["tools"]>["web"] extends infer Web
|
||||
? Web extends { search?: infer Search }
|
||||
? Search
|
||||
: undefined
|
||||
: undefined;
|
||||
|
||||
type GeminiConfig = {
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
type GeminiGroundingResponse = {
|
||||
candidates?: Array<{
|
||||
content?: {
|
||||
parts?: Array<{
|
||||
text?: string;
|
||||
}>;
|
||||
};
|
||||
groundingMetadata?: {
|
||||
groundingChunks?: Array<{
|
||||
web?: {
|
||||
uri?: string;
|
||||
title?: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
error?: {
|
||||
code?: number;
|
||||
message?: string;
|
||||
status?: string;
|
||||
};
|
||||
};
|
||||
|
||||
function resolveGeminiConfig(search?: WebSearchConfig): GeminiConfig {
|
||||
return resolveSearchProviderSectionConfig<GeminiConfig>(
|
||||
search as Record<string, unknown> | undefined,
|
||||
"gemini",
|
||||
);
|
||||
}
|
||||
|
||||
function resolveGeminiApiKey(gemini?: GeminiConfig): string | undefined {
|
||||
return (
|
||||
normalizeSecretInput(gemini?.apiKey) ||
|
||||
normalizeSecretInput(process.env.GEMINI_API_KEY) ||
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
function resolveGeminiModel(gemini?: GeminiConfig): string {
|
||||
const fromConfig =
|
||||
gemini && "model" in gemini && typeof gemini.model === "string" ? gemini.model.trim() : "";
|
||||
return fromConfig || DEFAULT_GEMINI_MODEL;
|
||||
}
|
||||
|
||||
async function runGeminiSearch(params: {
|
||||
query: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
timeoutSeconds: number;
|
||||
}): Promise<{ content: string; citations: Array<{ url: string; title?: string }> }> {
|
||||
const endpoint = `${GEMINI_API_BASE}/models/${params.model}:generateContent`;
|
||||
return withTrustedWebToolsEndpoint(
|
||||
{
|
||||
url: endpoint,
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-goog-api-key": params.apiKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
contents: [{ parts: [{ text: params.query }] }],
|
||||
tools: [{ google_search: {} }],
|
||||
}),
|
||||
},
|
||||
},
|
||||
async ({ response }) => {
|
||||
if (!response.ok) {
|
||||
const detailResult = await readResponseText(response, { maxBytes: 64_000 });
|
||||
const safeDetail = (detailResult.text || response.statusText).replace(
|
||||
/key=[^&\s]+/gi,
|
||||
"key=***",
|
||||
);
|
||||
throw new Error(`Gemini API error (${response.status}): ${safeDetail}`);
|
||||
}
|
||||
let data: GeminiGroundingResponse;
|
||||
try {
|
||||
data = (await response.json()) as GeminiGroundingResponse;
|
||||
} catch (err) {
|
||||
const safeError = String(err).replace(/key=[^&\s]+/gi, "key=***");
|
||||
throw new Error(`Gemini API returned invalid JSON: ${safeError}`, { cause: err });
|
||||
}
|
||||
if (data.error) {
|
||||
const rawMsg = data.error.message || data.error.status || "unknown";
|
||||
const safeMsg = rawMsg.replace(/key=[^&\s]+/gi, "key=***");
|
||||
throw new Error(`Gemini API error (${data.error.code}): ${safeMsg}`);
|
||||
}
|
||||
const candidate = data.candidates?.[0];
|
||||
const content =
|
||||
candidate?.content?.parts
|
||||
?.map((p) => p.text)
|
||||
.filter(Boolean)
|
||||
.join("\n") ?? "No response";
|
||||
const groundingChunks = candidate?.groundingMetadata?.groundingChunks ?? [];
|
||||
const rawCitations = groundingChunks
|
||||
.filter((chunk) => chunk.web?.uri)
|
||||
.map((chunk) => ({
|
||||
url: chunk.web!.uri!,
|
||||
title: chunk.web?.title || undefined,
|
||||
}));
|
||||
const citations: Array<{ url: string; title?: string }> = [];
|
||||
const MAX_CONCURRENT_REDIRECTS = 10;
|
||||
for (let i = 0; i < rawCitations.length; i += MAX_CONCURRENT_REDIRECTS) {
|
||||
const batch = rawCitations.slice(i, i + MAX_CONCURRENT_REDIRECTS);
|
||||
const resolved = await Promise.all(
|
||||
batch.map(async (citation) => ({
|
||||
...citation,
|
||||
url: await resolveCitationRedirectUrl(citation.url),
|
||||
})),
|
||||
);
|
||||
citations.push(...resolved);
|
||||
}
|
||||
return { content, citations };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const GEMINI_SEARCH_PROVIDER_METADATA: SearchProviderSetupMetadata =
|
||||
createSearchProviderSetupMetadata({
|
||||
provider: "gemini",
|
||||
label: "Gemini (Google Search)",
|
||||
hint: "Google Search grounding · AI-synthesized",
|
||||
envKeys: ["GEMINI_API_KEY"],
|
||||
placeholder: "AIza...",
|
||||
signupUrl: "https://aistudio.google.com/apikey",
|
||||
apiKeyConfigPath: "tools.web.search.gemini.apiKey",
|
||||
autodetectPriority: 20,
|
||||
requestSchema: Type.Object({
|
||||
query: Type.String({ description: "Search query string." }),
|
||||
count: Type.Optional(Type.Number({ minimum: 1, maximum: MAX_SEARCH_COUNT })),
|
||||
}),
|
||||
});
|
||||
|
||||
export function createBundledGeminiSearchProvider(): SearchProviderPlugin {
|
||||
return {
|
||||
id: "gemini",
|
||||
name: "Gemini Search",
|
||||
description:
|
||||
"Search the web using Gemini with Google Search grounding. Returns AI-synthesized answers with citations from Google Search.",
|
||||
pluginOwnedExecution: true,
|
||||
setup: GEMINI_SEARCH_PROVIDER_METADATA,
|
||||
isAvailable: (config) =>
|
||||
Boolean(
|
||||
resolveGeminiApiKey(
|
||||
resolveGeminiConfig(
|
||||
resolveSearchConfig<WebSearchConfig>(
|
||||
config?.tools?.web?.search as Record<string, unknown>,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
search: async (request, ctx): Promise<SearchProviderExecutionResult> => {
|
||||
const search = resolveSearchConfig<WebSearchConfig>(request.providerConfig);
|
||||
const geminiConfig = resolveGeminiConfig(search);
|
||||
const apiKey = resolveGeminiApiKey(geminiConfig);
|
||||
if (!apiKey) {
|
||||
return createMissingSearchKeyPayload(
|
||||
"missing_gemini_api_key",
|
||||
"web_search (gemini) needs an API key. Set GEMINI_API_KEY in the Gateway environment, or configure tools.web.search.gemini.apiKey.",
|
||||
);
|
||||
}
|
||||
const unsupportedFilter = rejectUnsupportedSearchFilters({
|
||||
providerName: "gemini",
|
||||
request,
|
||||
support: {
|
||||
country: false,
|
||||
language: false,
|
||||
freshness: false,
|
||||
date: false,
|
||||
domainFilter: false,
|
||||
},
|
||||
});
|
||||
if (unsupportedFilter) {
|
||||
return unsupportedFilter;
|
||||
}
|
||||
|
||||
const model = resolveGeminiModel(geminiConfig);
|
||||
const cacheKey = normalizeCacheKey(
|
||||
`gemini:${model}:${buildSearchRequestCacheIdentity({
|
||||
query: request.query,
|
||||
count: request.count,
|
||||
})}`,
|
||||
);
|
||||
const cached = readCache(GEMINI_SEARCH_CACHE, cacheKey);
|
||||
if (cached) return { ...cached.value, cached: true } as SearchProviderExecutionResult;
|
||||
const startedAt = Date.now();
|
||||
const result = await runGeminiSearch({
|
||||
query: request.query,
|
||||
apiKey,
|
||||
model,
|
||||
timeoutSeconds: ctx.timeoutSeconds,
|
||||
});
|
||||
const payload = {
|
||||
query: request.query,
|
||||
provider: "gemini",
|
||||
model,
|
||||
tookMs: Date.now() - startedAt,
|
||||
externalContent: {
|
||||
untrusted: true,
|
||||
source: "web_search",
|
||||
provider: "gemini",
|
||||
wrapped: true,
|
||||
},
|
||||
content: wrapWebContent(result.content),
|
||||
citations: result.citations,
|
||||
};
|
||||
writeCache(GEMINI_SEARCH_CACHE, cacheKey, payload, ctx.cacheTtlMs);
|
||||
return payload as SearchProviderExecutionResult;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
GEMINI_SEARCH_CACHE,
|
||||
clearSearchProviderCaches() {
|
||||
GEMINI_SEARCH_CACHE.clear();
|
||||
},
|
||||
} as const;
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"id": "search-grok",
|
||||
"defaultEnabledWhenBundled": true,
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
},
|
||||
"provides": ["providers.search.grok"]
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"name": "@openclaw/search-grok",
|
||||
"version": "2026.3.12",
|
||||
"private": true,
|
||||
"description": "OpenClaw Grok search plugin",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./src/index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { createBundledGrokSearchProvider } from "./provider.js";
|
||||
|
||||
const plugin = {
|
||||
id: "search-grok",
|
||||
name: "Grok Search",
|
||||
description: "Bundled xAI Grok web search provider for OpenClaw.",
|
||||
register(api: OpenClawPluginApi) {
|
||||
api.registerSearchProvider(createBundledGrokSearchProvider());
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
@@ -1,272 +0,0 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import {
|
||||
buildSearchRequestCacheIdentity,
|
||||
createSearchProviderSetupMetadata,
|
||||
createMissingSearchKeyPayload,
|
||||
normalizeCacheKey,
|
||||
normalizeSecretInput,
|
||||
readCache,
|
||||
rejectUnsupportedSearchFilters,
|
||||
resolveSearchConfig,
|
||||
resolveSearchProviderSectionConfig,
|
||||
throwWebSearchApiError,
|
||||
type OpenClawConfig,
|
||||
type SearchProviderExecutionResult,
|
||||
type SearchProviderSetupMetadata,
|
||||
type SearchProviderPlugin,
|
||||
withTrustedWebToolsEndpoint,
|
||||
wrapWebContent,
|
||||
writeCache,
|
||||
} from "openclaw/plugin-sdk/web-search";
|
||||
|
||||
const XAI_API_ENDPOINT = "https://api.x.ai/v1/responses";
|
||||
const DEFAULT_GROK_MODEL = "grok-4-1-fast";
|
||||
|
||||
const GROK_SEARCH_CACHE = new Map<string, { value: Record<string, unknown>; expiresAt: number }>();
|
||||
const MAX_SEARCH_COUNT = 10;
|
||||
|
||||
type WebSearchConfig = NonNullable<OpenClawConfig["tools"]>["web"] extends infer Web
|
||||
? Web extends { search?: infer Search }
|
||||
? Search
|
||||
: undefined
|
||||
: undefined;
|
||||
|
||||
type GrokConfig = {
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
inlineCitations?: boolean;
|
||||
};
|
||||
|
||||
type GrokSearchResponse = {
|
||||
output?: Array<{
|
||||
type?: string;
|
||||
role?: string;
|
||||
text?: string;
|
||||
content?: Array<{
|
||||
type?: string;
|
||||
text?: string;
|
||||
annotations?: Array<{
|
||||
type?: string;
|
||||
url?: string;
|
||||
start_index?: number;
|
||||
end_index?: number;
|
||||
}>;
|
||||
}>;
|
||||
annotations?: Array<{
|
||||
type?: string;
|
||||
url?: string;
|
||||
start_index?: number;
|
||||
end_index?: number;
|
||||
}>;
|
||||
}>;
|
||||
output_text?: string;
|
||||
citations?: string[];
|
||||
inline_citations?: Array<{
|
||||
start_index: number;
|
||||
end_index: number;
|
||||
url: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
function resolveGrokConfig(search?: WebSearchConfig): GrokConfig {
|
||||
return resolveSearchProviderSectionConfig<GrokConfig>(
|
||||
search as Record<string, unknown> | undefined,
|
||||
"grok",
|
||||
);
|
||||
}
|
||||
|
||||
function resolveGrokApiKey(grok?: GrokConfig): string | undefined {
|
||||
return (
|
||||
normalizeSecretInput(grok?.apiKey) || normalizeSecretInput(process.env.XAI_API_KEY) || undefined
|
||||
);
|
||||
}
|
||||
|
||||
function resolveGrokModel(grok?: GrokConfig): string {
|
||||
const fromConfig =
|
||||
grok && "model" in grok && typeof grok.model === "string" ? grok.model.trim() : "";
|
||||
return fromConfig || DEFAULT_GROK_MODEL;
|
||||
}
|
||||
|
||||
function resolveGrokInlineCitations(grok?: GrokConfig): boolean {
|
||||
return grok?.inlineCitations === true;
|
||||
}
|
||||
|
||||
function extractGrokContent(data: GrokSearchResponse): {
|
||||
text: string | undefined;
|
||||
annotationCitations: string[];
|
||||
} {
|
||||
for (const output of data.output ?? []) {
|
||||
if (output.type === "message") {
|
||||
for (const block of output.content ?? []) {
|
||||
if (block.type === "output_text" && typeof block.text === "string" && block.text) {
|
||||
const urls = (block.annotations ?? [])
|
||||
.filter((a) => a.type === "url_citation" && typeof a.url === "string")
|
||||
.map((a) => a.url as string);
|
||||
return { text: block.text, annotationCitations: [...new Set(urls)] };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
output.type === "output_text" &&
|
||||
"text" in output &&
|
||||
typeof output.text === "string" &&
|
||||
output.text
|
||||
) {
|
||||
const rawAnnotations =
|
||||
"annotations" in output && Array.isArray(output.annotations) ? output.annotations : [];
|
||||
const urls = rawAnnotations
|
||||
.filter(
|
||||
(a: Record<string, unknown>) => a.type === "url_citation" && typeof a.url === "string",
|
||||
)
|
||||
.map((a: Record<string, unknown>) => a.url as string);
|
||||
return { text: output.text, annotationCitations: [...new Set(urls)] };
|
||||
}
|
||||
}
|
||||
const text = typeof data.output_text === "string" ? data.output_text : undefined;
|
||||
return { text, annotationCitations: [] };
|
||||
}
|
||||
|
||||
async function runGrokSearch(params: {
|
||||
query: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
timeoutSeconds: number;
|
||||
inlineCitations: boolean;
|
||||
}) {
|
||||
const body: Record<string, unknown> = {
|
||||
model: params.model,
|
||||
input: [{ role: "user", content: params.query }],
|
||||
tools: [{ type: "web_search" }],
|
||||
};
|
||||
return withTrustedWebToolsEndpoint(
|
||||
{
|
||||
url: XAI_API_ENDPOINT,
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
},
|
||||
async ({ response }) => {
|
||||
if (!response.ok) {
|
||||
return await throwWebSearchApiError(response, "xAI");
|
||||
}
|
||||
const data = (await response.json()) as GrokSearchResponse;
|
||||
const { text: extractedText, annotationCitations } = extractGrokContent(data);
|
||||
return {
|
||||
content: extractedText ?? "No response",
|
||||
citations: (data.citations ?? []).length > 0 ? data.citations! : annotationCitations,
|
||||
inlineCitations: data.inline_citations,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const GROK_SEARCH_PROVIDER_METADATA: SearchProviderSetupMetadata =
|
||||
createSearchProviderSetupMetadata({
|
||||
provider: "grok",
|
||||
label: "Grok (xAI)",
|
||||
hint: "xAI web-grounded responses",
|
||||
envKeys: ["XAI_API_KEY"],
|
||||
placeholder: "xai-...",
|
||||
signupUrl: "https://console.x.ai/",
|
||||
apiKeyConfigPath: "tools.web.search.grok.apiKey",
|
||||
autodetectPriority: 30,
|
||||
requestSchema: Type.Object({
|
||||
query: Type.String({ description: "Search query string." }),
|
||||
count: Type.Optional(Type.Number({ minimum: 1, maximum: MAX_SEARCH_COUNT })),
|
||||
}),
|
||||
});
|
||||
|
||||
export function createBundledGrokSearchProvider(): SearchProviderPlugin {
|
||||
return {
|
||||
id: "grok",
|
||||
name: "xAI Grok",
|
||||
description:
|
||||
"Search the web using xAI Grok. Returns AI-synthesized answers with citations from real-time web search.",
|
||||
pluginOwnedExecution: true,
|
||||
setup: GROK_SEARCH_PROVIDER_METADATA,
|
||||
isAvailable: (config) =>
|
||||
Boolean(
|
||||
resolveGrokApiKey(
|
||||
resolveGrokConfig(
|
||||
resolveSearchConfig<WebSearchConfig>(
|
||||
config?.tools?.web?.search as Record<string, unknown>,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
search: async (request, ctx): Promise<SearchProviderExecutionResult> => {
|
||||
const search = resolveSearchConfig<WebSearchConfig>(request.providerConfig);
|
||||
const grokConfig = resolveGrokConfig(search);
|
||||
const apiKey = resolveGrokApiKey(grokConfig);
|
||||
if (!apiKey) {
|
||||
return createMissingSearchKeyPayload(
|
||||
"missing_xai_api_key",
|
||||
"web_search (grok) needs an xAI API key. Set XAI_API_KEY in the Gateway environment, or configure tools.web.search.grok.apiKey.",
|
||||
);
|
||||
}
|
||||
const unsupportedFilter = rejectUnsupportedSearchFilters({
|
||||
providerName: "grok",
|
||||
request,
|
||||
support: {
|
||||
country: false,
|
||||
language: false,
|
||||
freshness: false,
|
||||
date: false,
|
||||
domainFilter: false,
|
||||
},
|
||||
});
|
||||
if (unsupportedFilter) {
|
||||
return unsupportedFilter;
|
||||
}
|
||||
|
||||
const model = resolveGrokModel(grokConfig);
|
||||
const inlineCitationsEnabled = resolveGrokInlineCitations(grokConfig);
|
||||
const cacheKey = normalizeCacheKey(
|
||||
`grok:${model}:${String(inlineCitationsEnabled)}:${buildSearchRequestCacheIdentity({
|
||||
query: request.query,
|
||||
count: request.count,
|
||||
})}`,
|
||||
);
|
||||
const cached = readCache(GROK_SEARCH_CACHE, cacheKey);
|
||||
if (cached) return { ...cached.value, cached: true } as SearchProviderExecutionResult;
|
||||
const startedAt = Date.now();
|
||||
const result = await runGrokSearch({
|
||||
query: request.query,
|
||||
apiKey,
|
||||
model,
|
||||
timeoutSeconds: ctx.timeoutSeconds,
|
||||
inlineCitations: inlineCitationsEnabled,
|
||||
});
|
||||
const payload = {
|
||||
query: request.query,
|
||||
provider: "grok",
|
||||
model,
|
||||
tookMs: Date.now() - startedAt,
|
||||
externalContent: {
|
||||
untrusted: true,
|
||||
source: "web_search",
|
||||
provider: "grok",
|
||||
wrapped: true,
|
||||
},
|
||||
content: wrapWebContent(result.content),
|
||||
citations: result.citations,
|
||||
inlineCitations: result.inlineCitations,
|
||||
};
|
||||
writeCache(GROK_SEARCH_CACHE, cacheKey, payload, ctx.cacheTtlMs);
|
||||
return payload as SearchProviderExecutionResult;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
GROK_SEARCH_CACHE,
|
||||
clearSearchProviderCaches() {
|
||||
GROK_SEARCH_CACHE.clear();
|
||||
},
|
||||
} as const;
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"id": "search-kimi",
|
||||
"defaultEnabledWhenBundled": true,
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
},
|
||||
"provides": ["providers.search.kimi"]
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"name": "@openclaw/search-kimi",
|
||||
"version": "2026.3.12",
|
||||
"private": true,
|
||||
"description": "OpenClaw Kimi search plugin",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./src/index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { createBundledKimiSearchProvider } from "./provider.js";
|
||||
|
||||
const plugin = {
|
||||
id: "search-kimi",
|
||||
name: "Kimi Search",
|
||||
description: "Bundled Kimi web search provider for OpenClaw.",
|
||||
register(api: OpenClawPluginApi) {
|
||||
api.registerSearchProvider(createBundledKimiSearchProvider());
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
@@ -1,323 +0,0 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import {
|
||||
buildSearchRequestCacheIdentity,
|
||||
createSearchProviderSetupMetadata,
|
||||
createMissingSearchKeyPayload,
|
||||
normalizeCacheKey,
|
||||
normalizeSecretInput,
|
||||
readCache,
|
||||
rejectUnsupportedSearchFilters,
|
||||
resolveSearchConfig,
|
||||
resolveSearchProviderSectionConfig,
|
||||
type OpenClawConfig,
|
||||
type SearchProviderExecutionResult,
|
||||
type SearchProviderSetupMetadata,
|
||||
type SearchProviderPlugin,
|
||||
withTrustedWebToolsEndpoint,
|
||||
wrapWebContent,
|
||||
writeCache,
|
||||
} from "openclaw/plugin-sdk/web-search";
|
||||
|
||||
const DEFAULT_KIMI_BASE_URL = "https://api.moonshot.ai/v1";
|
||||
const DEFAULT_KIMI_MODEL = "moonshot-v1-128k";
|
||||
const KIMI_WEB_SEARCH_TOOL = {
|
||||
type: "builtin_function",
|
||||
function: { name: "$web_search" },
|
||||
} as const;
|
||||
|
||||
const KIMI_SEARCH_CACHE = new Map<string, { value: Record<string, unknown>; expiresAt: number }>();
|
||||
const MAX_SEARCH_COUNT = 10;
|
||||
|
||||
type WebSearchConfig = NonNullable<OpenClawConfig["tools"]>["web"] extends infer Web
|
||||
? Web extends { search?: infer Search }
|
||||
? Search
|
||||
: undefined
|
||||
: undefined;
|
||||
|
||||
type KimiConfig = {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
type KimiToolCall = {
|
||||
id?: string;
|
||||
type?: string;
|
||||
function?: {
|
||||
name?: string;
|
||||
arguments?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type KimiMessage = {
|
||||
role?: string;
|
||||
content?: string;
|
||||
reasoning_content?: string;
|
||||
tool_calls?: KimiToolCall[];
|
||||
};
|
||||
|
||||
type KimiSearchResponse = {
|
||||
choices?: Array<{
|
||||
finish_reason?: string;
|
||||
message?: KimiMessage;
|
||||
}>;
|
||||
search_results?: Array<{
|
||||
title?: string;
|
||||
url?: string;
|
||||
content?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
function resolveKimiConfig(search?: WebSearchConfig): KimiConfig {
|
||||
return resolveSearchProviderSectionConfig<KimiConfig>(
|
||||
search as Record<string, unknown> | undefined,
|
||||
"kimi",
|
||||
);
|
||||
}
|
||||
|
||||
function resolveKimiApiKey(kimi?: KimiConfig): string | undefined {
|
||||
return (
|
||||
normalizeSecretInput(kimi?.apiKey) ||
|
||||
normalizeSecretInput(process.env.KIMI_API_KEY) ||
|
||||
normalizeSecretInput(process.env.MOONSHOT_API_KEY) ||
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
function resolveKimiModel(kimi?: KimiConfig): string {
|
||||
const fromConfig =
|
||||
kimi && "model" in kimi && typeof kimi.model === "string" ? kimi.model.trim() : "";
|
||||
return fromConfig || DEFAULT_KIMI_MODEL;
|
||||
}
|
||||
|
||||
function resolveKimiBaseUrl(kimi?: KimiConfig): string {
|
||||
const fromConfig =
|
||||
kimi && "baseUrl" in kimi && typeof kimi.baseUrl === "string" ? kimi.baseUrl.trim() : "";
|
||||
return fromConfig || DEFAULT_KIMI_BASE_URL;
|
||||
}
|
||||
|
||||
function extractKimiMessageText(message: KimiMessage | undefined): string | undefined {
|
||||
const content = message?.content?.trim();
|
||||
if (content) return content;
|
||||
const reasoning = message?.reasoning_content?.trim();
|
||||
return reasoning || undefined;
|
||||
}
|
||||
|
||||
function extractKimiCitations(data: KimiSearchResponse): string[] {
|
||||
const citations = (data.search_results ?? [])
|
||||
.map((entry) => entry.url?.trim())
|
||||
.filter((url): url is string => Boolean(url));
|
||||
for (const toolCall of data.choices?.[0]?.message?.tool_calls ?? []) {
|
||||
const rawArguments = toolCall.function?.arguments;
|
||||
if (!rawArguments) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(rawArguments) as {
|
||||
search_results?: Array<{ url?: string }>;
|
||||
url?: string;
|
||||
};
|
||||
if (typeof parsed.url === "string" && parsed.url.trim()) citations.push(parsed.url.trim());
|
||||
for (const result of parsed.search_results ?? []) {
|
||||
if (typeof result.url === "string" && result.url.trim()) citations.push(result.url.trim());
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed tool arguments
|
||||
}
|
||||
}
|
||||
return [...new Set(citations)];
|
||||
}
|
||||
|
||||
function buildKimiToolResultContent(data: KimiSearchResponse): string {
|
||||
return JSON.stringify({
|
||||
search_results: (data.search_results ?? []).map((entry) => ({
|
||||
title: entry.title ?? "",
|
||||
url: entry.url ?? "",
|
||||
content: entry.content ?? "",
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
async function runKimiSearch(params: {
|
||||
query: string;
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
timeoutSeconds: number;
|
||||
}) {
|
||||
const baseUrl = params.baseUrl.trim().replace(/\/$/, "");
|
||||
const endpoint = `${baseUrl}/chat/completions`;
|
||||
const messages: Array<Record<string, unknown>> = [{ role: "user", content: params.query }];
|
||||
const collectedCitations = new Set<string>();
|
||||
const MAX_ROUNDS = 3;
|
||||
for (let round = 0; round < MAX_ROUNDS; round += 1) {
|
||||
const nextResult = await withTrustedWebToolsEndpoint(
|
||||
{
|
||||
url: endpoint,
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: params.model,
|
||||
messages,
|
||||
tools: [KIMI_WEB_SEARCH_TOOL],
|
||||
}),
|
||||
},
|
||||
},
|
||||
async ({
|
||||
response,
|
||||
}): Promise<{ done: true; content: string; citations: string[] } | { done: false }> => {
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => "");
|
||||
throw new Error(`Kimi API error (${response.status}): ${detail || response.statusText}`);
|
||||
}
|
||||
const data = (await response.json()) as KimiSearchResponse;
|
||||
for (const citation of extractKimiCitations(data)) {
|
||||
collectedCitations.add(citation);
|
||||
}
|
||||
const choice = data.choices?.[0];
|
||||
const message = choice?.message;
|
||||
const text = extractKimiMessageText(message);
|
||||
const toolCalls = message?.tool_calls ?? [];
|
||||
if (choice?.finish_reason !== "tool_calls" || toolCalls.length === 0) {
|
||||
return { done: true, content: text ?? "No response", citations: [...collectedCitations] };
|
||||
}
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
content: message?.content ?? "",
|
||||
...(message?.reasoning_content ? { reasoning_content: message.reasoning_content } : {}),
|
||||
tool_calls: toolCalls,
|
||||
});
|
||||
const toolContent = buildKimiToolResultContent(data);
|
||||
let pushedToolResult = false;
|
||||
for (const toolCall of toolCalls) {
|
||||
const toolCallId = toolCall.id?.trim();
|
||||
if (!toolCallId) continue;
|
||||
pushedToolResult = true;
|
||||
messages.push({
|
||||
role: "tool",
|
||||
tool_call_id: toolCallId,
|
||||
content: toolContent,
|
||||
});
|
||||
}
|
||||
if (!pushedToolResult) {
|
||||
return { done: true, content: text ?? "No response", citations: [...collectedCitations] };
|
||||
}
|
||||
return { done: false };
|
||||
},
|
||||
);
|
||||
if (nextResult.done) {
|
||||
return { content: nextResult.content, citations: nextResult.citations };
|
||||
}
|
||||
}
|
||||
return {
|
||||
content: "Search completed but no final answer was produced.",
|
||||
citations: [...collectedCitations],
|
||||
};
|
||||
}
|
||||
|
||||
export const KIMI_SEARCH_PROVIDER_METADATA: SearchProviderSetupMetadata =
|
||||
createSearchProviderSetupMetadata({
|
||||
provider: "kimi",
|
||||
label: "Kimi (Moonshot)",
|
||||
hint: "Moonshot web search",
|
||||
envKeys: ["KIMI_API_KEY", "MOONSHOT_API_KEY"],
|
||||
placeholder: "sk-...",
|
||||
signupUrl: "https://platform.moonshot.cn/",
|
||||
apiKeyConfigPath: "tools.web.search.kimi.apiKey",
|
||||
autodetectPriority: 40,
|
||||
requestSchema: Type.Object({
|
||||
query: Type.String({ description: "Search query string." }),
|
||||
count: Type.Optional(Type.Number({ minimum: 1, maximum: MAX_SEARCH_COUNT })),
|
||||
}),
|
||||
});
|
||||
|
||||
export function createBundledKimiSearchProvider(): SearchProviderPlugin {
|
||||
return {
|
||||
id: "kimi",
|
||||
name: "Kimi by Moonshot",
|
||||
description:
|
||||
"Search the web using Kimi by Moonshot. Returns AI-synthesized answers with citations from native $web_search.",
|
||||
pluginOwnedExecution: true,
|
||||
setup: KIMI_SEARCH_PROVIDER_METADATA,
|
||||
isAvailable: (config) =>
|
||||
Boolean(
|
||||
resolveKimiApiKey(
|
||||
resolveKimiConfig(
|
||||
resolveSearchConfig<WebSearchConfig>(
|
||||
config?.tools?.web?.search as Record<string, unknown>,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
search: async (request, ctx): Promise<SearchProviderExecutionResult> => {
|
||||
const search = resolveSearchConfig<WebSearchConfig>(request.providerConfig);
|
||||
const kimiConfig = resolveKimiConfig(search);
|
||||
const apiKey = resolveKimiApiKey(kimiConfig);
|
||||
if (!apiKey) {
|
||||
return createMissingSearchKeyPayload(
|
||||
"missing_kimi_api_key",
|
||||
"web_search (kimi) needs a Moonshot API key. Set KIMI_API_KEY or MOONSHOT_API_KEY in the Gateway environment, or configure tools.web.search.kimi.apiKey.",
|
||||
);
|
||||
}
|
||||
const unsupportedFilter = rejectUnsupportedSearchFilters({
|
||||
providerName: "kimi",
|
||||
request,
|
||||
support: {
|
||||
country: false,
|
||||
language: false,
|
||||
freshness: false,
|
||||
date: false,
|
||||
domainFilter: false,
|
||||
},
|
||||
});
|
||||
if (unsupportedFilter) {
|
||||
return unsupportedFilter;
|
||||
}
|
||||
|
||||
const baseUrl = resolveKimiBaseUrl(kimiConfig);
|
||||
const model = resolveKimiModel(kimiConfig);
|
||||
const cacheKey = normalizeCacheKey(
|
||||
`kimi:${baseUrl}:${model}:${buildSearchRequestCacheIdentity({
|
||||
query: request.query,
|
||||
count: request.count,
|
||||
})}`,
|
||||
);
|
||||
const cached = readCache(KIMI_SEARCH_CACHE, cacheKey);
|
||||
if (cached) return { ...cached.value, cached: true } as SearchProviderExecutionResult;
|
||||
const startedAt = Date.now();
|
||||
const result = await runKimiSearch({
|
||||
query: request.query,
|
||||
apiKey,
|
||||
baseUrl,
|
||||
model,
|
||||
timeoutSeconds: ctx.timeoutSeconds,
|
||||
});
|
||||
const payload = {
|
||||
query: request.query,
|
||||
provider: "kimi",
|
||||
model,
|
||||
tookMs: Date.now() - startedAt,
|
||||
externalContent: {
|
||||
untrusted: true,
|
||||
source: "web_search",
|
||||
provider: "kimi",
|
||||
wrapped: true,
|
||||
},
|
||||
content: wrapWebContent(result.content),
|
||||
citations: result.citations,
|
||||
};
|
||||
writeCache(KIMI_SEARCH_CACHE, cacheKey, payload, ctx.cacheTtlMs);
|
||||
return payload as SearchProviderExecutionResult;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
KIMI_SEARCH_CACHE,
|
||||
clearSearchProviderCaches() {
|
||||
KIMI_SEARCH_CACHE.clear();
|
||||
},
|
||||
} as const;
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"id": "search-perplexity",
|
||||
"defaultEnabledWhenBundled": true,
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
},
|
||||
"provides": ["providers.search.perplexity"]
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"name": "@openclaw/search-perplexity",
|
||||
"version": "2026.3.12",
|
||||
"private": true,
|
||||
"description": "OpenClaw Perplexity search plugin",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./src/index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { createBundledPerplexitySearchProvider } from "./provider.js";
|
||||
|
||||
const plugin = {
|
||||
id: "search-perplexity",
|
||||
name: "Perplexity Search",
|
||||
description: "Bundled Perplexity web search provider for OpenClaw.",
|
||||
register(api: OpenClawPluginApi) {
|
||||
api.registerSearchProvider(createBundledPerplexitySearchProvider());
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
@@ -1,662 +0,0 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import {
|
||||
buildSearchRequestCacheIdentity,
|
||||
createSearchProviderSetupMetadata,
|
||||
createMissingSearchKeyPayload,
|
||||
createSearchProviderErrorResult,
|
||||
normalizeCacheKey,
|
||||
normalizeDateInputToIso,
|
||||
normalizeResolvedSecretInputString,
|
||||
normalizeSecretInput,
|
||||
readCache,
|
||||
resolveSearchConfig,
|
||||
resolveSearchProviderSectionConfig,
|
||||
resolveSiteName,
|
||||
throwWebSearchApiError,
|
||||
type OpenClawConfig,
|
||||
type SearchProviderExecutionResult,
|
||||
type SearchProviderSetupMetadata,
|
||||
type SearchProviderPlugin,
|
||||
withTrustedWebToolsEndpoint,
|
||||
wrapWebContent,
|
||||
writeCache,
|
||||
} from "openclaw/plugin-sdk/web-search";
|
||||
|
||||
const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1";
|
||||
const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai";
|
||||
const PERPLEXITY_SEARCH_ENDPOINT = "https://api.perplexity.ai/search";
|
||||
const DEFAULT_PERPLEXITY_MODEL = "perplexity/sonar-pro";
|
||||
const PERPLEXITY_KEY_PREFIXES = ["pplx-"];
|
||||
const OPENROUTER_KEY_PREFIXES = ["sk-or-"];
|
||||
const PERPLEXITY_RECENCY_VALUES = new Set(["day", "week", "month", "year"]);
|
||||
const ISO_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||||
const MAX_SEARCH_COUNT = 10;
|
||||
|
||||
const PERPLEXITY_SEARCH_CACHE = new Map<
|
||||
string,
|
||||
{ value: Record<string, unknown>; expiresAt: number }
|
||||
>();
|
||||
|
||||
type WebSearchConfig = NonNullable<OpenClawConfig["tools"]>["web"] extends infer Web
|
||||
? Web extends { search?: infer Search }
|
||||
? Search
|
||||
: undefined
|
||||
: undefined;
|
||||
|
||||
type PerplexityConfig = {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
type PerplexityApiKeySource = "config" | "perplexity_env" | "openrouter_env" | "none";
|
||||
type PerplexityTransport = "search_api" | "chat_completions";
|
||||
type PerplexityBaseUrlHint = "direct" | "openrouter";
|
||||
|
||||
type PerplexitySearchResponse = {
|
||||
choices?: Array<{
|
||||
message?: {
|
||||
content?: string;
|
||||
annotations?: Array<{
|
||||
type?: string;
|
||||
url?: string;
|
||||
url_citation?: {
|
||||
url?: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
citations?: string[];
|
||||
};
|
||||
|
||||
type PerplexitySearchApiResult = {
|
||||
title?: string;
|
||||
url?: string;
|
||||
snippet?: string;
|
||||
date?: string;
|
||||
};
|
||||
|
||||
type PerplexitySearchApiResponse = {
|
||||
results?: PerplexitySearchApiResult[];
|
||||
};
|
||||
|
||||
function normalizeApiKey(key: unknown): string {
|
||||
return normalizeSecretInput(key);
|
||||
}
|
||||
|
||||
function extractPerplexityCitations(data: PerplexitySearchResponse): string[] {
|
||||
const normalizeUrl = (value: unknown): string | undefined => {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
};
|
||||
const topLevel = (data.citations ?? [])
|
||||
.map(normalizeUrl)
|
||||
.filter((url): url is string => Boolean(url));
|
||||
if (topLevel.length > 0) {
|
||||
return [...new Set(topLevel)];
|
||||
}
|
||||
const citations: string[] = [];
|
||||
for (const choice of data.choices ?? []) {
|
||||
for (const annotation of choice.message?.annotations ?? []) {
|
||||
if (annotation.type !== "url_citation") {
|
||||
continue;
|
||||
}
|
||||
const url = normalizeUrl(annotation.url_citation?.url ?? annotation.url);
|
||||
if (url) {
|
||||
citations.push(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...new Set(citations)];
|
||||
}
|
||||
|
||||
function resolvePerplexityConfig(search?: WebSearchConfig): PerplexityConfig {
|
||||
return resolveSearchProviderSectionConfig<PerplexityConfig>(
|
||||
search as Record<string, unknown> | undefined,
|
||||
"perplexity",
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePerplexityApiKey(perplexity?: PerplexityConfig): {
|
||||
apiKey?: string;
|
||||
source: PerplexityApiKeySource;
|
||||
} {
|
||||
const fromConfig = normalizeApiKey(perplexity?.apiKey);
|
||||
if (fromConfig) {
|
||||
return { apiKey: fromConfig, source: "config" };
|
||||
}
|
||||
const fromEnvPerplexity = normalizeApiKey(process.env.PERPLEXITY_API_KEY);
|
||||
if (fromEnvPerplexity) {
|
||||
return { apiKey: fromEnvPerplexity, source: "perplexity_env" };
|
||||
}
|
||||
const fromEnvOpenRouter = normalizeApiKey(process.env.OPENROUTER_API_KEY);
|
||||
if (fromEnvOpenRouter) {
|
||||
return { apiKey: fromEnvOpenRouter, source: "openrouter_env" };
|
||||
}
|
||||
return { apiKey: undefined, source: "none" };
|
||||
}
|
||||
|
||||
function inferPerplexityBaseUrlFromApiKey(apiKey?: string): PerplexityBaseUrlHint | undefined {
|
||||
if (!apiKey) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = apiKey.toLowerCase();
|
||||
if (PERPLEXITY_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
|
||||
return "direct";
|
||||
}
|
||||
if (OPENROUTER_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
|
||||
return "openrouter";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolvePerplexityBaseUrl(
|
||||
perplexity?: PerplexityConfig,
|
||||
authSource: PerplexityApiKeySource = "none",
|
||||
configuredKey?: string,
|
||||
): string {
|
||||
const fromConfig =
|
||||
perplexity && "baseUrl" in perplexity && typeof perplexity.baseUrl === "string"
|
||||
? perplexity.baseUrl.trim()
|
||||
: "";
|
||||
if (fromConfig) {
|
||||
return fromConfig;
|
||||
}
|
||||
if (authSource === "perplexity_env") {
|
||||
return PERPLEXITY_DIRECT_BASE_URL;
|
||||
}
|
||||
if (authSource === "openrouter_env") {
|
||||
return DEFAULT_PERPLEXITY_BASE_URL;
|
||||
}
|
||||
if (authSource === "config") {
|
||||
const inferred = inferPerplexityBaseUrlFromApiKey(configuredKey);
|
||||
if (inferred === "openrouter") {
|
||||
return DEFAULT_PERPLEXITY_BASE_URL;
|
||||
}
|
||||
return PERPLEXITY_DIRECT_BASE_URL;
|
||||
}
|
||||
return DEFAULT_PERPLEXITY_BASE_URL;
|
||||
}
|
||||
|
||||
function resolvePerplexityModel(perplexity?: PerplexityConfig): string {
|
||||
const fromConfig =
|
||||
perplexity && "model" in perplexity && typeof perplexity.model === "string"
|
||||
? perplexity.model.trim()
|
||||
: "";
|
||||
return fromConfig || DEFAULT_PERPLEXITY_MODEL;
|
||||
}
|
||||
|
||||
function isDirectPerplexityBaseUrl(baseUrl: string): boolean {
|
||||
const trimmed = baseUrl.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return new URL(trimmed).hostname.toLowerCase() === "api.perplexity.ai";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePerplexityRequestModel(baseUrl: string, model: string): string {
|
||||
if (!isDirectPerplexityBaseUrl(baseUrl)) {
|
||||
return model;
|
||||
}
|
||||
return model.startsWith("perplexity/") ? model.slice("perplexity/".length) : model;
|
||||
}
|
||||
|
||||
function resolvePerplexityTransport(perplexity?: PerplexityConfig): {
|
||||
apiKey?: string;
|
||||
source: PerplexityApiKeySource;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
transport: PerplexityTransport;
|
||||
} {
|
||||
const auth = resolvePerplexityApiKey(perplexity);
|
||||
const baseUrl = resolvePerplexityBaseUrl(perplexity, auth.source, auth.apiKey);
|
||||
const model = resolvePerplexityModel(perplexity);
|
||||
const hasLegacyOverride = Boolean(
|
||||
(perplexity?.baseUrl && perplexity.baseUrl.trim()) ||
|
||||
(perplexity?.model && perplexity.model.trim()),
|
||||
);
|
||||
return {
|
||||
...auth,
|
||||
baseUrl,
|
||||
model,
|
||||
transport:
|
||||
hasLegacyOverride || !isDirectPerplexityBaseUrl(baseUrl) ? "chat_completions" : "search_api",
|
||||
};
|
||||
}
|
||||
|
||||
async function runPerplexitySearchApi(params: {
|
||||
query: string;
|
||||
apiKey: string;
|
||||
count: number;
|
||||
timeoutSeconds: number;
|
||||
country?: string;
|
||||
searchDomainFilter?: string[];
|
||||
searchRecencyFilter?: string;
|
||||
searchLanguageFilter?: string[];
|
||||
searchAfterDate?: string;
|
||||
searchBeforeDate?: string;
|
||||
maxTokens?: number;
|
||||
maxTokensPerPage?: number;
|
||||
}) {
|
||||
const body: Record<string, unknown> = { query: params.query, max_results: params.count };
|
||||
if (params.country) body.country = params.country;
|
||||
if (params.searchDomainFilter?.length) body.search_domain_filter = params.searchDomainFilter;
|
||||
if (params.searchRecencyFilter) body.search_recency_filter = params.searchRecencyFilter;
|
||||
if (params.searchLanguageFilter?.length)
|
||||
body.search_language_filter = params.searchLanguageFilter;
|
||||
if (params.searchAfterDate) body.search_after_date = params.searchAfterDate;
|
||||
if (params.searchBeforeDate) body.search_before_date = params.searchBeforeDate;
|
||||
if (params.maxTokens !== undefined) body.max_tokens = params.maxTokens;
|
||||
if (params.maxTokensPerPage !== undefined) body.max_tokens_per_page = params.maxTokensPerPage;
|
||||
|
||||
return withTrustedWebToolsEndpoint(
|
||||
{
|
||||
url: PERPLEXITY_SEARCH_ENDPOINT,
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
"HTTP-Referer": "https://openclaw.ai",
|
||||
"X-Title": "OpenClaw Web Search",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
},
|
||||
async ({ response }) => {
|
||||
if (!response.ok) {
|
||||
return await throwWebSearchApiError(response, "Perplexity Search");
|
||||
}
|
||||
const data = (await response.json()) as PerplexitySearchApiResponse;
|
||||
const results = Array.isArray(data.results) ? data.results : [];
|
||||
return results.map((entry) => {
|
||||
const title = entry.title ?? "";
|
||||
const url = entry.url ?? "";
|
||||
const snippet = entry.snippet ?? "";
|
||||
return {
|
||||
title: title ? wrapWebContent(title, "web_search") : "",
|
||||
url,
|
||||
description: snippet ? wrapWebContent(snippet, "web_search") : "",
|
||||
published: entry.date ?? undefined,
|
||||
siteName: resolveSiteName(url) || undefined,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function runPerplexitySearch(params: {
|
||||
query: string;
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
timeoutSeconds: number;
|
||||
freshness?: string;
|
||||
}) {
|
||||
const baseUrl = params.baseUrl.trim().replace(/\/$/, "");
|
||||
const endpoint = `${baseUrl}/chat/completions`;
|
||||
const model = resolvePerplexityRequestModel(baseUrl, params.model);
|
||||
const body: Record<string, unknown> = {
|
||||
model,
|
||||
messages: [{ role: "user", content: params.query }],
|
||||
};
|
||||
if (params.freshness) {
|
||||
body.search_recency_filter = params.freshness;
|
||||
}
|
||||
return withTrustedWebToolsEndpoint(
|
||||
{
|
||||
url: endpoint,
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
"HTTP-Referer": "https://openclaw.ai",
|
||||
"X-Title": "OpenClaw Web Search",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
},
|
||||
async ({ response }) => {
|
||||
if (!response.ok) {
|
||||
return await throwWebSearchApiError(response, "Perplexity");
|
||||
}
|
||||
const data = (await response.json()) as PerplexitySearchResponse;
|
||||
return {
|
||||
content: data.choices?.[0]?.message?.content ?? "No response",
|
||||
citations: extractPerplexityCitations(data),
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function isoToPerplexityDate(iso: string): string | undefined {
|
||||
const match = iso.match(ISO_DATE_PATTERN);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const [, year, month, day] = match;
|
||||
return `${Number.parseInt(month, 10)}/${Number.parseInt(day, 10)}/${year}`;
|
||||
}
|
||||
|
||||
function createPerplexityPayload(params: {
|
||||
request: { query: string };
|
||||
startedAt: number;
|
||||
model?: string;
|
||||
results?: unknown[];
|
||||
content?: string;
|
||||
citations?: string[];
|
||||
}) {
|
||||
const payload: Record<string, unknown> = {
|
||||
query: params.request.query,
|
||||
provider: "perplexity",
|
||||
tookMs: Date.now() - params.startedAt,
|
||||
externalContent: {
|
||||
untrusted: true,
|
||||
source: "web_search",
|
||||
provider: "perplexity",
|
||||
wrapped: true,
|
||||
},
|
||||
};
|
||||
if (params.model) payload.model = params.model;
|
||||
if (params.results) {
|
||||
payload.results = params.results;
|
||||
payload.count = params.results.length;
|
||||
}
|
||||
if (params.content) payload.content = wrapWebContent(params.content, "web_search");
|
||||
if (params.citations) payload.citations = params.citations;
|
||||
return payload;
|
||||
}
|
||||
|
||||
export const PERPLEXITY_SEARCH_PROVIDER_METADATA: SearchProviderSetupMetadata =
|
||||
createSearchProviderSetupMetadata({
|
||||
provider: "perplexity",
|
||||
label: "Perplexity Search",
|
||||
hint: "Structured results · domain/country/language/time filters",
|
||||
envKeys: ["PERPLEXITY_API_KEY"],
|
||||
placeholder: "pplx-...",
|
||||
signupUrl: "https://www.perplexity.ai/settings/api",
|
||||
apiKeyConfigPath: "tools.web.search.perplexity.apiKey",
|
||||
resolveRuntimeMetadata: (params) => ({
|
||||
perplexityTransport: resolvePerplexityTransport(
|
||||
resolvePerplexityConfig(resolveSearchConfig<WebSearchConfig>(params.search)),
|
||||
).transport,
|
||||
}),
|
||||
autodetectPriority: 50,
|
||||
resolveRequestSchema: (params) => {
|
||||
const runtimeTransport =
|
||||
params.runtimeMetadata && typeof params.runtimeMetadata.perplexityTransport === "string"
|
||||
? params.runtimeMetadata.perplexityTransport
|
||||
: undefined;
|
||||
return runtimeTransport === "chat_completions"
|
||||
? createPerplexityChatSchema()
|
||||
: createPerplexitySearchApiSchema();
|
||||
},
|
||||
});
|
||||
|
||||
function createPerplexitySearchApiSchema() {
|
||||
return Type.Object({
|
||||
query: Type.String({ description: "Search query string." }),
|
||||
count: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Number of results to return (1-10).",
|
||||
minimum: 1,
|
||||
maximum: MAX_SEARCH_COUNT,
|
||||
}),
|
||||
),
|
||||
freshness: Type.Optional(
|
||||
Type.String({
|
||||
description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'.",
|
||||
}),
|
||||
),
|
||||
country: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Native Perplexity Search API only. 2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.",
|
||||
}),
|
||||
),
|
||||
language: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Native Perplexity Search API only. ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').",
|
||||
}),
|
||||
),
|
||||
date_after: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Native Perplexity Search API only. Only results published after this date (YYYY-MM-DD).",
|
||||
}),
|
||||
),
|
||||
date_before: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Native Perplexity Search API only. Only results published before this date (YYYY-MM-DD).",
|
||||
}),
|
||||
),
|
||||
domain_filter: Type.Optional(
|
||||
Type.Array(Type.String(), {
|
||||
description:
|
||||
"Native Perplexity Search API only. Domain filter (max 20). Allowlist: ['nature.com'] or denylist: ['-reddit.com']. Cannot mix.",
|
||||
}),
|
||||
),
|
||||
max_tokens: Type.Optional(
|
||||
Type.Number({
|
||||
description:
|
||||
"Native Perplexity Search API only. Total content budget across all results (default: 25000, max: 1000000).",
|
||||
minimum: 1,
|
||||
maximum: 1000000,
|
||||
}),
|
||||
),
|
||||
max_tokens_per_page: Type.Optional(
|
||||
Type.Number({
|
||||
description:
|
||||
"Native Perplexity Search API only. Max tokens extracted per page (default: 2048).",
|
||||
minimum: 1,
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function createPerplexityChatSchema() {
|
||||
return Type.Object({
|
||||
query: Type.String({ description: "Search query string." }),
|
||||
count: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Number of results to return (1-10).",
|
||||
minimum: 1,
|
||||
maximum: MAX_SEARCH_COUNT,
|
||||
}),
|
||||
),
|
||||
freshness: Type.Optional(
|
||||
Type.String({
|
||||
description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'.",
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function createBundledPerplexitySearchProvider(): SearchProviderPlugin {
|
||||
return {
|
||||
id: "perplexity",
|
||||
name: "Perplexity",
|
||||
description:
|
||||
"Search the web using Perplexity. Runtime routing decides between native Search API and Sonar chat-completions compatibility. Structured filters are available on the native Search API path.",
|
||||
pluginOwnedExecution: true,
|
||||
setup: PERPLEXITY_SEARCH_PROVIDER_METADATA,
|
||||
resolveRuntimeMetadata: PERPLEXITY_SEARCH_PROVIDER_METADATA.credentials?.resolveRuntimeMetadata,
|
||||
isAvailable: (config) =>
|
||||
Boolean(
|
||||
resolvePerplexityApiKey(
|
||||
resolvePerplexityConfig(
|
||||
resolveSearchConfig<WebSearchConfig>(
|
||||
config?.tools?.web?.search as Record<string, unknown>,
|
||||
),
|
||||
),
|
||||
).apiKey,
|
||||
),
|
||||
search: async (request, ctx): Promise<SearchProviderExecutionResult> => {
|
||||
const search = resolveSearchConfig<WebSearchConfig>(request.providerConfig);
|
||||
const runtime = resolvePerplexityTransport(resolvePerplexityConfig(search));
|
||||
if (!runtime.apiKey) {
|
||||
return createMissingSearchKeyPayload(
|
||||
"missing_perplexity_api_key",
|
||||
"web_search (perplexity) needs an API key. Set PERPLEXITY_API_KEY or OPENROUTER_API_KEY in the Gateway environment, or configure tools.web.search.perplexity.apiKey.",
|
||||
);
|
||||
}
|
||||
const supportsStructured = runtime.transport === "search_api";
|
||||
if (request.country && !supportsStructured) {
|
||||
return createSearchProviderErrorResult(
|
||||
"unsupported_country",
|
||||
"country filtering is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it.",
|
||||
);
|
||||
}
|
||||
if (request.language && !supportsStructured) {
|
||||
return createSearchProviderErrorResult(
|
||||
"unsupported_language",
|
||||
"language filtering is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it.",
|
||||
);
|
||||
}
|
||||
if (request.language && !/^[a-z]{2}$/i.test(request.language)) {
|
||||
return createSearchProviderErrorResult(
|
||||
"invalid_language",
|
||||
"language must be a 2-letter ISO 639-1 code like 'en', 'de', or 'fr'.",
|
||||
);
|
||||
}
|
||||
const normalizedFreshness = request.freshness
|
||||
? PERPLEXITY_RECENCY_VALUES.has(request.freshness.trim().toLowerCase())
|
||||
? request.freshness.trim().toLowerCase()
|
||||
: undefined
|
||||
: undefined;
|
||||
if (request.freshness && !normalizedFreshness) {
|
||||
return createSearchProviderErrorResult(
|
||||
"invalid_freshness",
|
||||
"freshness must be day, week, month, or year.",
|
||||
);
|
||||
}
|
||||
if ((request.dateAfter || request.dateBefore) && !supportsStructured) {
|
||||
return createSearchProviderErrorResult(
|
||||
"unsupported_date_filter",
|
||||
"date_after/date_before are only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable them.",
|
||||
);
|
||||
}
|
||||
if (request.domainFilter && request.domainFilter.length > 0 && !supportsStructured) {
|
||||
return createSearchProviderErrorResult(
|
||||
"unsupported_domain_filter",
|
||||
"domain_filter is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it.",
|
||||
);
|
||||
}
|
||||
if (request.domainFilter && request.domainFilter.length > 0) {
|
||||
const hasDenylist = request.domainFilter.some((domain) => domain.startsWith("-"));
|
||||
const hasAllowlist = request.domainFilter.some((domain) => !domain.startsWith("-"));
|
||||
if (hasDenylist && hasAllowlist) {
|
||||
return createSearchProviderErrorResult(
|
||||
"invalid_domain_filter",
|
||||
"domain_filter cannot mix allowlist and denylist entries. Use either all positive entries (allowlist) or all entries prefixed with '-' (denylist).",
|
||||
);
|
||||
}
|
||||
if (request.domainFilter.length > 20) {
|
||||
return createSearchProviderErrorResult(
|
||||
"invalid_domain_filter",
|
||||
"domain_filter supports a maximum of 20 domains.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
runtime.transport === "chat_completions" &&
|
||||
(request.maxTokens !== undefined || request.maxTokensPerPage !== undefined)
|
||||
) {
|
||||
return createSearchProviderErrorResult(
|
||||
"unsupported_content_budget",
|
||||
"max_tokens and max_tokens_per_page are only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable them.",
|
||||
);
|
||||
}
|
||||
if (request.dateAfter && !normalizeDateInputToIso(request.dateAfter)) {
|
||||
return createSearchProviderErrorResult(
|
||||
"invalid_date_after",
|
||||
"date_after must be a valid YYYY-MM-DD date.",
|
||||
);
|
||||
}
|
||||
if (request.dateBefore && !normalizeDateInputToIso(request.dateBefore)) {
|
||||
return createSearchProviderErrorResult(
|
||||
"invalid_date_before",
|
||||
"date_before must be a valid YYYY-MM-DD date.",
|
||||
);
|
||||
}
|
||||
|
||||
const cacheKey = normalizeCacheKey(
|
||||
`perplexity:${runtime.transport}:${runtime.baseUrl}:${runtime.model}:${buildSearchRequestCacheIdentity(
|
||||
{
|
||||
query: request.query,
|
||||
count: request.count,
|
||||
country: request.country,
|
||||
language: request.language,
|
||||
freshness: normalizedFreshness,
|
||||
dateAfter: request.dateAfter,
|
||||
dateBefore: request.dateBefore,
|
||||
domainFilter: request.domainFilter,
|
||||
maxTokens: request.maxTokens,
|
||||
maxTokensPerPage: request.maxTokensPerPage,
|
||||
},
|
||||
)}`,
|
||||
);
|
||||
const cached = readCache(PERPLEXITY_SEARCH_CACHE, cacheKey);
|
||||
if (cached) return { ...cached.value, cached: true } as SearchProviderExecutionResult;
|
||||
const startedAt = Date.now();
|
||||
let payload: Record<string, unknown>;
|
||||
if (runtime.transport === "chat_completions") {
|
||||
const result = await runPerplexitySearch({
|
||||
query: request.query,
|
||||
apiKey: runtime.apiKey,
|
||||
baseUrl: runtime.baseUrl,
|
||||
model: runtime.model,
|
||||
timeoutSeconds: ctx.timeoutSeconds,
|
||||
freshness: normalizedFreshness,
|
||||
});
|
||||
payload = createPerplexityPayload({
|
||||
request,
|
||||
startedAt,
|
||||
model: runtime.model,
|
||||
content: result.content,
|
||||
citations: result.citations,
|
||||
});
|
||||
} else {
|
||||
const results = await runPerplexitySearchApi({
|
||||
query: request.query,
|
||||
apiKey: runtime.apiKey,
|
||||
count: request.count,
|
||||
timeoutSeconds: ctx.timeoutSeconds,
|
||||
country: request.country,
|
||||
searchDomainFilter: request.domainFilter,
|
||||
searchRecencyFilter: normalizedFreshness,
|
||||
searchLanguageFilter: request.language ? [request.language] : undefined,
|
||||
searchAfterDate: request.dateAfter ? isoToPerplexityDate(request.dateAfter) : undefined,
|
||||
searchBeforeDate: request.dateBefore
|
||||
? isoToPerplexityDate(request.dateBefore)
|
||||
: undefined,
|
||||
maxTokens: request.maxTokens,
|
||||
maxTokensPerPage: request.maxTokensPerPage,
|
||||
});
|
||||
payload = createPerplexityPayload({ request, startedAt, results });
|
||||
}
|
||||
writeCache(PERPLEXITY_SEARCH_CACHE, cacheKey, payload, ctx.cacheTtlMs);
|
||||
return payload as SearchProviderExecutionResult;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
PERPLEXITY_SEARCH_CACHE,
|
||||
clearSearchProviderCaches() {
|
||||
PERPLEXITY_SEARCH_CACHE.clear();
|
||||
},
|
||||
} as const;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { evaluateSenderGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access";
|
||||
import { evaluateSenderGroupAccessForPolicy } from "../../../src/plugin-sdk/group-access.js";
|
||||
import { normalizeE164 } from "../../../src/utils.js";
|
||||
|
||||
export type SignalSender =
|
||||
|
||||
@@ -74,7 +74,10 @@ function createAutoAbortController() {
|
||||
}
|
||||
|
||||
async function runMonitorWithMocks(opts: MonitorSignalProviderOptions) {
|
||||
return monitorSignalProvider(opts);
|
||||
return monitorSignalProvider({
|
||||
config: config as OpenClawConfig,
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
async function receiveSignalPayloads(params: {
|
||||
@@ -304,7 +307,9 @@ describe("monitorSignalProvider tool results", () => {
|
||||
],
|
||||
});
|
||||
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
await vi.waitFor(() => {
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(sendMock.mock.calls[0][1]).toBe("PFX final reply");
|
||||
});
|
||||
|
||||
@@ -460,8 +465,9 @@ describe("monitorSignalProvider tool results", () => {
|
||||
],
|
||||
});
|
||||
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(updateLastRouteMock).toHaveBeenCalled();
|
||||
await vi.waitFor(() => {
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not resend pairing code when a request is already pending", async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ type SlackProviderMonitor = (params: {
|
||||
botToken: string;
|
||||
appToken: string;
|
||||
abortSignal: AbortSignal;
|
||||
config?: Record<string, unknown>;
|
||||
}) => Promise<unknown>;
|
||||
|
||||
type SlackTestState = {
|
||||
@@ -49,14 +50,51 @@ type SlackClient = {
|
||||
};
|
||||
};
|
||||
|
||||
export const getSlackHandlers = () =>
|
||||
(
|
||||
globalThis as {
|
||||
__slackHandlers?: Map<string, SlackHandler>;
|
||||
}
|
||||
).__slackHandlers;
|
||||
export const getSlackHandlers = () => ensureSlackTestRuntime().handlers;
|
||||
|
||||
export const getSlackClient = () => (globalThis as { __slackClient?: SlackClient }).__slackClient;
|
||||
export const getSlackClient = () => ensureSlackTestRuntime().client;
|
||||
|
||||
function ensureSlackTestRuntime(): {
|
||||
handlers: Map<string, SlackHandler>;
|
||||
client: SlackClient;
|
||||
} {
|
||||
const globalState = globalThis as {
|
||||
__slackHandlers?: Map<string, SlackHandler>;
|
||||
__slackClient?: SlackClient;
|
||||
};
|
||||
if (!globalState.__slackHandlers) {
|
||||
globalState.__slackHandlers = new Map<string, SlackHandler>();
|
||||
}
|
||||
if (!globalState.__slackClient) {
|
||||
globalState.__slackClient = {
|
||||
auth: { test: vi.fn().mockResolvedValue({ user_id: "bot-user" }) },
|
||||
conversations: {
|
||||
info: vi.fn().mockResolvedValue({
|
||||
channel: { name: "dm", is_im: true },
|
||||
}),
|
||||
replies: vi.fn().mockResolvedValue({ messages: [] }),
|
||||
history: vi.fn().mockResolvedValue({ messages: [] }),
|
||||
},
|
||||
users: {
|
||||
info: vi.fn().mockResolvedValue({
|
||||
user: { profile: { display_name: "Ada" } },
|
||||
}),
|
||||
},
|
||||
assistant: {
|
||||
threads: {
|
||||
setStatus: vi.fn().mockResolvedValue({ ok: true }),
|
||||
},
|
||||
},
|
||||
reactions: {
|
||||
add: (...args: unknown[]) => slackTestState.reactMock(...args),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
handlers: globalState.__slackHandlers,
|
||||
client: globalState.__slackClient,
|
||||
};
|
||||
}
|
||||
|
||||
export const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
@@ -78,6 +116,7 @@ export function startSlackMonitor(
|
||||
botToken: opts?.botToken ?? "bot-token",
|
||||
appToken: opts?.appToken ?? "app-token",
|
||||
abortSignal: controller.signal,
|
||||
config: slackTestState.config,
|
||||
});
|
||||
return { controller, run };
|
||||
}
|
||||
@@ -193,34 +232,9 @@ vi.mock("../../../src/config/sessions.js", async (importOriginal) => {
|
||||
});
|
||||
|
||||
vi.mock("@slack/bolt", () => {
|
||||
const handlers = new Map<string, SlackHandler>();
|
||||
(globalThis as { __slackHandlers?: typeof handlers }).__slackHandlers = handlers;
|
||||
const client = {
|
||||
auth: { test: vi.fn().mockResolvedValue({ user_id: "bot-user" }) },
|
||||
conversations: {
|
||||
info: vi.fn().mockResolvedValue({
|
||||
channel: { name: "dm", is_im: true },
|
||||
}),
|
||||
replies: vi.fn().mockResolvedValue({ messages: [] }),
|
||||
history: vi.fn().mockResolvedValue({ messages: [] }),
|
||||
},
|
||||
users: {
|
||||
info: vi.fn().mockResolvedValue({
|
||||
user: { profile: { display_name: "Ada" } },
|
||||
}),
|
||||
},
|
||||
assistant: {
|
||||
threads: {
|
||||
setStatus: vi.fn().mockResolvedValue({ ok: true }),
|
||||
},
|
||||
},
|
||||
reactions: {
|
||||
add: (...args: unknown[]) => slackTestState.reactMock(...args),
|
||||
},
|
||||
};
|
||||
(globalThis as { __slackClient?: typeof client }).__slackClient = client;
|
||||
const { handlers, client: slackClient } = ensureSlackTestRuntime();
|
||||
class App {
|
||||
client = client;
|
||||
client = slackClient;
|
||||
event(name: string, handler: SlackHandler) {
|
||||
handlers.set(name, handler);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { HISTORY_CONTEXT_MARKER } from "../../../src/auto-reply/reply/history.js";
|
||||
import { resetInboundDedupe } from "../../../src/auto-reply/reply/inbound-dedupe.js";
|
||||
import { CURRENT_MESSAGE_MARKER } from "../../../src/auto-reply/reply/mentions.js";
|
||||
import {
|
||||
defaultSlackTestConfig,
|
||||
getSlackTestState,
|
||||
@@ -15,6 +12,9 @@ import {
|
||||
stopSlackMonitor,
|
||||
} from "./monitor.test-helpers.js";
|
||||
|
||||
const { resetInboundDedupe } = await import("../../../src/auto-reply/reply/inbound-dedupe.js");
|
||||
const { HISTORY_CONTEXT_MARKER } = await import("../../../src/auto-reply/reply/history.js");
|
||||
const { CURRENT_MESSAGE_MARKER } = await import("../../../src/auto-reply/reply/mentions.js");
|
||||
const { monitorSlackProvider } = await import("./monitor.js");
|
||||
|
||||
const slackTestState = getSlackTestState();
|
||||
@@ -209,7 +209,9 @@ describe("monitorSlackProvider tool results", () => {
|
||||
|
||||
function expectSingleSendWithThread(threadTs: string | undefined) {
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMock.mock.calls[0][2]).toMatchObject({ threadTs });
|
||||
expect((sendMock.mock.calls[0]?.[2] as { threadTs?: string } | undefined)?.threadTs).toBe(
|
||||
threadTs,
|
||||
);
|
||||
}
|
||||
|
||||
async function runDefaultMessageAndExpectSentText(expectedText: string) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { WebClient as SlackWebClient } from "@slack/web-api";
|
||||
import { resolveRequestUrl } from "openclaw/plugin-sdk/request-url";
|
||||
import { normalizeHostname } from "../../../../src/infra/net/hostname.js";
|
||||
import type { FetchLike } from "../../../../src/media/fetch.js";
|
||||
import { fetchRemoteMedia } from "../../../../src/media/fetch.js";
|
||||
import { saveMediaBuffer } from "../../../../src/media/store.js";
|
||||
import { resolveRequestUrl } from "../../../../src/plugin-sdk/request-url.js";
|
||||
import type { SlackAttachment, SlackFile } from "../types.js";
|
||||
|
||||
function isSlackHostname(hostname: string): boolean {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { evaluateGroupRouteAccessForPolicy } from "openclaw/plugin-sdk/group-access";
|
||||
import { evaluateGroupRouteAccessForPolicy } from "../../../../src/plugin-sdk/group-access.js";
|
||||
|
||||
export function isSlackChannelAllowedByPolicy(params: {
|
||||
groupPolicy: "open" | "disabled" | "allowlist";
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import plugin from "./index.js";
|
||||
|
||||
function createApi(params?: {
|
||||
config?: OpenClawPluginApi["config"];
|
||||
pluginConfig?: Record<string, unknown>;
|
||||
}) {
|
||||
let registeredProvider: Parameters<OpenClawPluginApi["registerSearchProvider"]>[0] | undefined;
|
||||
const api = {
|
||||
id: "tavily-search",
|
||||
name: "Tavily Search",
|
||||
source: "/tmp/tavily-search/index.ts",
|
||||
config: params?.config ?? {},
|
||||
pluginConfig: params?.pluginConfig,
|
||||
runtime: {} as never,
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
registerSearchProvider: vi.fn((provider) => {
|
||||
registeredProvider = provider;
|
||||
}),
|
||||
} as unknown as OpenClawPluginApi;
|
||||
|
||||
plugin.register?.(api);
|
||||
if (!registeredProvider) {
|
||||
throw new Error("search provider was not registered");
|
||||
}
|
||||
return registeredProvider;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("tavily-search plugin", () => {
|
||||
it("registers a tavily search provider and detects availability from plugin config", () => {
|
||||
const provider = createApi({
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"tavily-search": {
|
||||
config: {
|
||||
apiKey: "tvly-test-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(provider.id).toBe("tavily");
|
||||
expect(provider.isAvailable?.({})).toBe(false);
|
||||
expect(
|
||||
provider.isAvailable?.({
|
||||
plugins: {
|
||||
entries: {
|
||||
"tavily-search": {
|
||||
config: {
|
||||
apiKey: "tvly-test-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("maps Tavily responses into plugin search results", async () => {
|
||||
const provider = createApi({
|
||||
pluginConfig: {
|
||||
apiKey: "tvly-test-key",
|
||||
searchDepth: "advanced",
|
||||
},
|
||||
});
|
||||
const fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
answer: "Tavily says hello",
|
||||
results: [
|
||||
{
|
||||
title: "Example",
|
||||
url: "https://example.com/article",
|
||||
content: "Snippet",
|
||||
published_date: "2026-03-10",
|
||||
},
|
||||
],
|
||||
}),
|
||||
}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await provider.search(
|
||||
{
|
||||
query: "hello",
|
||||
count: 3,
|
||||
country: "US",
|
||||
freshness: "week",
|
||||
},
|
||||
{
|
||||
config: {},
|
||||
timeoutSeconds: 5,
|
||||
cacheTtlMs: 1000,
|
||||
pluginConfig: {
|
||||
apiKey: "tvly-test-key",
|
||||
searchDepth: "advanced",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||
expect(JSON.parse(String(init.body))).toMatchObject({
|
||||
api_key: "tvly-test-key",
|
||||
query: "hello",
|
||||
max_results: 3,
|
||||
search_depth: "advanced",
|
||||
topic: "news",
|
||||
days: 7,
|
||||
country: "US",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
content: "Tavily says hello",
|
||||
citations: ["https://example.com/article"],
|
||||
results: [
|
||||
{
|
||||
title: "Example",
|
||||
url: "https://example.com/article",
|
||||
description: "Snippet",
|
||||
published: "2026-03-10",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,197 +0,0 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { createSearchProviderSetupMetadata } from "openclaw/plugin-sdk/web-search";
|
||||
|
||||
const TAVILY_SEARCH_ENDPOINT = "https://api.tavily.com/search";
|
||||
const MAX_SEARCH_COUNT = 10;
|
||||
|
||||
type TavilyPluginConfig = {
|
||||
apiKey?: string;
|
||||
searchDepth?: "basic" | "advanced";
|
||||
};
|
||||
|
||||
type TavilySearchResult = {
|
||||
title?: string;
|
||||
url?: string;
|
||||
content?: string;
|
||||
published_date?: string;
|
||||
};
|
||||
|
||||
type TavilySearchResponse = {
|
||||
answer?: string;
|
||||
results?: TavilySearchResult[];
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function normalizeString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function resolvePluginConfig(value: unknown): TavilyPluginConfig {
|
||||
if (!isRecord(value)) {
|
||||
return {};
|
||||
}
|
||||
return value as TavilyPluginConfig;
|
||||
}
|
||||
|
||||
function resolveRootPluginConfig(
|
||||
config: OpenClawPluginApi["config"],
|
||||
pluginId: string,
|
||||
): TavilyPluginConfig {
|
||||
return resolvePluginConfig(config?.plugins?.entries?.[pluginId]?.config);
|
||||
}
|
||||
|
||||
function resolveApiKey(config: TavilyPluginConfig): string | undefined {
|
||||
return normalizeString(config.apiKey) ?? normalizeString(process.env.TAVILY_API_KEY);
|
||||
}
|
||||
|
||||
function resolveSearchDepth(config: TavilyPluginConfig): "basic" | "advanced" {
|
||||
return config.searchDepth === "advanced" ? "advanced" : "basic";
|
||||
}
|
||||
|
||||
function resolveFreshnessDays(freshness?: string): number | undefined {
|
||||
const normalized = normalizeString(freshness)?.toLowerCase();
|
||||
if (normalized === "day") {
|
||||
return 1;
|
||||
}
|
||||
if (normalized === "week") {
|
||||
return 7;
|
||||
}
|
||||
if (normalized === "month") {
|
||||
return 30;
|
||||
}
|
||||
if (normalized === "year") {
|
||||
return 365;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const plugin = {
|
||||
id: "tavily-search",
|
||||
name: "Tavily Search",
|
||||
description: "Tavily web_search plugin",
|
||||
register(api: OpenClawPluginApi) {
|
||||
api.registerSearchProvider({
|
||||
id: "tavily",
|
||||
name: "Tavily Search",
|
||||
description:
|
||||
"Search the web using Tavily. Returns structured results and an AI-synthesized answer when available.",
|
||||
docsUrl: "https://docs.tavily.com/",
|
||||
configFieldOrder: ["apiKey", "searchDepth"],
|
||||
setup: createSearchProviderSetupMetadata({
|
||||
provider: "tavily",
|
||||
label: "Tavily Search",
|
||||
hint: "Plugin search with structured results and optional AI answer synthesis.",
|
||||
envKeys: ["TAVILY_API_KEY"],
|
||||
placeholder: "tvly-...",
|
||||
signupUrl: "https://app.tavily.com/home",
|
||||
apiKeyConfigPath: "plugins.entries.tavily-search.config.apiKey",
|
||||
install: {
|
||||
npmSpec: "@openclaw/tavily-search",
|
||||
localPath: "extensions/tavily-search",
|
||||
defaultChoice: "local",
|
||||
},
|
||||
requestSchema: Type.Object(
|
||||
{
|
||||
query: Type.String({ description: "Search query string." }),
|
||||
count: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Number of results to return (1-10).",
|
||||
minimum: 1,
|
||||
maximum: MAX_SEARCH_COUNT,
|
||||
}),
|
||||
),
|
||||
country: Type.Optional(
|
||||
Type.String({
|
||||
description: "Optional 2-letter country code for region-specific results.",
|
||||
}),
|
||||
),
|
||||
freshness: Type.Optional(
|
||||
Type.String({
|
||||
description: "Filter by time: 'day', 'week', 'month', or 'year'.",
|
||||
}),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: true },
|
||||
),
|
||||
}),
|
||||
isAvailable: (config) =>
|
||||
Boolean(resolveApiKey(resolveRootPluginConfig(config ?? {}, api.id))),
|
||||
search: async (params, ctx) => {
|
||||
const pluginConfig = resolvePluginConfig(ctx.pluginConfig);
|
||||
const apiKey = resolveApiKey(pluginConfig);
|
||||
if (!apiKey) {
|
||||
return {
|
||||
error: "missing_tavily_api_key",
|
||||
message:
|
||||
"Tavily search provider needs an API key. Set plugins.entries.tavily-search.config.apiKey or TAVILY_API_KEY in the Gateway environment.",
|
||||
};
|
||||
}
|
||||
|
||||
const freshnessDays = resolveFreshnessDays(params.freshness);
|
||||
const body: Record<string, unknown> = {
|
||||
api_key: apiKey,
|
||||
query: params.query,
|
||||
max_results: params.count,
|
||||
search_depth: resolveSearchDepth(pluginConfig),
|
||||
include_answer: true,
|
||||
include_raw_content: false,
|
||||
topic: freshnessDays ? "news" : "general",
|
||||
};
|
||||
if (freshnessDays !== undefined) {
|
||||
body.days = freshnessDays;
|
||||
}
|
||||
if (params.country) {
|
||||
body.country = params.country;
|
||||
}
|
||||
|
||||
const response = await fetch(TAVILY_SEARCH_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(Math.max(ctx.timeoutSeconds, 1) * 1000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text();
|
||||
return {
|
||||
error: "search_failed",
|
||||
message: `Tavily search failed (${response.status}): ${detail || response.statusText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = (await response.json()) as TavilySearchResponse;
|
||||
const results = Array.isArray(data.results) ? data.results : [];
|
||||
|
||||
return {
|
||||
content: normalizeString(data.answer),
|
||||
citations: results
|
||||
.map((entry) => normalizeString(entry.url))
|
||||
.filter((entry): entry is string => Boolean(entry)),
|
||||
results: results
|
||||
.map((entry) => {
|
||||
const url = normalizeString(entry.url);
|
||||
if (!url) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
url,
|
||||
title: normalizeString(entry.title),
|
||||
description: normalizeString(entry.content),
|
||||
published: normalizeString(entry.published_date),
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is NonNullable<typeof entry> => Boolean(entry)),
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"id": "tavily-search",
|
||||
"name": "Tavily Search",
|
||||
"description": "Search the web using Tavily.",
|
||||
"provides": ["providers.search.tavily"],
|
||||
"uiHints": {
|
||||
"apiKey": {
|
||||
"label": "Tavily API key",
|
||||
"placeholder": "tvly-...",
|
||||
"help": "API key for Tavily web search",
|
||||
"sensitive": true
|
||||
},
|
||||
"searchDepth": {
|
||||
"label": "Search depth",
|
||||
"help": "Use advanced for deeper search at higher cost"
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^tvly-\\S+$"
|
||||
},
|
||||
"searchDepth": {
|
||||
"type": "string",
|
||||
"enum": ["basic", "advanced"]
|
||||
}
|
||||
},
|
||||
"required": ["apiKey"]
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"name": "@openclaw/tavily-search",
|
||||
"version": "2026.3.9",
|
||||
"description": "OpenClaw Tavily search plugin",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/tavily-search",
|
||||
"localPath": "extensions/tavily-search",
|
||||
"defaultChoice": "local"
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user