Compare commits

...

35 Commits

Author SHA1 Message Date
Val Alexander
e9b7ccbd6e
format: fix formatting after rebase 2026-03-17 23:58:55 -05:00
Peter Steinberger
f7483b35c3
fix(slack): repair gateway watch runtime export 2026-03-17 23:57:35 -05:00
Peter Steinberger
026be9dfae
refactor: deduplicate channel config adapters 2026-03-17 23:57:35 -05:00
Tak Hoffman
18a8477f03
chore finalize web search provider boundaries 2026-03-17 23:57:35 -05:00
Peter Steinberger
b41aa861d5
test: harden prompt composition coverage 2026-03-17 23:57:35 -05:00
Ayaan Zaidi
1dfeb21cb5
test(telegram): pass explicit deps in command tests 2026-03-17 23:57:35 -05:00
Ayaan Zaidi
3bf540170a
test(telegram): inject bot deps in harnesses 2026-03-17 23:57:35 -05:00
Ayaan Zaidi
e5fd061470
refactor(telegram): inject shared bot deps 2026-03-17 23:57:35 -05:00
Ayaan Zaidi
3a4ed87599
test(telegram): align media harness with runtime seam 2026-03-17 23:57:35 -05:00
Ayaan Zaidi
97ae0158e8
test(telegram): rewire bot harnesses to runtime seams 2026-03-17 23:57:35 -05:00
Ayaan Zaidi
1662f3f5da
test(telegram): add dispatch and handler seams 2026-03-17 23:57:35 -05:00
Ayaan Zaidi
b00f3f9b64
test(telegram): add bot runtime seam 2026-03-17 23:57:35 -05:00
Tak Hoffman
5484225b2d
test add extension plugin sdk boundary guards 2026-03-17 23:57:35 -05:00
Tak Hoffman
a03f43d5bd
refactor web search config ownership into extensions 2026-03-17 23:57:35 -05:00
Peter Steinberger
84cf8c32aa
fix: repair plugin runtime api imports 2026-03-17 23:57:35 -05:00
Gustavo Madeira Santana
65dff0c2a3
Docs: clarify plugin target resolution and directories 2026-03-17 23:57:35 -05:00
Vincent Koc
5d439a43f4
Plugins: internalize slack SDK imports 2026-03-17 23:57:34 -05:00
Vincent Koc
10c16d0de8
Plugins: internalize imessage SDK imports 2026-03-17 23:57:34 -05:00
Vincent Koc
d3ca5fb8a1
Image generation: add fal provider (#49454) 2026-03-17 23:57:34 -05:00
Vincent Koc
cd60db8f54
Tests: clean up trusted proxy pairing seed 2026-03-17 23:57:34 -05:00
joshavant
9b4468fb82
Changelog: update secrets exec refs attribution 2026-03-17 23:57:34 -05:00
Peter Steinberger
49c5f4eb1e
feat: finish xai provider integration 2026-03-17 23:57:34 -05:00
Gustavo Madeira Santana
3186cbbbe2
Plugins: move config-backed directories behind channel plugins 2026-03-17 23:56:58 -05:00
Peter Steinberger
348016d8a8
refactor: split remaining monitor runtime helpers 2026-03-17 23:56:58 -05:00
Gustavo Madeira Santana
0d438921ea
Outbound: move target resolution heuristics behind plugins 2026-03-17 23:56:58 -05:00
Josh Avant
50a2be72fe
Secrets: gate exec dry-run and preflight resolution behind --allow-exec (#49417)
* Secrets: gate exec dry-run resolution behind --allow-exec

* Secrets: fix dry-run completeness and skipped exec audit semantics

* Secrets: require --allow-exec for exec-containing apply writes

* Docs: align secrets exec consent behavior

* Changelog: note secrets exec consent gating
2026-03-17 23:56:58 -05:00
Vincent Koc
371732f399
docs(plugins): dedup in-process trust refs and add manifest cross-references
- Replace redundant in-process trust statements with cross-references
  to the Execution model section (lines 573, 2436)
- Add CLI reference link from plugin.md CLI section
- Add configuration reference link from manifest.md validation section
- Add provider runtime hooks link from manifest.md providerAuthChoices

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 23:56:57 -05:00
Val Alexander
473ba3149b
changelog: add entries for UI expand/navigate and plugin TDZ/import fixes 2026-03-17 23:53:03 -05:00
Val Alexander
5116dbeb60
Plugins: use var for reserved commands to avoid bundler TDZ; scope expand button to assistant; wire cron run navigate 2026-03-17 23:53:03 -05:00
Val Alexander
0bd4c7cd43
Update ui/src/ui/chat/grouped-render.ts
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-17 23:49:00 -05:00
Val Alexander
feddfb4859
Refactor: lazy initialize reserved commands to avoid TDZ errors 2026-03-17 23:43:54 -05:00
Val Alexander
95e20e8a35
UI: add expand-to-canvas button and session navigation from sessions/cron views 2026-03-17 23:42:22 -05:00
Val Alexander
52662d39d6
format: sort imports and fix parameter formatting 2026-03-17 23:42:13 -05:00
Val Alexander
3d0b3526e0
Plugins: fix googlechat runtime-api and signal SDK import paths 2026-03-17 23:42:03 -05:00
Val Alexander
0a30d5a4e1
UI: mute colored focus ring on agent chat textarea 2026-03-17 23:25:18 -05:00
233 changed files with 8727 additions and 3500 deletions

4
.github/labeler.yml vendored
View File

@ -314,3 +314,7 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/xiaomi/**"
"extensions: fal":
- changed-files:
- any-glob-to-any-file:
- "extensions/fal/**"

View File

@ -398,6 +398,100 @@ jobs:
echo "::error::Web search provider boundary grace period ended at ${WEB_SEARCH_PROVIDER_BOUNDARY_ENFORCE_AFTER}. ${fix_instructions}"
exit "$status"
extension-src-outside-plugin-sdk-boundary:
name: "extension-src-outside-plugin-sdk-boundary"
needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
env:
EXTENSION_PLUGIN_SDK_BOUNDARY_ENFORCE_AFTER: "2026-03-24T05:00:00Z"
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
use-sticky-disk: "false"
- name: Run extension src boundary guard with grace period
shell: bash
run: |
set -euo pipefail
tmp_output="$(mktemp)"
if pnpm run lint:extensions:no-src-outside-plugin-sdk >"$tmp_output" 2>&1; then
cat "$tmp_output"
rm -f "$tmp_output"
exit 0
fi
status=$?
cat "$tmp_output"
rm -f "$tmp_output"
now_epoch="$(date -u +%s)"
enforce_epoch="$(date -u -d "$EXTENSION_PLUGIN_SDK_BOUNDARY_ENFORCE_AFTER" +%s)"
fix_instructions="If you are an LLM agent fixing this: run 'pnpm run lint:extensions:no-src-outside-plugin-sdk', move extension imports off core src paths and onto src/plugin-sdk/**, and if the remaining inventory is intentional for now update test/fixtures/extension-src-outside-plugin-sdk-inventory.json in the same PR."
if [ "$now_epoch" -lt "$enforce_epoch" ]; then
echo "::warning::Extension src boundary violations are temporarily allowed until ${EXTENSION_PLUGIN_SDK_BOUNDARY_ENFORCE_AFTER}. This grace period ends in one week from the rollout date. After that timestamp this job will fail unless the inventory is reduced or the baseline is intentionally updated. ${fix_instructions}"
exit 0
fi
echo "::error::Extension src boundary grace period ended at ${EXTENSION_PLUGIN_SDK_BOUNDARY_ENFORCE_AFTER}. ${fix_instructions}"
exit "$status"
extension-plugin-sdk-internal-boundary:
name: "extension-plugin-sdk-internal-boundary"
needs: [docs-scope, changed-scope]
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
runs-on: blacksmith-16vcpu-ubuntu-2404
env:
EXTENSION_PLUGIN_SDK_INTERNAL_ENFORCE_AFTER: "2026-03-24T05:00:00Z"
steps:
- name: Checkout
uses: actions/checkout@v6
with:
submodules: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
use-sticky-disk: "false"
- name: Run extension plugin-sdk-internal guard with grace period
shell: bash
run: |
set -euo pipefail
tmp_output="$(mktemp)"
if pnpm run lint:extensions:no-plugin-sdk-internal >"$tmp_output" 2>&1; then
cat "$tmp_output"
rm -f "$tmp_output"
exit 0
fi
status=$?
cat "$tmp_output"
rm -f "$tmp_output"
now_epoch="$(date -u +%s)"
enforce_epoch="$(date -u -d "$EXTENSION_PLUGIN_SDK_INTERNAL_ENFORCE_AFTER" +%s)"
fix_instructions="If you are an LLM agent fixing this: run 'pnpm run lint:extensions:no-plugin-sdk-internal', remove extension imports of src/plugin-sdk-internal/** in favor of src/plugin-sdk/**, and if the remaining inventory is intentional for now update test/fixtures/extension-plugin-sdk-internal-inventory.json in the same PR."
if [ "$now_epoch" -lt "$enforce_epoch" ]; then
echo "::warning::Extension plugin-sdk-internal boundary violations are temporarily allowed until ${EXTENSION_PLUGIN_SDK_INTERNAL_ENFORCE_AFTER}. This grace period ends in one week from the rollout date. After that timestamp this job will fail unless the inventory is reduced or the baseline is intentionally updated. ${fix_instructions}"
exit 0
fi
echo "::error::Extension plugin-sdk-internal boundary grace period ended at ${EXTENSION_PLUGIN_SDK_INTERNAL_ENFORCE_AFTER}. ${fix_instructions}"
exit "$status"
build-smoke:
name: "build-smoke"
needs: [docs-scope, changed-scope]

View File

@ -40,9 +40,12 @@ Docs: https://docs.openclaw.ai
- Plugins/binding: add `onConversationBindingResolved(...)` so plugins can react immediately after bind approvals or denies without blocking channel interaction acknowledgements. (#48678) Thanks @huntharo.
- CLI/config: expand `config set` with SecretRef and provider builder modes, JSON/batch assignment support, and `--dry-run` validation with structured JSON output. (#49296) Thanks @joshavant.
- Control UI/appearance: unify theme border radii across Claw, Knot, and Dash, and add a Roundness slider to the Appearance settings so users can adjust corner radius from sharp to fully rounded. Thanks @BunsDev.
- Control UI/chat: add an expand-to-canvas button on assistant chat bubbles and in-app session navigation from Sessions and Cron views. Thanks @BunsDev.
### Fixes
- Plugins/bundler TDZ: fix `RESERVED_COMMANDS` temporal dead zone error that prevented device-pair, phone-control, and talk-voice plugins from registering when the bundler placed the commands module after call sites in the same output chunk. Thanks @BunsDev.
- Plugins/imports: fix stale googlechat runtime-api import paths and signal SDK circular re-exports broken by recent plugin-sdk refactors. Thanks @BunsDev.
- Google auth/Node 25: patch `gaxios` to use native fetch without injecting `globalThis.window`, while translating proxy and mTLS transport settings so Google Vertex and Google Chat auth keep working on Node 25. (#47914) Thanks @pdd-cli.
- Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse. (#47560) Thanks @ngutman.
- Plugins/context engines: enforce owner-aware context-engine registration on both loader and public SDK paths so plugins cannot spoof privileged ownership, claim the core `legacy` engine id, or overwrite an existing engine id through direct SDK imports. (#47595) Thanks @vincentkoc.
@ -135,6 +138,8 @@ Docs: https://docs.openclaw.ai
- Gateway/auth: add regression coverage that keeps device-less trusted-proxy Control UI sessions off privileged pairing approval RPCs. Thanks @vincentkoc.
- Plugins/runtime-api: pin extension runtime-api export seams with explicit guardrail coverage so future surface creep becomes a deliberate diff. Thanks @vincentkoc.
- Telegram/security: add regression coverage proving pinned fallback host overrides stay bound to Telegram and delegate non-matching hostnames back to the original lookup path. Thanks @vincentkoc.
- Secrets/exec refs: require explicit `--allow-exec` for `secrets apply` write plans that contain exec SecretRefs/providers, and align audit/configure/apply dry-run behavior to skip exec checks unless opted in to prevent unexpected command side effects. (#49417) Thanks @restriction and @joshavant.
- Tools/image generation: add bundled fal image generation support so `image_generate` can target `fal/*` models with `FAL_KEY`, including single-image edit flows via FLUX image-to-image. Thanks @vincentkoc.
### Breaking

View File

@ -276,9 +276,9 @@ Note: plugins can add additional top-level commands (for example `openclaw voice
## Secrets
- `openclaw secrets reload` — re-resolve refs and atomically swap the runtime snapshot.
- `openclaw secrets audit` — scan for plaintext residues, unresolved refs, and precedence drift.
- `openclaw secrets configure` — interactive helper for provider setup + SecretRef mapping + preflight/apply.
- `openclaw secrets apply --from <plan.json>` — apply a previously generated plan (`--dry-run` supported).
- `openclaw secrets audit` — scan for plaintext residues, unresolved refs, and precedence drift (`--allow-exec` to execute exec providers during audit).
- `openclaw secrets configure` — interactive helper for provider setup + SecretRef mapping + preflight/apply (`--allow-exec` to execute exec providers during preflight and exec-containing apply flows).
- `openclaw secrets apply --from <plan.json>` — apply a previously generated plan (`--dry-run` supported; use `--allow-exec` to permit exec providers in dry-run and exec-containing write plans).
## Plugins

View File

@ -14,9 +14,9 @@ Use `openclaw secrets` to manage SecretRefs and keep the active runtime snapshot
Command roles:
- `reload`: gateway RPC (`secrets.reload`) that re-resolves refs and swaps runtime snapshot only on full success (no config writes).
- `audit`: read-only scan of configuration/auth/generated-model stores and legacy residues for plaintext, unresolved refs, and precedence drift.
- `audit`: read-only scan of configuration/auth/generated-model stores and legacy residues for plaintext, unresolved refs, and precedence drift (exec refs are skipped unless `--allow-exec` is set).
- `configure`: interactive planner for provider setup, target mapping, and preflight (TTY required).
- `apply`: execute a saved plan (`--dry-run` for validation only), then scrub targeted plaintext residues.
- `apply`: execute a saved plan (`--dry-run` for validation only; dry-run skips exec checks by default, and write mode rejects exec-containing plans unless `--allow-exec` is set), then scrub targeted plaintext residues.
Recommended operator loop:
@ -29,6 +29,8 @@ openclaw secrets audit --check
openclaw secrets reload
```
If your plan includes `exec` SecretRefs/providers, pass `--allow-exec` on both dry-run and write apply commands.
Exit code note for CI/gates:
- `audit --check` returns `1` on findings.
@ -73,6 +75,7 @@ Header residue note:
openclaw secrets audit
openclaw secrets audit --check
openclaw secrets audit --json
openclaw secrets audit --allow-exec
```
Exit behavior:
@ -83,6 +86,7 @@ Exit behavior:
Report shape highlights:
- `status`: `clean | findings | unresolved`
- `resolution`: `refsChecked`, `skippedExecRefs`, `resolvabilityComplete`
- `summary`: `plaintextCount`, `unresolvedRefCount`, `shadowedRefCount`, `legacyResidueCount`
- finding codes:
- `PLAINTEXT_FOUND`
@ -115,6 +119,7 @@ Flags:
- `--providers-only`: configure `secrets.providers` only, skip credential mapping.
- `--skip-provider-setup`: skip provider setup and map credentials to existing providers.
- `--agent <id>`: scope `auth-profiles.json` target discovery and writes to one agent store.
- `--allow-exec`: allow exec SecretRef checks during preflight/apply (may execute provider commands).
Notes:
@ -124,6 +129,7 @@ Notes:
- `configure` supports creating new `auth-profiles.json` mappings directly in the picker flow.
- Canonical supported surface: [SecretRef Credential Surface](/reference/secretref-credential-surface).
- It performs preflight resolution before apply.
- If preflight/apply includes exec refs, keep `--allow-exec` set for both steps.
- Generated plans default to scrub options (`scrubEnv`, `scrubAuthProfilesForProviderTargets`, `scrubLegacyAuthJson` all enabled).
- Apply path is one-way for scrubbed plaintext values.
- Without `--apply`, CLI still prompts `Apply this plan now?` after preflight.
@ -141,10 +147,19 @@ Apply or preflight a plan generated previously:
```bash
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --allow-exec
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run --allow-exec
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --json
```
Exec behavior:
- `--dry-run` validates preflight without writing files.
- exec SecretRef checks are skipped by default in dry-run.
- write mode rejects plans that contain exec SecretRefs/providers unless `--allow-exec` is set.
- Use `--allow-exec` to opt in to exec provider checks/execution in either mode.
Plan contract details (allowed target paths, validation rules, and failure semantics):
- [Secrets Apply Plan Contract](/gateway/secrets-plan-contract)

View File

@ -81,6 +81,12 @@ Invalid plan target path for models.providers.apiKey: models.providers.openai.ba
No writes are committed for an invalid plan.
## Exec provider consent behavior
- `--dry-run` skips exec SecretRef checks by default.
- Plans containing exec SecretRefs/providers are rejected in write mode unless `--allow-exec` is set.
- When validating/applying exec-containing plans, pass `--allow-exec` in both dry-run and write commands.
## Runtime and audit scope notes
- Ref-only `auth-profiles.json` entries (`keyRef`/`tokenRef`) are included in runtime resolution and audit coverage.
@ -94,6 +100,10 @@ openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run
# Then apply for real
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json
# For exec-containing plans, opt in explicitly in both modes
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run --allow-exec
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --allow-exec
```
If apply fails with an invalid target path message, regenerate the plan with `openclaw secrets configure` or fix the target path to a supported shape above.

View File

@ -414,6 +414,11 @@ Findings include:
- precedence shadowing (`auth-profiles.json` taking priority over `openclaw.json` refs)
- legacy residues (`auth.json`, OAuth reminders)
Exec note:
- By default, audit skips exec SecretRef resolvability checks to avoid command side effects.
- Use `openclaw secrets audit --allow-exec` to execute exec providers during audit.
Header residue note:
- Sensitive provider header detection is name-heuristic based (common auth/credential header names and fragments such as `authorization`, `x-api-key`, `token`, `secret`, `password`, and `credential`).
@ -429,6 +434,11 @@ Interactive helper that:
- runs preflight resolution
- can apply immediately
Exec note:
- Preflight skips exec SecretRef checks unless `--allow-exec` is set.
- If you apply directly from `configure --apply` and the plan includes exec refs/providers, keep `--allow-exec` set for the apply step too.
Helpful modes:
- `openclaw secrets configure --providers-only`
@ -447,9 +457,16 @@ Apply a saved plan:
```bash
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --allow-exec
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run --allow-exec
```
Exec note:
- dry-run skips exec checks unless `--allow-exec` is set.
- write mode rejects plans containing exec SecretRefs/providers unless `--allow-exec` is set.
For strict target/path contract details and exact rejection rules, see:
- [Secrets Apply Plan Contract](/gateway/secrets-plan-contract)

View File

@ -121,6 +121,8 @@ Example:
- If plugin config exists but the plugin is **disabled**, the config is kept and
a **warning** is surfaced in Doctor + logs.
See [Configuration reference](/configuration) for the full `plugins.*` schema.
## Notes
- The manifest is **required for native OpenClaw plugins**, including local filesystem loads.
@ -131,7 +133,9 @@ Example:
runtime just to inspect env names.
- `providerAuthChoices` is the cheap metadata path for auth-choice pickers,
`--auth-choice` resolution, preferred-provider mapping, and simple onboarding
CLI flag registration before provider runtime loads.
CLI flag registration before provider runtime loads. For runtime wizard
metadata that requires provider code, see
[Provider runtime hooks](/tools/plugin#provider-runtime-hooks).
- Exclusive plugin kinds are selected through `plugins.slots.*`.
- `kind: "memory"` is selected by `plugins.slots.memory`.
- `kind: "context-engine"` is selected by `plugins.slots.contextEngine`

View File

@ -47,6 +47,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
- [Venice (Venice AI, privacy-focused)](/providers/venice)
- [vLLM (local models)](/providers/vllm)
- [xAI](/providers/xai)
- [Xiaomi](/providers/xiaomi)
- [Z.AI](/providers/zai)

View File

@ -39,6 +39,7 @@ model as `provider/model`.
- [Venice (Venice AI)](/providers/venice)
- [Amazon Bedrock](/providers/bedrock)
- [Qianfan](/providers/qianfan)
- [xAI](/providers/xai)
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
see [Model providers](/concepts/model-providers).

61
docs/providers/xai.md Normal file
View File

@ -0,0 +1,61 @@
---
summary: "Use xAI Grok models in OpenClaw"
read_when:
- You want to use Grok models in OpenClaw
- You are configuring xAI auth or model ids
title: "xAI"
---
# xAI
OpenClaw ships a bundled `xai` provider plugin for Grok models.
## Setup
1. Create an API key in the xAI console.
2. Set `XAI_API_KEY`, or run:
```bash
openclaw onboard --auth-choice xai-api-key
```
3. Pick a model such as:
```json5
{
agents: { defaults: { model: { primary: "xai/grok-4" } } },
}
```
## Current bundled model catalog
OpenClaw now includes these xAI model families out of the box:
- `grok-4`, `grok-4-0709`
- `grok-4-fast-reasoning`, `grok-4-fast-non-reasoning`
- `grok-4-1-fast-reasoning`, `grok-4-1-fast-non-reasoning`
- `grok-4.20-experimental-beta-0304-reasoning`
- `grok-4.20-experimental-beta-0304-non-reasoning`
- `grok-code-fast-1`
The plugin also forward-resolves newer `grok-4*` and `grok-code-fast*` ids when
they follow the same API shape.
## Web search
The bundled `grok` web-search provider uses `XAI_API_KEY` too:
```bash
openclaw config set tools.web.search.provider grok
```
## Known limits
- Auth is API-key only today. There is no xAI OAuth/device-code flow in OpenClaw yet.
- `grok-4.20-multi-agent-experimental-beta-0304` is not supported on the normal xAI provider path because it requires a different upstream API surface than the standard OpenClaw xAI transport.
- Native xAI server-side tools such as `x_search` and `code_execution` are not yet first-class model-provider features in the bundled plugin.
## Notes
- OpenClaw applies xAI-specific tool-schema and tool-call compatibility fixes automatically on the shared runner path.
- For the broader provider overview, see [Model providers](/providers/index).

View File

@ -570,7 +570,8 @@ Native OpenClaw plugins can register capabilities and surfaces:
- **Skills** (by listing `skills` directories in the plugin manifest)
- **Auto-reply commands** (execute without invoking the AI agent)
Native OpenClaw plugins run **inprocess** with the Gateway, so treat them as trusted code.
Native OpenClaw plugins run in-process with the Gateway (see
[Execution model](#execution-model) for trust implications).
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
Think of these registrations as **capability claims**. A plugin is not supposed
@ -1139,6 +1140,54 @@ authoring plugins:
`openclaw/plugin-sdk/twitch`, `openclaw/plugin-sdk/voice-call`,
`openclaw/plugin-sdk/zalo`, and `openclaw/plugin-sdk/zalouser`.
## Channel target resolution
Channel plugins should own channel-specific target semantics. Keep the shared
outbound host generic and use the messaging adapter surface for provider rules:
- `messaging.inferTargetChatType({ to })` decides whether a normalized target
should be treated as `direct`, `group`, or `channel` before directory lookup.
- `messaging.targetResolver.looksLikeId(raw, normalized)` tells core whether an
input should skip straight to id-like resolution instead of directory search.
- `messaging.targetResolver.resolveTarget(...)` is the plugin fallback when
core needs a final provider-owned resolution after normalization or after a
directory miss.
- `messaging.resolveOutboundSessionRoute(...)` owns provider-specific session
route construction once a target is resolved.
Recommended split:
- Use `inferTargetChatType` for category decisions that should happen before
searching peers/groups.
- Use `looksLikeId` for “treat this as an explicit/native target id” checks.
- Use `resolveTarget` for provider-specific normalization fallback, not for
broad directory search.
- Keep provider-native ids like chat ids, thread ids, JIDs, handles, and room
ids inside `target` values or provider-specific params, not in generic SDK
fields.
## Config-backed directories
Plugins that derive directory entries from config should keep that logic in the
plugin and reuse the shared helpers from
`openclaw/plugin-sdk/directory-runtime`.
Use this when a channel needs config-backed peers/groups such as:
- allowlist-driven DM peers
- configured channel/group maps
- account-scoped static directory fallbacks
The shared helpers in `directory-runtime` only handle generic operations:
- query filtering
- limit application
- deduping/normalization helpers
- building `ChannelDirectoryEntry[]`
Channel-specific account inspection and id normalization should stay in the
plugin implementation.
## Provider catalogs
Provider plugins can define model catalogs for inference with
@ -1618,14 +1667,11 @@ openclaw plugins disable <id>
openclaw plugins doctor
```
`openclaw plugins list` shows the top-level format as `openclaw` or `bundle`.
Verbose list/inspect output also shows bundle subtype (`codex`, `claude`, or
`cursor`) plus detected bundle capabilities.
See [`openclaw plugins` CLI reference](/cli/plugins) for full details on each
command (install rules, inspect output, marketplace installs, uninstall).
`plugins update` only works for npm installs tracked under `plugins.installs`.
If stored integrity metadata changes between updates, OpenClaw warns and asks for confirmation (use global `--yes` to bypass prompts).
Plugins may also register their own toplevel commands (example: `openclaw voicecall`).
Plugins may also register their own top-level commands (example:
`openclaw voicecall`).
## Plugin API (overview)
@ -2433,7 +2479,7 @@ See [Voice Call](/plugins/voice-call) and `extensions/voice-call/README.md` for
## Safety notes
Plugins run in-process with the Gateway. Treat them as trusted code:
Plugins run in-process with the Gateway (see [Execution model](#execution-model)):
- Only install plugins you trust.
- Prefer `plugins.allow` allowlists.

View File

@ -19,4 +19,27 @@ describe("amazon-bedrock provider plugin", () => {
} as never),
).toBeUndefined();
});
it("disables prompt caching for non-Anthropic Bedrock models", () => {
const provider = registerSingleProviderPlugin(amazonBedrockPlugin);
const wrapped = provider.wrapStreamFn?.({
provider: "amazon-bedrock",
modelId: "amazon.nova-micro-v1:0",
streamFn: (_model, _context, options) => options,
} as never);
expect(
wrapped?.(
{
api: "openai-completions",
provider: "amazon-bedrock",
id: "amazon.nova-micro-v1:0",
} as never,
{ messages: [] } as never,
{},
),
).toMatchObject({
cacheRetention: "none",
});
});
});

View File

@ -1,4 +1,8 @@
import { definePluginEntry } from "openclaw/plugin-sdk/core";
import {
createBedrockNoCacheWrapper,
isAnthropicBedrockModel,
} from "openclaw/plugin-sdk/provider-stream";
const PROVIDER_ID = "amazon-bedrock";
const CLAUDE_46_MODEL_RE = /claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i;
@ -13,6 +17,8 @@ export default definePluginEntry({
label: "Amazon Bedrock",
docsPath: "/providers/models",
auth: [],
wrapStreamFn: ({ modelId, streamFn }) =>
isAnthropicBedrockModel(modelId) ? streamFn : createBedrockNoCacheWrapper(streamFn),
resolveDefaultThinkingLevel: ({ modelId }) =>
CLAUDE_46_MODEL_RE.test(modelId.trim()) ? "adaptive" : undefined,
});

View File

@ -1,7 +1,6 @@
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
import {
createScopedAccountConfigAccessors,
createScopedChannelConfigBase,
createScopedChannelConfigAdapter,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
@ -34,6 +33,8 @@ import { blueBubblesSetupAdapter } from "./setup-core.js";
import { blueBubblesSetupWizard } from "./setup-surface.js";
import {
extractHandleFromChatGuid,
inferBlueBubblesTargetChatType,
looksLikeBlueBubblesExplicitTargetId,
looksLikeBlueBubblesTargetId,
normalizeBlueBubblesHandle,
normalizeBlueBubblesMessagingTarget,
@ -45,8 +46,12 @@ const loadBlueBubblesChannelRuntime = createLazyRuntimeNamedExport(
"blueBubblesChannelRuntime",
);
const bluebubblesConfigAccessors = createScopedAccountConfigAccessors({
resolveAccount: ({ cfg, accountId }) => resolveBlueBubblesAccount({ cfg, accountId }),
const bluebubblesConfigAdapter = createScopedChannelConfigAdapter<ResolvedBlueBubblesAccount>({
sectionKey: "bluebubbles",
listAccountIds: listBlueBubblesAccountIds,
resolveAccount: (cfg, accountId) => resolveBlueBubblesAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultBlueBubblesAccountId,
clearBaseFields: ["serverUrl", "password", "name", "webhookPath"],
resolveAllowFrom: (account: ResolvedBlueBubblesAccount) => account.config.allowFrom,
formatAllowFrom: (allowFrom) =>
formatNormalizedAllowFromEntries({
@ -55,14 +60,6 @@ const bluebubblesConfigAccessors = createScopedAccountConfigAccessors({
}),
});
const bluebubblesConfigBase = createScopedChannelConfigBase<ResolvedBlueBubblesAccount>({
sectionKey: "bluebubbles",
listAccountIds: listBlueBubblesAccountIds,
resolveAccount: (cfg, accountId) => resolveBlueBubblesAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultBlueBubblesAccountId,
clearBaseFields: ["serverUrl", "password", "name", "webhookPath"],
});
const resolveBlueBubblesDmPolicy = createScopedDmSecurityResolver<ResolvedBlueBubblesAccount>({
channelKey: "bluebubbles",
resolvePolicy: (account) => account.config.dmPolicy,
@ -113,7 +110,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema),
setupWizard: blueBubblesSetupWizard,
config: {
...bluebubblesConfigBase,
...bluebubblesConfigAdapter,
isConfigured: (account) => account.configured,
describeAccount: (account): ChannelAccountSnapshot => ({
accountId: account.accountId,
@ -122,7 +119,6 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
configured: account.configured,
baseUrl: account.baseUrl,
}),
...bluebubblesConfigAccessors,
},
actions: bluebubblesMessageActions,
security: {
@ -141,10 +137,26 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
},
messaging: {
normalizeTarget: normalizeBlueBubblesMessagingTarget,
inferTargetChatType: ({ to }) => inferBlueBubblesTargetChatType(to),
resolveOutboundSessionRoute: (params) => resolveBlueBubblesOutboundSessionRoute(params),
targetResolver: {
looksLikeId: looksLikeBlueBubblesTargetId,
looksLikeId: looksLikeBlueBubblesExplicitTargetId,
hint: "<handle|chat_guid:GUID|chat_id:ID|chat_identifier:ID>",
resolveTarget: async ({ normalized }) => {
const to = normalized?.trim();
if (!to) {
return null;
}
const chatType = inferBlueBubblesTargetChatType(to);
if (!chatType) {
return null;
}
return {
to,
kind: chatType === "direct" ? "user" : "group",
source: "normalized" as const,
};
},
},
formatTargetDisplay: ({ target, display }) => {
const shouldParseDisplay = (value: string): boolean => {

View File

@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import {
inferBlueBubblesTargetChatType,
looksLikeBlueBubblesExplicitTargetId,
isAllowedBlueBubblesSender,
looksLikeBlueBubblesTargetId,
normalizeBlueBubblesMessagingTarget,
@ -101,6 +103,30 @@ describe("looksLikeBlueBubblesTargetId", () => {
});
});
describe("looksLikeBlueBubblesExplicitTargetId", () => {
it("treats explicit chat targets as immediate ids", () => {
expect(looksLikeBlueBubblesExplicitTargetId("chat_guid:ABC-123")).toBe(true);
expect(looksLikeBlueBubblesExplicitTargetId("imessage:+15551234567")).toBe(true);
});
it("prefers directory fallback for bare handles and phone numbers", () => {
expect(looksLikeBlueBubblesExplicitTargetId("+1 (555) 123-4567")).toBe(false);
expect(looksLikeBlueBubblesExplicitTargetId("user@example.com")).toBe(false);
});
});
describe("inferBlueBubblesTargetChatType", () => {
it("infers direct chat for handles and dm chat_guids", () => {
expect(inferBlueBubblesTargetChatType("+15551234567")).toBe("direct");
expect(inferBlueBubblesTargetChatType("chat_guid:iMessage;-;+15551234567")).toBe("direct");
});
it("infers group chat for explicit group targets", () => {
expect(inferBlueBubblesTargetChatType("chat_id:123")).toBe("group");
expect(inferBlueBubblesTargetChatType("chat_guid:iMessage;+;chat123")).toBe("group");
});
});
describe("parseBlueBubblesTarget", () => {
it("parses chat<digits> pattern as chat_identifier", () => {
expect(parseBlueBubblesTarget("chat660250192681427962")).toEqual({

View File

@ -237,6 +237,63 @@ export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string):
return false;
}
export function looksLikeBlueBubblesExplicitTargetId(raw: string, normalized?: string): boolean {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
const candidate = stripBlueBubblesPrefix(trimmed);
if (!candidate) {
return false;
}
const lowered = candidate.toLowerCase();
if (/^(imessage|sms|auto):/.test(lowered)) {
return true;
}
if (
/^(chat_id|chatid|chat|chat_guid|chatguid|guid|chat_identifier|chatidentifier|chatident|group):/.test(
lowered,
)
) {
return true;
}
if (parseRawChatGuid(candidate) || looksLikeRawChatIdentifier(candidate)) {
return true;
}
if (normalized) {
const normalizedTrimmed = normalized.trim();
if (!normalizedTrimmed) {
return false;
}
const normalizedLower = normalizedTrimmed.toLowerCase();
if (
/^(imessage|sms|auto):/.test(normalizedLower) ||
/^(chat_id|chat_guid|chat_identifier):/.test(normalizedLower)
) {
return true;
}
}
return false;
}
export function inferBlueBubblesTargetChatType(raw: string): "direct" | "group" | undefined {
try {
const parsed = parseBlueBubblesTarget(raw);
if (parsed.kind === "handle") {
return "direct";
}
if (parsed.kind === "chat_guid") {
return parsed.chatGuid.includes(";+;") ? "group" : "direct";
}
if (parsed.kind === "chat_id" || parsed.kind === "chat_identifier") {
return "group";
}
} catch {
return undefined;
}
return undefined;
}
export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
const trimmed = stripBlueBubblesPrefix(raw);
if (!trimmed) {

View File

@ -1,8 +1,34 @@
{
"id": "brave",
"uiHints": {
"webSearch.apiKey": {
"label": "Brave Search API Key",
"help": "Brave Search API key (fallback: BRAVE_API_KEY env var).",
"sensitive": true,
"placeholder": "BSA..."
},
"webSearch.mode": {
"label": "Brave Search Mode",
"help": "Brave Search mode: web or llm-context."
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
"properties": {
"webSearch": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": ["string", "object"]
},
"mode": {
"type": "string",
"enum": ["web", "llm-context"]
}
}
}
}
}
}

View File

@ -17,7 +17,12 @@ import {
withTrustedWebSearchEndpoint,
writeCachedSearchPayload,
} from "../../../src/agents/tools/web-search-provider-common.js";
import {
resolveProviderWebSearchPluginConfig,
setProviderWebSearchPluginConfigValue,
} from "../../../src/agents/tools/web-search-provider-config.js";
import { formatCliCommand } from "../../../src/cli/command-format.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type {
WebSearchProviderPlugin,
WebSearchProviderToolDefinition,
@ -90,6 +95,7 @@ const BRAVE_SEARCH_LANG_ALIASES: Record<string, string> = {
const BRAVE_UI_LANG_LOCALE = /^([a-z]{2})-([a-z]{2})$/i;
type BraveConfig = {
apiKey?: unknown;
mode?: string;
};
@ -112,18 +118,41 @@ type BraveLlmContextResponse = {
sources?: { url?: string; hostname?: string; date?: string }[];
};
function resolveBraveConfig(searchConfig?: SearchConfigRecord): BraveConfig {
const brave = searchConfig?.brave;
return brave && typeof brave === "object" && !Array.isArray(brave) ? (brave as BraveConfig) : {};
function resolveBraveConfig(
config?: OpenClawConfig,
searchConfig?: SearchConfigRecord,
): BraveConfig {
const pluginConfig = resolveProviderWebSearchPluginConfig(config, "brave");
if (pluginConfig) {
return pluginConfig as BraveConfig;
}
const scoped = (searchConfig as Record<string, unknown> | undefined)?.brave;
return scoped && typeof scoped === "object" && !Array.isArray(scoped)
? ({
...(scoped as BraveConfig),
apiKey: (searchConfig as Record<string, unknown> | undefined)?.apiKey,
} as BraveConfig)
: ({ apiKey: (searchConfig as Record<string, unknown> | undefined)?.apiKey } as BraveConfig);
}
function resolveBraveMode(brave: BraveConfig): "web" | "llm-context" {
return brave.mode === "llm-context" ? "llm-context" : "web";
}
function resolveBraveApiKey(searchConfig?: SearchConfigRecord): string | undefined {
function resolveBraveApiKey(
config?: OpenClawConfig,
searchConfig?: SearchConfigRecord,
): string | undefined {
const braveConfig = resolveBraveConfig(config, searchConfig);
return (
readConfiguredSecretString(searchConfig?.apiKey, "tools.web.search.apiKey") ??
readConfiguredSecretString(
braveConfig.apiKey,
"plugins.entries.brave.config.webSearch.apiKey",
) ??
readConfiguredSecretString(
(searchConfig as Record<string, unknown> | undefined)?.apiKey,
"tools.web.search.apiKey",
) ??
readProviderEnvValue(["BRAVE_API_KEY"])
);
}
@ -384,9 +413,10 @@ function missingBraveKeyPayload() {
}
function createBraveToolDefinition(
config?: OpenClawConfig,
searchConfig?: SearchConfigRecord,
): WebSearchProviderToolDefinition {
const braveConfig = resolveBraveConfig(searchConfig);
const braveConfig = resolveBraveConfig(config, searchConfig);
const braveMode = resolveBraveMode(braveConfig);
return {
@ -396,7 +426,7 @@ function createBraveToolDefinition(
: "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.",
parameters: createBraveSchema(),
execute: async (args) => {
const apiKey = resolveBraveApiKey(searchConfig);
const apiKey = resolveBraveApiKey(config, searchConfig);
if (!apiKey) {
return missingBraveKeyPayload();
}
@ -594,14 +624,19 @@ export function createBraveWebSearchProvider(): WebSearchProviderPlugin {
signupUrl: "https://brave.com/search/api/",
docsUrl: "https://docs.openclaw.ai/brave-search",
autoDetectOrder: 10,
credentialPath: "tools.web.search.apiKey",
inactiveSecretPaths: ["tools.web.search.apiKey"],
credentialPath: "plugins.entries.brave.config.webSearch.apiKey",
inactiveSecretPaths: ["plugins.entries.brave.config.webSearch.apiKey"],
getCredentialValue: (searchConfig) => searchConfig?.apiKey,
setCredentialValue: (searchConfigTarget, value) => {
searchConfigTarget.apiKey = value;
},
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "brave")?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
setProviderWebSearchPluginConfigValue(configTarget, "brave", "apiKey", value);
},
createTool: (ctx) =>
createBraveToolDefinition(ctx.searchConfig as SearchConfigRecord | undefined),
createBraveToolDefinition(ctx.config, ctx.searchConfig as SearchConfigRecord | undefined),
};
}

View File

@ -18,6 +18,10 @@ import {
type ResolvedDiscordAccount,
} from "./accounts.js";
import { auditDiscordChannelPermissions, collectDiscordAuditChannelIds } from "./audit.js";
import {
listDiscordDirectoryGroupsFromConfig,
listDiscordDirectoryPeersFromConfig,
} from "./directory-config.js";
import {
isDiscordExecApprovalClientEnabled,
shouldSuppressLocalDiscordExecApprovalPrompt,
@ -41,8 +45,6 @@ import {
type ChannelPlugin,
DEFAULT_ACCOUNT_ID,
getChatChannelMeta,
listDiscordDirectoryGroupsFromConfig,
listDiscordDirectoryPeersFromConfig,
PAIRING_APPROVED_MESSAGE,
projectCredentialSnapshotFields,
resolveConfiguredFromCredentialStatuses,
@ -51,7 +53,7 @@ import {
import { getDiscordRuntime } from "./runtime.js";
import { fetchChannelPermissionsDiscord } from "./send.js";
import { discordSetupAdapter } from "./setup-core.js";
import { createDiscordPluginBase, discordConfigAccessors } from "./shared.js";
import { createDiscordPluginBase, discordConfigAdapter } from "./shared.js";
import { collectDiscordStatusIssues } from "./status-issues.js";
import { parseDiscordTarget } from "./targets.js";
import { DiscordUiContainer } from "./ui.js";
@ -305,7 +307,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
applyConfigEdit: buildAccountScopedAllowlistConfigEditor({
channelId: "discord",
normalize: ({ cfg, accountId, values }) =>
discordConfigAccessors.formatAllowFrom!({ cfg, accountId, allowFrom: values }),
discordConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }),
resolvePaths: resolveLegacyDmAllowlistConfigPaths,
}),
},

View File

@ -0,0 +1,57 @@
import {
applyDirectoryQueryAndLimit,
collectNormalizedDirectoryIds,
toDirectoryEntries,
type DirectoryConfigParams,
} from "openclaw/plugin-sdk/directory-runtime";
import type { InspectedDiscordAccount } from "../../../src/channels/read-only-account-inspect.discord.runtime.js";
import { inspectReadOnlyChannelAccount } from "../../../src/channels/read-only-account-inspect.js";
export async function listDiscordDirectoryPeersFromConfig(params: DirectoryConfigParams) {
const account = (await inspectReadOnlyChannelAccount({
channelId: "discord",
cfg: params.cfg,
accountId: params.accountId,
})) as InspectedDiscordAccount | null;
if (!account || !("config" in account)) {
return [];
}
const allowFrom = account.config.allowFrom ?? account.config.dm?.allowFrom ?? [];
const guildUsers = Object.values(account.config.guilds ?? {}).flatMap((guild) => [
...(guild.users ?? []),
...Object.values(guild.channels ?? {}).flatMap((channel) => channel.users ?? []),
]);
const ids = collectNormalizedDirectoryIds({
sources: [allowFrom, Object.keys(account.config.dms ?? {}), guildUsers],
normalizeId: (raw) => {
const mention = raw.match(/^<@!?(\d+)>$/);
const cleaned = (mention?.[1] ?? raw).replace(/^(discord|user):/i, "").trim();
return /^\d+$/.test(cleaned) ? `user:${cleaned}` : null;
},
});
return toDirectoryEntries("user", applyDirectoryQueryAndLimit(ids, params));
}
export async function listDiscordDirectoryGroupsFromConfig(params: DirectoryConfigParams) {
const account = (await inspectReadOnlyChannelAccount({
channelId: "discord",
cfg: params.cfg,
accountId: params.accountId,
})) as InspectedDiscordAccount | null;
if (!account || !("config" in account)) {
return [];
}
const ids = collectNormalizedDirectoryIds({
sources: Object.values(account.config.guilds ?? {}).map((guild) =>
Object.keys(guild.channels ?? {}),
),
normalizeId: (raw) => {
const mention = raw.match(/^<#(\d+)>$/);
const cleaned = (mention?.[1] ?? raw).replace(/^(discord|channel|group):/i, "").trim();
return /^\d+$/.test(cleaned) ? `channel:${cleaned}` : null;
},
});
return toDirectoryEntries("group", applyDirectoryQueryAndLimit(ids, params));
}

View File

@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DirectoryConfigParams } from "../../../src/channels/plugins/directory-config.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { DirectoryConfigParams } from "../../../src/plugin-sdk/directory-runtime.js";
import { listDiscordDirectoryGroupsLive, listDiscordDirectoryPeersLive } from "./directory-live.js";
function makeParams(overrides: Partial<DirectoryConfigParams> = {}): DirectoryConfigParams {

View File

@ -2,7 +2,7 @@ import { ChannelType } from "discord-api-types/v10";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { NativeCommandSpec } from "../../../../src/auto-reply/commands-registry.js";
import * as dispatcherModule from "../../../../src/auto-reply/reply/provider-dispatcher.js";
import type { ChatType } from "../../../../src/channels/chat-type.js";
import { setDefaultChannelPluginRegistryForTests } from "../../../../src/commands/channel-test-helpers.js";
import type { OpenClawConfig } from "../../../../src/config/config.js";
import * as pluginCommandsModule from "../../../../src/plugins/commands.js";
import { clearPluginCommands, registerPluginCommand } from "../../../../src/plugins/commands.js";
@ -12,32 +12,26 @@ import {
} from "./native-command.test-helpers.js";
import { createNoopThreadBindingManager } from "./thread-bindings.js";
type ResolveConfiguredBindingRouteFn =
typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConfiguredBindingRoute;
type EnsureConfiguredBindingRouteReadyFn =
typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredBindingRouteReady;
const persistentBindingMocks = vi.hoisted(() => ({
resolveConfiguredAcpBindingRecord: vi.fn<ResolveConfiguredBindingRouteFn>((params) => ({
bindingResolution: null,
route: params.route,
})),
ensureConfiguredAcpBindingSession: vi.fn<EnsureConfiguredBindingRouteReadyFn>(async () => ({
const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() =>
vi.fn<EnsureConfiguredBindingRouteReadyFn>(async () => ({
ok: true,
})),
}));
);
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
return {
...actual,
resolveConfiguredBindingRoute: persistentBindingMocks.resolveConfiguredAcpBindingRecord,
ensureConfiguredBindingRouteReady: persistentBindingMocks.ensureConfiguredAcpBindingSession,
ensureConfiguredBindingRouteReady: (...args: unknown[]) =>
ensureConfiguredBindingRouteReadyMock(
...(args as Parameters<EnsureConfiguredBindingRouteReadyFn>),
),
};
});
import { createDiscordNativeCommand } from "./native-command.js";
function createInteraction(params?: {
channelType?: ChannelType;
channelId?: string;
@ -66,7 +60,12 @@ function createConfig(): OpenClawConfig {
} as OpenClawConfig;
}
function createNativeCommand(cfg: OpenClawConfig, commandSpec: NativeCommandSpec) {
async function loadCreateDiscordNativeCommand() {
return (await import("./native-command.js")).createDiscordNativeCommand;
}
async function createNativeCommand(cfg: OpenClawConfig, commandSpec: NativeCommandSpec) {
const createDiscordNativeCommand = await loadCreateDiscordNativeCommand();
return createDiscordNativeCommand({
command: commandSpec,
cfg,
@ -78,7 +77,8 @@ function createNativeCommand(cfg: OpenClawConfig, commandSpec: NativeCommandSpec
});
}
function createPluginCommand(params: { cfg: OpenClawConfig; name: string }) {
async function createPluginCommand(params: { cfg: OpenClawConfig; name: string }) {
const createDiscordNativeCommand = await loadCreateDiscordNativeCommand();
return createDiscordNativeCommand({
command: {
name: params.name,
@ -119,7 +119,7 @@ async function expectPairCommandReply(params: {
commandName: string;
interaction: MockCommandInteraction;
}) {
const command = createPluginCommand({
const command = await createPluginCommand({
cfg: params.cfg,
name: params.commandName,
});
@ -143,150 +143,14 @@ async function expectPairCommandReply(params: {
);
}
function createStatusCommand(cfg: OpenClawConfig) {
return createNativeCommand(cfg, {
async function createStatusCommand(cfg: OpenClawConfig) {
return await createNativeCommand(cfg, {
name: "status",
description: "Status",
acceptsArgs: false,
});
}
function resolveConversationFromParams(params: Parameters<ResolveConfiguredBindingRouteFn>[0]) {
if ("conversation" in params) {
return params.conversation;
}
return {
channel: params.channel,
accountId: params.accountId,
conversationId: params.conversationId,
...(params.parentConversationId ? { parentConversationId: params.parentConversationId } : {}),
};
}
function createConfiguredBindingResolution(params: {
conversation: ReturnType<typeof resolveConversationFromParams>;
boundSessionKey: string;
}) {
const peerKind: ChatType = params.conversation.conversationId.startsWith("dm-")
? "direct"
: "channel";
const configuredBinding = {
spec: {
channel: "discord" as const,
accountId: params.conversation.accountId,
conversationId: params.conversation.conversationId,
...(params.conversation.parentConversationId
? { parentConversationId: params.conversation.parentConversationId }
: {}),
agentId: "codex",
mode: "persistent" as const,
},
record: {
bindingId: `config:acp:discord:${params.conversation.accountId}:${params.conversation.conversationId}`,
targetSessionKey: params.boundSessionKey,
targetKind: "session" as const,
conversation: params.conversation,
status: "active" as const,
boundAt: 0,
},
};
return {
conversation: params.conversation,
compiledBinding: {
channel: "discord" as const,
binding: {
type: "acp" as const,
agentId: "codex",
match: {
channel: "discord",
accountId: params.conversation.accountId,
peer: {
kind: peerKind,
id: params.conversation.conversationId,
},
},
acp: {
mode: "persistent" as const,
},
},
bindingConversationId: params.conversation.conversationId,
target: {
conversationId: params.conversation.conversationId,
...(params.conversation.parentConversationId
? { parentConversationId: params.conversation.parentConversationId }
: {}),
},
agentId: "codex",
provider: {
compileConfiguredBinding: () => ({
conversationId: params.conversation.conversationId,
...(params.conversation.parentConversationId
? { parentConversationId: params.conversation.parentConversationId }
: {}),
}),
matchInboundConversation: () => ({
conversationId: params.conversation.conversationId,
...(params.conversation.parentConversationId
? { parentConversationId: params.conversation.parentConversationId }
: {}),
}),
},
targetFactory: {
driverId: "acp" as const,
materialize: () => ({
record: configuredBinding.record,
statefulTarget: {
kind: "stateful" as const,
driverId: "acp",
sessionKey: params.boundSessionKey,
agentId: "codex",
},
}),
},
},
match: {
conversationId: params.conversation.conversationId,
...(params.conversation.parentConversationId
? { parentConversationId: params.conversation.parentConversationId }
: {}),
},
record: configuredBinding.record,
statefulTarget: {
kind: "stateful" as const,
driverId: "acp",
sessionKey: params.boundSessionKey,
agentId: "codex",
},
};
}
function setConfiguredBinding(channelId: string, boundSessionKey: string) {
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockImplementation((params) => {
const conversation = resolveConversationFromParams(params);
const bindingResolution = createConfiguredBindingResolution({
conversation: {
...conversation,
conversationId: channelId,
},
boundSessionKey,
});
return {
bindingResolution,
boundSessionKey,
boundAgentId: "codex",
route: {
...params.route,
agentId: "codex",
sessionKey: boundSessionKey,
matchedBy: "binding.channel",
},
};
});
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
ok: true,
});
}
function createDispatchSpy() {
return vi.spyOn(dispatcherModule, "dispatchReplyWithDispatcher").mockResolvedValue({
counts: {
@ -299,26 +163,23 @@ function createDispatchSpy() {
function expectBoundSessionDispatch(
dispatchSpy: ReturnType<typeof createDispatchSpy>,
boundSessionKey: string,
expectedPattern: RegExp,
) {
expect(dispatchSpy).toHaveBeenCalledTimes(1);
const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as {
ctx?: { SessionKey?: string; CommandTargetSessionKey?: string };
};
expect(dispatchCall.ctx?.SessionKey).toBe(boundSessionKey);
expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(boundSessionKey);
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1);
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).toHaveBeenCalledTimes(1);
expect(dispatchCall.ctx?.SessionKey).toMatch(expectedPattern);
expect(dispatchCall.ctx?.CommandTargetSessionKey).toMatch(expectedPattern);
expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1);
}
async function expectBoundStatusCommandDispatch(params: {
cfg: OpenClawConfig;
interaction: MockCommandInteraction;
channelId: string;
boundSessionKey: string;
expectedPattern: RegExp;
}) {
const command = createStatusCommand(params.cfg);
setConfiguredBinding(params.channelId, params.boundSessionKey);
const command = await createStatusCommand(params.cfg);
vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null);
const dispatchSpy = createDispatchSpy();
@ -327,20 +188,16 @@ async function expectBoundStatusCommandDispatch(params: {
params.interaction as unknown,
);
expectBoundSessionDispatch(dispatchSpy, params.boundSessionKey);
expectBoundSessionDispatch(dispatchSpy, params.expectedPattern);
}
describe("Discord native plugin command dispatch", () => {
beforeEach(() => {
vi.clearAllMocks();
clearPluginCommands();
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReset();
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockImplementation((params) => ({
bindingResolution: null,
route: params.route,
}));
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockReset();
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
setDefaultChannelPluginRegistryForTests();
ensureConfiguredBindingRouteReadyMock.mockReset();
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({
ok: true,
});
});
@ -397,15 +254,7 @@ describe("Discord native plugin command dispatch", () => {
description: "Pair",
acceptsArgs: true,
};
const command = createDiscordNativeCommand({
command: commandSpec,
cfg,
discordConfig: cfg.channels?.discord ?? {},
accountId: "default",
sessionPrefix: "discord:slash",
ephemeralDefault: true,
threadBindings: createNoopThreadBindingManager("default"),
});
const command = await createNativeCommand(cfg, commandSpec);
const interaction = createInteraction({
channelType: ChannelType.GuildText,
channelId: "234567890123456789",
@ -449,15 +298,7 @@ describe("Discord native plugin command dispatch", () => {
description: "List cron jobs",
acceptsArgs: false,
};
const command = createDiscordNativeCommand({
command: commandSpec,
cfg,
discordConfig: cfg.channels?.discord ?? {},
accountId: "default",
sessionPrefix: "discord:slash",
ephemeralDefault: true,
threadBindings: createNoopThreadBindingManager("default"),
});
const command = await createNativeCommand(cfg, commandSpec);
const interaction = createInteraction();
const pluginMatch = {
command: {
@ -492,11 +333,21 @@ describe("Discord native plugin command dispatch", () => {
it("routes native slash commands through configured ACP Discord channel bindings", async () => {
const guildId = "1459246755253325866";
const channelId = "1478836151241412759";
const boundSessionKey = "agent:codex:acp:binding:discord:default:feedface";
const cfg = {
commands: {
useAccessGroups: false,
},
channels: {
discord: {
guilds: {
[guildId]: {
channels: {
[channelId]: { allow: true, requireMention: false },
},
},
},
},
},
bindings: [
{
type: "acp",
@ -522,8 +373,7 @@ describe("Discord native plugin command dispatch", () => {
await expectBoundStatusCommandDispatch({
cfg,
interaction,
channelId,
boundSessionKey,
expectedPattern: /^agent:codex:acp:binding:discord:default:/,
});
});
@ -557,7 +407,7 @@ describe("Discord native plugin command dispatch", () => {
},
},
} as OpenClawConfig;
const command = createStatusCommand(cfg);
const command = await createStatusCommand(cfg);
const interaction = createInteraction({
channelType: ChannelType.GuildText,
channelId,
@ -578,13 +428,11 @@ describe("Discord native plugin command dispatch", () => {
expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(
"agent:qwen:discord:channel:1478836151241412759",
);
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1);
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).not.toHaveBeenCalled();
expect(ensureConfiguredBindingRouteReadyMock).not.toHaveBeenCalled();
});
it("routes Discord DM native slash commands through configured ACP bindings", async () => {
const channelId = "dm-1";
const boundSessionKey = "agent:codex:acp:binding:discord:default:dmfeedface";
const cfg = {
commands: {
useAccessGroups: false,
@ -617,15 +465,13 @@ describe("Discord native plugin command dispatch", () => {
await expectBoundStatusCommandDispatch({
cfg,
interaction,
channelId,
boundSessionKey,
expectedPattern: /^agent:codex:acp:binding:discord:default:/,
});
});
it("allows recovery commands through configured ACP bindings even when ensure fails", async () => {
const guildId = "1459246755253325866";
const channelId = "1479098716916023408";
const boundSessionKey = "agent:codex:acp:binding:discord:default:feedface";
const cfg = {
commands: {
useAccessGroups: false,
@ -651,14 +497,13 @@ describe("Discord native plugin command dispatch", () => {
guildId,
guildName: "Ops",
});
const command = createNativeCommand(cfg, {
const command = await createNativeCommand(cfg, {
name: "new",
description: "Start a new session.",
acceptsArgs: true,
});
setConfiguredBinding(channelId, boundSessionKey);
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({
ok: false,
error: "acpx exited with code 1",
});
@ -671,10 +516,11 @@ describe("Discord native plugin command dispatch", () => {
const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as {
ctx?: { SessionKey?: string; CommandTargetSessionKey?: string };
};
expect(dispatchCall.ctx?.SessionKey).toBe(boundSessionKey);
expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(boundSessionKey);
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1);
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).not.toHaveBeenCalled();
expect(dispatchCall.ctx?.SessionKey).toMatch(/^agent:codex:acp:binding:discord:default:/);
expect(dispatchCall.ctx?.CommandTargetSessionKey).toMatch(
/^agent:codex:acp:binding:discord:default:/,
);
expect(ensureConfiguredBindingRouteReadyMock).not.toHaveBeenCalled();
expect(interaction.reply).not.toHaveBeenCalledWith(
expect.objectContaining({
content: "Configured ACP binding is unavailable right now. Please try again.",

View File

@ -7,7 +7,6 @@ import {
baseRuntime,
getFirstDiscordMessageHandlerParams,
getProviderMonitorTestMocks,
mockResolvedDiscordAccountConfig,
resetDiscordProviderMonitorMocks,
} from "../../../../test/helpers/extensions/discord-provider.test-support.js";
@ -37,6 +36,21 @@ const {
voiceRuntimeModuleLoadedMock,
} = getProviderMonitorTestMocks();
function createConfigWithDiscordAccount(overrides: Record<string, unknown> = {}): OpenClawConfig {
return {
channels: {
discord: {
accounts: {
default: {
token: "MTIz.abc.def",
...overrides,
},
},
},
},
} as OpenClawConfig;
}
vi.mock("openclaw/plugin-sdk/plugin-runtime", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/plugin-runtime")>(
"openclaw/plugin-sdk/plugin-runtime",
@ -90,7 +104,18 @@ describe("monitorDiscordProvider", () => {
};
beforeEach(() => {
vi.resetModules();
resetDiscordProviderMonitorMocks();
vi.doMock("../accounts.js", () => ({
resolveDiscordAccount: (...args: Parameters<typeof resolveDiscordAccountMock>) =>
resolveDiscordAccountMock(...args),
}));
vi.doMock("../probe.js", () => ({
fetchDiscordApplicationId: async () => "app-1",
}));
vi.doMock("../token.js", () => ({
normalizeDiscordToken: (value?: string) => value,
}));
});
it("stops thread bindings when startup fails before lifecycle begins", async () => {
@ -139,7 +164,7 @@ describe("monitorDiscordProvider", () => {
it("loads the Discord voice runtime only when voice is enabled", async () => {
resolveDiscordAccountMock.mockReturnValue({
accountId: "default",
token: "cfg-token",
token: "MTIz.abc.def",
config: {
commands: { native: true, nativeSkills: false },
voice: { enabled: true },
@ -356,11 +381,18 @@ describe("monitorDiscordProvider", () => {
});
it("forwards custom eventQueue config from discord config to Carbon Client", async () => {
const { monitorDiscordProvider } = await import("./provider.js");
mockResolvedDiscordAccountConfig({
resolveDiscordAccountMock.mockReturnValue({
accountId: "default",
token: "MTIz.abc.def",
config: {
commands: { native: true, nativeSkills: false },
voice: { enabled: false },
agentComponents: { enabled: false },
execApprovals: { enabled: false },
eventQueue: { listenerTimeout: 300_000 },
},
});
const { monitorDiscordProvider } = await import("./provider.js");
await monitorDiscordProvider({
config: baseConfig(),
@ -374,12 +406,10 @@ describe("monitorDiscordProvider", () => {
it("does not reuse eventQueue.listenerTimeout as the queued inbound worker timeout", async () => {
const { monitorDiscordProvider } = await import("./provider.js");
mockResolvedDiscordAccountConfig({
eventQueue: { listenerTimeout: 50_000 },
});
await monitorDiscordProvider({
config: baseConfig(),
config: createConfigWithDiscordAccount({
eventQueue: { listenerTimeout: 50_000 },
}),
runtime: baseRuntime(),
});
@ -392,11 +422,18 @@ describe("monitorDiscordProvider", () => {
});
it("forwards inbound worker timeout config to the Discord message handler", async () => {
const { monitorDiscordProvider } = await import("./provider.js");
mockResolvedDiscordAccountConfig({
resolveDiscordAccountMock.mockReturnValue({
accountId: "default",
token: "MTIz.abc.def",
config: {
commands: { native: true, nativeSkills: false },
voice: { enabled: false },
agentComponents: { enabled: false },
execApprovals: { enabled: false },
inboundWorker: { runTimeoutMs: 300_000 },
},
});
const { monitorDiscordProvider } = await import("./provider.js");
await monitorDiscordProvider({
config: baseConfig(),

View File

@ -1,8 +1,6 @@
export {
buildComputedAccountStatusSnapshot,
buildTokenChannelStatusSummary,
listDiscordDirectoryGroupsFromConfig,
listDiscordDirectoryPeersFromConfig,
PAIRING_APPROVED_MESSAGE,
projectCredentialSnapshotFields,
resolveConfiguredFromCredentialStatuses,
@ -21,8 +19,15 @@ export {
export { DiscordConfigSchema } from "openclaw/plugin-sdk/discord-core";
export { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
export {
listDiscordDirectoryGroupsFromConfig,
listDiscordDirectoryPeersFromConfig,
} from "./directory-config.js";
export {
createHybridChannelConfigAdapter,
createScopedChannelConfigAdapter,
createScopedAccountConfigAccessors,
createScopedChannelConfigBase,
createTopLevelChannelConfigAdapter,
} from "openclaw/plugin-sdk/channel-config-helpers";
export {
createAccountActionGate,
@ -35,13 +40,16 @@ export type {
ChannelMessageActionAdapter,
ChannelMessageActionName,
} from "openclaw/plugin-sdk/channel-runtime";
export { withNormalizedTimestamp } from "../../../src/agents/date-time.js";
export { assertMediaNotDataUrl } from "../../../src/agents/sandbox-paths.js";
export { parseAvailableTags, readReactionParams } from "openclaw/plugin-sdk/discord-core";
export { resolvePollMaxSelections } from "../../../src/polls.js";
export type { DiscordAccountConfig, DiscordActionConfig } from "../../../src/config/types.js";
export {
assertMediaNotDataUrl,
parseAvailableTags,
readReactionParams,
resolvePollMaxSelections,
withNormalizedTimestamp,
} from "openclaw/plugin-sdk/discord-core";
export type { DiscordAccountConfig, DiscordActionConfig } from "openclaw/plugin-sdk/discord";
export {
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
normalizeSecretInputString,
} from "../../../src/config/types.secrets.js";
} from "openclaw/plugin-sdk/config-runtime";

View File

@ -8,8 +8,7 @@ import {
type ResolvedDiscordAccount,
} from "./accounts.js";
import {
createScopedAccountConfigAccessors,
createScopedChannelConfigBase,
createScopedChannelConfigAdapter,
buildChannelConfigSchema,
DiscordConfigSchema,
getChatChannelMeta,
@ -27,20 +26,16 @@ export const discordSetupWizard = createDiscordSetupWizardProxy(
async () => (await loadDiscordChannelRuntime()).discordSetupWizard,
);
export const discordConfigAccessors = createScopedAccountConfigAccessors({
resolveAccount: ({ cfg, accountId }) => resolveDiscordAccount({ cfg, accountId }),
resolveAllowFrom: (account: ResolvedDiscordAccount) => account.config.dm?.allowFrom,
formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }),
resolveDefaultTo: (account: ResolvedDiscordAccount) => account.config.defaultTo,
});
export const discordConfigBase = createScopedChannelConfigBase<ResolvedDiscordAccount>({
export const discordConfigAdapter = createScopedChannelConfigAdapter<ResolvedDiscordAccount>({
sectionKey: DISCORD_CHANNEL,
listAccountIds: listDiscordAccountIds,
resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }),
inspectAccount: (cfg, accountId) => inspectDiscordAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultDiscordAccountId,
clearBaseFields: ["token", "name"],
resolveAllowFrom: (account: ResolvedDiscordAccount) => account.config.dm?.allowFrom,
formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }),
resolveDefaultTo: (account: ResolvedDiscordAccount) => account.config.defaultTo,
});
export function createDiscordPluginBase(params: {
@ -75,7 +70,7 @@ export function createDiscordPluginBase(params: {
reload: { configPrefixes: ["channels.discord"] },
configSchema: buildChannelConfigSchema(DiscordConfigSchema),
config: {
...discordConfigBase,
...discordConfigAdapter,
isConfigured: (account) => Boolean(account.token?.trim()),
describeAccount: (account) => ({
accountId: account.accountId,
@ -84,7 +79,6 @@ export function createDiscordPluginBase(params: {
configured: Boolean(account.token?.trim()),
tokenSource: account.tokenSource,
}),
...discordConfigAccessors,
},
setup: params.setup,
}) as Pick<

44
extensions/fal/index.ts Normal file
View File

@ -0,0 +1,44 @@
import { definePluginEntry } from "openclaw/plugin-sdk/core";
import { buildFalImageGenerationProvider } from "openclaw/plugin-sdk/image-generation";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth";
import { applyFalConfig, FAL_DEFAULT_IMAGE_MODEL_REF } from "./onboard.js";
const PROVIDER_ID = "fal";
export default definePluginEntry({
id: PROVIDER_ID,
name: "fal Provider",
description: "Bundled fal image generation provider",
register(api) {
api.registerProvider({
id: PROVIDER_ID,
label: "fal",
docsPath: "/providers/models",
envVars: ["FAL_KEY"],
auth: [
createProviderApiKeyAuthMethod({
providerId: PROVIDER_ID,
methodId: "api-key",
label: "fal API key",
hint: "Image generation API key",
optionKey: "falApiKey",
flagName: "--fal-api-key",
envVar: "FAL_KEY",
promptMessage: "Enter fal API key",
defaultModel: FAL_DEFAULT_IMAGE_MODEL_REF,
expectedProviders: ["fal"],
applyConfig: (cfg) => applyFalConfig(cfg),
wizard: {
choiceId: "fal-api-key",
choiceLabel: "fal API key",
choiceHint: "Image generation API key",
groupId: "fal",
groupLabel: "fal",
groupHint: "Image generation",
},
}),
],
});
api.registerImageGenerationProvider(buildFalImageGenerationProvider());
},
});

21
extensions/fal/onboard.ts Normal file
View File

@ -0,0 +1,21 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard";
export const FAL_DEFAULT_IMAGE_MODEL_REF = "fal/fal-ai/flux/dev";
export function applyFalConfig(cfg: OpenClawConfig): OpenClawConfig {
if (cfg.agents?.defaults?.imageGenerationModel) {
return cfg;
}
return {
...cfg,
agents: {
...cfg.agents,
defaults: {
...cfg.agents?.defaults,
imageGenerationModel: {
primary: FAL_DEFAULT_IMAGE_MODEL_REF,
},
},
},
};
}

View File

@ -0,0 +1,27 @@
{
"id": "fal",
"providers": ["fal"],
"providerAuthEnvVars": {
"fal": ["FAL_KEY"]
},
"providerAuthChoices": [
{
"provider": "fal",
"method": "api-key",
"choiceId": "fal-api-key",
"choiceLabel": "fal API key",
"groupId": "fal",
"groupLabel": "fal",
"groupHint": "Image generation",
"optionKey": "falApiKey",
"cliFlag": "--fal-api-key",
"cliOption": "--fal-api-key <key>",
"cliDescription": "fal API key"
}
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@ -0,0 +1,12 @@
{
"name": "@openclaw/fal-provider",
"version": "2026.3.14",
"private": true,
"description": "OpenClaw fal provider plugin",
"type": "module",
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@ -0,0 +1,471 @@
import type { ClawdbotConfig } from "../runtime-api.js";
import { normalizeFeishuExternalKey } from "./external-keys.js";
import { downloadMessageResourceFeishu } from "./media.js";
import { parsePostContent } from "./post.js";
import { getFeishuRuntime } from "./runtime.js";
import type { FeishuMediaInfo } from "./types.js";
export type FeishuMention = {
key: string;
id: {
open_id?: string;
user_id?: string;
union_id?: string;
};
name: string;
tenant_key?: string;
};
type FeishuMessageLike = {
message: {
content: string;
message_type: string;
mentions?: FeishuMention[];
chat_id: string;
root_id?: string;
parent_id?: string;
thread_id?: string;
message_id: string;
};
sender: {
sender_id: {
open_id?: string;
user_id?: string;
};
};
};
export type GroupSessionScope = "group" | "group_sender" | "group_topic" | "group_topic_sender";
export type ResolvedFeishuGroupSession = {
peerId: string;
parentPeer: { kind: "group"; id: string } | null;
groupSessionScope: GroupSessionScope;
replyInThread: boolean;
threadReply: boolean;
};
function buildFeishuConversationId(params: {
chatId: string;
scope: GroupSessionScope | "group_sender";
topicId?: string;
senderOpenId?: string;
}): string {
switch (params.scope) {
case "group_sender":
return `${params.chatId}:sender:${params.senderOpenId}`;
case "group_topic":
return `${params.chatId}:topic:${params.topicId}`;
case "group_topic_sender":
return `${params.chatId}:topic:${params.topicId}:sender:${params.senderOpenId}`;
default:
return params.chatId;
}
}
export function resolveFeishuGroupSession(params: {
chatId: string;
senderOpenId: string;
messageId: string;
rootId?: string;
threadId?: string;
groupConfig?: {
groupSessionScope?: GroupSessionScope;
topicSessionMode?: "enabled" | "disabled";
replyInThread?: "enabled" | "disabled";
};
feishuCfg?: {
groupSessionScope?: GroupSessionScope;
topicSessionMode?: "enabled" | "disabled";
replyInThread?: "enabled" | "disabled";
};
}): ResolvedFeishuGroupSession {
const { chatId, senderOpenId, messageId, rootId, threadId, groupConfig, feishuCfg } = params;
const normalizedThreadId = threadId?.trim();
const normalizedRootId = rootId?.trim();
const threadReply = Boolean(normalizedThreadId || normalizedRootId);
const replyInThread =
(groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled" ||
threadReply;
const legacyTopicSessionMode =
groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
const groupSessionScope: GroupSessionScope =
groupConfig?.groupSessionScope ??
feishuCfg?.groupSessionScope ??
(legacyTopicSessionMode === "enabled" ? "group_topic" : "group");
const topicScope =
groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender"
? (normalizedRootId ?? normalizedThreadId ?? (replyInThread ? messageId : null))
: null;
let peerId = chatId;
switch (groupSessionScope) {
case "group_sender":
peerId = buildFeishuConversationId({ chatId, scope: "group_sender", senderOpenId });
break;
case "group_topic":
peerId = topicScope
? buildFeishuConversationId({ chatId, scope: "group_topic", topicId: topicScope })
: chatId;
break;
case "group_topic_sender":
peerId = topicScope
? buildFeishuConversationId({
chatId,
scope: "group_topic_sender",
topicId: topicScope,
senderOpenId,
})
: buildFeishuConversationId({ chatId, scope: "group_sender", senderOpenId });
break;
case "group":
default:
peerId = chatId;
break;
}
return {
peerId,
parentPeer:
topicScope &&
(groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender")
? { kind: "group", id: chatId }
: null,
groupSessionScope,
replyInThread,
threadReply,
};
}
export function parseMessageContent(content: string, messageType: string): string {
if (messageType === "post") {
return parsePostContent(content).textContent;
}
try {
const parsed = JSON.parse(content);
if (messageType === "text") {
return parsed.text || "";
}
if (messageType === "share_chat") {
if (parsed && typeof parsed === "object") {
const share = parsed as { body?: unknown; summary?: unknown; share_chat_id?: unknown };
if (typeof share.body === "string" && share.body.trim()) {
return share.body.trim();
}
if (typeof share.summary === "string" && share.summary.trim()) {
return share.summary.trim();
}
if (typeof share.share_chat_id === "string" && share.share_chat_id.trim()) {
return `[Forwarded message: ${share.share_chat_id.trim()}]`;
}
}
return "[Forwarded message]";
}
if (messageType === "merge_forward") {
return "[Merged and Forwarded Message - loading...]";
}
return content;
} catch {
return content;
}
}
function formatSubMessageContent(content: string, contentType: string): string {
try {
const parsed = JSON.parse(content);
switch (contentType) {
case "text":
return parsed.text || content;
case "post":
return parsePostContent(content).textContent;
case "image":
return "[Image]";
case "file":
return `[File: ${parsed.file_name || "unknown"}]`;
case "audio":
return "[Audio]";
case "video":
return "[Video]";
case "sticker":
return "[Sticker]";
case "merge_forward":
return "[Nested Merged Forward]";
default:
return `[${contentType}]`;
}
} catch {
return content;
}
}
export function parseMergeForwardContent(params: {
content: string;
log?: (...args: any[]) => void;
}): string {
const { content, log } = params;
const maxMessages = 50;
log?.("feishu: parsing merge_forward sub-messages from API response");
let items: Array<{
message_id?: string;
msg_type?: string;
body?: { content?: string };
sender?: { id?: string };
upper_message_id?: string;
create_time?: string;
}>;
try {
items = JSON.parse(content);
} catch {
log?.("feishu: merge_forward items parse failed");
return "[Merged and Forwarded Message - parse error]";
}
if (!Array.isArray(items) || items.length === 0) {
return "[Merged and Forwarded Message - no sub-messages]";
}
const subMessages = items.filter((item) => item.upper_message_id);
if (subMessages.length === 0) {
return "[Merged and Forwarded Message - no sub-messages found]";
}
log?.(`feishu: merge_forward contains ${subMessages.length} sub-messages`);
subMessages.sort(
(a, b) => parseInt(a.create_time || "0", 10) - parseInt(b.create_time || "0", 10),
);
const lines = ["[Merged and Forwarded Messages]"];
for (const item of subMessages.slice(0, maxMessages)) {
lines.push(`- ${formatSubMessageContent(item.body?.content || "", item.msg_type || "text")}`);
}
if (subMessages.length > maxMessages) {
lines.push(`... and ${subMessages.length - maxMessages} more messages`);
}
return lines.join("\n");
}
export function checkBotMentioned(event: FeishuMessageLike, botOpenId?: string): boolean {
if (!botOpenId) {
return false;
}
if ((event.message.content ?? "").includes("@_all")) {
return true;
}
const mentions = event.message.mentions ?? [];
if (mentions.length > 0) {
return mentions.some((mention) => mention.id.open_id === botOpenId);
}
if (event.message.message_type === "post") {
return parsePostContent(event.message.content).mentionedOpenIds.some((id) => id === botOpenId);
}
return false;
}
export function normalizeMentions(
text: string,
mentions?: FeishuMention[],
botStripId?: string,
): string {
if (!mentions || mentions.length === 0) {
return text;
}
const escaped = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const escapeName = (value: string) => value.replace(/</g, "&lt;").replace(/>/g, "&gt;");
let result = text;
for (const mention of mentions) {
const mentionId = mention.id.open_id;
const replacement =
botStripId && mentionId === botStripId
? ""
: mentionId
? `<at user_id="${mentionId}">${escapeName(mention.name)}</at>`
: `@${mention.name}`;
result = result.replace(new RegExp(escaped(mention.key), "g"), () => replacement).trim();
}
return result;
}
export function normalizeFeishuCommandProbeBody(text: string): string {
if (!text) {
return "";
}
return text
.replace(/<at\b[^>]*>[^<]*<\/at>/giu, " ")
.replace(/(^|\s)@[^/\s]+(?=\s|$|\/)/gu, "$1")
.replace(/\s+/g, " ")
.trim();
}
export function parseMediaKeys(
content: string,
messageType: string,
): { imageKey?: string; fileKey?: string; fileName?: string } {
try {
const parsed = JSON.parse(content);
const imageKey = normalizeFeishuExternalKey(parsed.image_key);
const fileKey = normalizeFeishuExternalKey(parsed.file_key);
switch (messageType) {
case "image":
return { imageKey, fileName: parsed.file_name };
case "file":
case "audio":
case "sticker":
return { fileKey, fileName: parsed.file_name };
case "video":
case "media":
return { fileKey, imageKey, fileName: parsed.file_name };
default:
return {};
}
} catch {
return {};
}
}
export function toMessageResourceType(messageType: string): "image" | "file" {
return messageType === "image" ? "image" : "file";
}
function inferPlaceholder(messageType: string): string {
switch (messageType) {
case "image":
return "<media:image>";
case "file":
return "<media:document>";
case "audio":
return "<media:audio>";
case "video":
case "media":
return "<media:video>";
case "sticker":
return "<media:sticker>";
default:
return "<media:document>";
}
}
export async function resolveFeishuMediaList(params: {
cfg: ClawdbotConfig;
messageId: string;
messageType: string;
content: string;
maxBytes: number;
log?: (msg: string) => void;
accountId?: string;
}): Promise<FeishuMediaInfo[]> {
const { cfg, messageId, messageType, content, maxBytes, log, accountId } = params;
const mediaTypes = ["image", "file", "audio", "video", "media", "sticker", "post"];
if (!mediaTypes.includes(messageType)) {
return [];
}
const out: FeishuMediaInfo[] = [];
const core = getFeishuRuntime();
if (messageType === "post") {
const { imageKeys, mediaKeys } = parsePostContent(content);
if (imageKeys.length === 0 && mediaKeys.length === 0) {
return [];
}
if (imageKeys.length > 0) {
log?.(`feishu: post message contains ${imageKeys.length} embedded image(s)`);
}
if (mediaKeys.length > 0) {
log?.(`feishu: post message contains ${mediaKeys.length} embedded media file(s)`);
}
for (const imageKey of imageKeys) {
try {
const result = await downloadMessageResourceFeishu({
cfg,
messageId,
fileKey: imageKey,
type: "image",
accountId,
});
const contentType =
result.contentType ?? (await core.media.detectMime({ buffer: result.buffer }));
const saved = await core.channel.media.saveMediaBuffer(
result.buffer,
contentType,
"inbound",
maxBytes,
);
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: "<media:image>",
});
log?.(`feishu: downloaded embedded image ${imageKey}, saved to ${saved.path}`);
} catch (err) {
log?.(`feishu: failed to download embedded image ${imageKey}: ${String(err)}`);
}
}
for (const media of mediaKeys) {
try {
const result = await downloadMessageResourceFeishu({
cfg,
messageId,
fileKey: media.fileKey,
type: "file",
accountId,
});
const contentType =
result.contentType ?? (await core.media.detectMime({ buffer: result.buffer }));
const saved = await core.channel.media.saveMediaBuffer(
result.buffer,
contentType,
"inbound",
maxBytes,
);
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: "<media:video>",
});
log?.(`feishu: downloaded embedded media ${media.fileKey}, saved to ${saved.path}`);
} catch (err) {
log?.(`feishu: failed to download embedded media ${media.fileKey}: ${String(err)}`);
}
}
return out;
}
const mediaKeys = parseMediaKeys(content, messageType);
if (!mediaKeys.imageKey && !mediaKeys.fileKey) {
return [];
}
try {
const fileKey = mediaKeys.fileKey || mediaKeys.imageKey;
if (!fileKey) {
return [];
}
const result = await downloadMessageResourceFeishu({
cfg,
messageId,
fileKey,
type: toMessageResourceType(messageType),
accountId,
});
const contentType =
result.contentType ?? (await core.media.detectMime({ buffer: result.buffer }));
const saved = await core.channel.media.saveMediaBuffer(
result.buffer,
contentType,
"inbound",
maxBytes,
result.fileName || mediaKeys.fileName,
);
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: inferPlaceholder(messageType),
});
log?.(`feishu: downloaded ${messageType} media, saved to ${saved.path}`);
} catch (err) {
log?.(`feishu: failed to download ${messageType} media: ${String(err)}`);
}
return out;
}

View File

@ -22,13 +22,20 @@ import {
warnMissingProviderGroupPolicyFallbackOnce,
} from "../runtime-api.js";
import { resolveFeishuAccount } from "./accounts.js";
import {
checkBotMentioned,
normalizeFeishuCommandProbeBody,
normalizeMentions,
parseMergeForwardContent,
parseMessageContent,
resolveFeishuGroupSession,
resolveFeishuMediaList,
toMessageResourceType,
} from "./bot-content.js";
import { type FeishuPermissionError, resolveFeishuSenderName } from "./bot-sender-name.js";
import { createFeishuClient } from "./client.js";
import { buildFeishuConversationId } from "./conversation-id.js";
import { finalizeFeishuMessageProcessing, tryRecordMessagePersistent } from "./dedup.js";
import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
import { normalizeFeishuExternalKey } from "./external-keys.js";
import { downloadMessageResourceFeishu } from "./media.js";
import { extractMentionTargets, isMentionForwardRequest } from "./mention.js";
import {
resolveFeishuGroupConfig,
@ -36,13 +43,14 @@ import {
resolveFeishuAllowlistMatch,
isFeishuGroupAllowed,
} from "./policy.js";
import { parsePostContent } from "./post.js";
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
import { getFeishuRuntime } from "./runtime.js";
import { getMessageFeishu, listFeishuThreadMessages, sendMessageFeishu } from "./send.js";
import type { FeishuMessageContext, FeishuMediaInfo } from "./types.js";
import type { FeishuMessageContext } from "./types.js";
import type { DynamicAgentCreationConfig } from "./types.js";
export { toMessageResourceType } from "./bot-content.js";
// Cache permission errors to avoid spamming the user with repeated notifications.
// Key: appId or "default", Value: timestamp of last notification
const permissionErrorNotifiedAt = new Map<string, number>();
@ -91,546 +99,6 @@ export type FeishuBotAddedEvent = {
operator_tenant_key?: string;
};
type GroupSessionScope = "group" | "group_sender" | "group_topic" | "group_topic_sender";
type ResolvedFeishuGroupSession = {
peerId: string;
parentPeer: { kind: "group"; id: string } | null;
groupSessionScope: GroupSessionScope;
replyInThread: boolean;
threadReply: boolean;
};
function resolveFeishuGroupSession(params: {
chatId: string;
senderOpenId: string;
messageId: string;
rootId?: string;
threadId?: string;
groupConfig?: {
groupSessionScope?: GroupSessionScope;
topicSessionMode?: "enabled" | "disabled";
replyInThread?: "enabled" | "disabled";
};
feishuCfg?: {
groupSessionScope?: GroupSessionScope;
topicSessionMode?: "enabled" | "disabled";
replyInThread?: "enabled" | "disabled";
};
}): ResolvedFeishuGroupSession {
const { chatId, senderOpenId, messageId, rootId, threadId, groupConfig, feishuCfg } = params;
const normalizedThreadId = threadId?.trim();
const normalizedRootId = rootId?.trim();
const threadReply = Boolean(normalizedThreadId || normalizedRootId);
const replyInThread =
(groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled" ||
threadReply;
const legacyTopicSessionMode =
groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
const groupSessionScope: GroupSessionScope =
groupConfig?.groupSessionScope ??
feishuCfg?.groupSessionScope ??
(legacyTopicSessionMode === "enabled" ? "group_topic" : "group");
// Keep topic session keys stable across the "first turn creates thread" flow:
// first turn may only have message_id, while the next turn carries root_id/thread_id.
// Prefer root_id first so both turns stay on the same peer key.
const topicScope =
groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender"
? (normalizedRootId ?? normalizedThreadId ?? (replyInThread ? messageId : null))
: null;
let peerId = chatId;
switch (groupSessionScope) {
case "group_sender":
peerId = buildFeishuConversationId({
chatId,
scope: "group_sender",
senderOpenId,
});
break;
case "group_topic":
peerId = topicScope
? buildFeishuConversationId({
chatId,
scope: "group_topic",
topicId: topicScope,
})
: chatId;
break;
case "group_topic_sender":
peerId = topicScope
? buildFeishuConversationId({
chatId,
scope: "group_topic_sender",
topicId: topicScope,
senderOpenId,
})
: buildFeishuConversationId({
chatId,
scope: "group_sender",
senderOpenId,
});
break;
case "group":
default:
peerId = chatId;
break;
}
const parentPeer =
topicScope &&
(groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender")
? {
kind: "group" as const,
id: chatId,
}
: null;
return {
peerId,
parentPeer,
groupSessionScope,
replyInThread,
threadReply,
};
}
function parseMessageContent(content: string, messageType: string): string {
if (messageType === "post") {
// Extract text content from rich text post
const { textContent } = parsePostContent(content);
return textContent;
}
try {
const parsed = JSON.parse(content);
if (messageType === "text") {
return parsed.text || "";
}
if (messageType === "share_chat") {
// Preserve available summary text for merged/forwarded chat messages.
if (parsed && typeof parsed === "object") {
const share = parsed as {
body?: unknown;
summary?: unknown;
share_chat_id?: unknown;
};
if (typeof share.body === "string" && share.body.trim().length > 0) {
return share.body.trim();
}
if (typeof share.summary === "string" && share.summary.trim().length > 0) {
return share.summary.trim();
}
if (typeof share.share_chat_id === "string" && share.share_chat_id.trim().length > 0) {
return `[Forwarded message: ${share.share_chat_id.trim()}]`;
}
}
return "[Forwarded message]";
}
if (messageType === "merge_forward") {
// Return placeholder; actual content fetched asynchronously in handleFeishuMessage
return "[Merged and Forwarded Message - loading...]";
}
return content;
} catch {
return content;
}
}
/**
* Parse merge_forward message content and fetch sub-messages.
* Returns formatted text content of all sub-messages.
*/
function parseMergeForwardContent(params: {
content: string;
log?: (...args: any[]) => void;
}): string {
const { content, log } = params;
const maxMessages = 50;
// For merge_forward, the API returns all sub-messages in items array
// with upper_message_id pointing to the merge_forward message.
// The 'content' parameter here is actually the full API response items array as JSON.
log?.(`feishu: parsing merge_forward sub-messages from API response`);
let items: Array<{
message_id?: string;
msg_type?: string;
body?: { content?: string };
sender?: { id?: string };
upper_message_id?: string;
create_time?: string;
}>;
try {
items = JSON.parse(content);
} catch {
log?.(`feishu: merge_forward items parse failed`);
return "[Merged and Forwarded Message - parse error]";
}
if (!Array.isArray(items) || items.length === 0) {
return "[Merged and Forwarded Message - no sub-messages]";
}
// Filter to only sub-messages (those with upper_message_id, skip the merge_forward container itself)
const subMessages = items.filter((item) => item.upper_message_id);
if (subMessages.length === 0) {
return "[Merged and Forwarded Message - no sub-messages found]";
}
log?.(`feishu: merge_forward contains ${subMessages.length} sub-messages`);
// Sort by create_time
subMessages.sort((a, b) => {
const timeA = parseInt(a.create_time || "0", 10);
const timeB = parseInt(b.create_time || "0", 10);
return timeA - timeB;
});
// Format output
const lines: string[] = ["[Merged and Forwarded Messages]"];
const limitedMessages = subMessages.slice(0, maxMessages);
for (const item of limitedMessages) {
const msgContent = item.body?.content || "";
const msgType = item.msg_type || "text";
const formatted = formatSubMessageContent(msgContent, msgType);
lines.push(`- ${formatted}`);
}
if (subMessages.length > maxMessages) {
lines.push(`... and ${subMessages.length - maxMessages} more messages`);
}
return lines.join("\n");
}
/**
* Format sub-message content based on message type.
*/
function formatSubMessageContent(content: string, contentType: string): string {
try {
const parsed = JSON.parse(content);
switch (contentType) {
case "text":
return parsed.text || content;
case "post": {
const { textContent } = parsePostContent(content);
return textContent;
}
case "image":
return "[Image]";
case "file":
return `[File: ${parsed.file_name || "unknown"}]`;
case "audio":
return "[Audio]";
case "video":
return "[Video]";
case "sticker":
return "[Sticker]";
case "merge_forward":
return "[Nested Merged Forward]";
default:
return `[${contentType}]`;
}
} catch {
return content;
}
}
function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean {
if (!botOpenId) return false;
// Check for @all (@_all in Feishu) — treat as mentioning every bot
const rawContent = event.message.content ?? "";
if (rawContent.includes("@_all")) return true;
const mentions = event.message.mentions ?? [];
if (mentions.length > 0) {
// Rely on Feishu mention IDs; display names can vary by alias/context.
return mentions.some((m) => m.id.open_id === botOpenId);
}
// Post (rich text) messages may have empty message.mentions when they contain docs/paste
if (event.message.message_type === "post") {
const { mentionedOpenIds } = parsePostContent(event.message.content);
return mentionedOpenIds.some((id) => id === botOpenId);
}
return false;
}
function normalizeMentions(
text: string,
mentions?: FeishuMessageEvent["message"]["mentions"],
botStripId?: string,
): string {
if (!mentions || mentions.length === 0) return text;
const escaped = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const escapeName = (value: string) => value.replace(/</g, "&lt;").replace(/>/g, "&gt;");
let result = text;
for (const mention of mentions) {
const mentionId = mention.id.open_id;
const replacement =
botStripId && mentionId === botStripId
? ""
: mentionId
? `<at user_id="${mentionId}">${escapeName(mention.name)}</at>`
: `@${mention.name}`;
result = result.replace(new RegExp(escaped(mention.key), "g"), () => replacement).trim();
}
return result;
}
function normalizeFeishuCommandProbeBody(text: string): string {
if (!text) {
return "";
}
return text
.replace(/<at\b[^>]*>[^<]*<\/at>/giu, " ")
.replace(/(^|\s)@[^/\s]+(?=\s|$|\/)/gu, "$1")
.replace(/\s+/g, " ")
.trim();
}
/**
* Parse media keys from message content based on message type.
*/
function parseMediaKeys(
content: string,
messageType: string,
): {
imageKey?: string;
fileKey?: string;
fileName?: string;
} {
try {
const parsed = JSON.parse(content);
const imageKey = normalizeFeishuExternalKey(parsed.image_key);
const fileKey = normalizeFeishuExternalKey(parsed.file_key);
switch (messageType) {
case "image":
return { imageKey, fileName: parsed.file_name };
case "file":
return { fileKey, fileName: parsed.file_name };
case "audio":
return { fileKey, fileName: parsed.file_name };
case "video":
case "media":
// Video/media has both file_key (video) and image_key (thumbnail)
return { fileKey, imageKey, fileName: parsed.file_name };
case "sticker":
return { fileKey, fileName: parsed.file_name };
default:
return {};
}
} catch {
return {};
}
}
/**
* Map Feishu message type to messageResource.get resource type.
* Feishu messageResource API supports only: image | file.
*/
export function toMessageResourceType(messageType: string): "image" | "file" {
return messageType === "image" ? "image" : "file";
}
/**
* Infer placeholder text based on message type.
*/
function inferPlaceholder(messageType: string): string {
switch (messageType) {
case "image":
return "<media:image>";
case "file":
return "<media:document>";
case "audio":
return "<media:audio>";
case "video":
case "media":
return "<media:video>";
case "sticker":
return "<media:sticker>";
default:
return "<media:document>";
}
}
/**
* Resolve media from a Feishu message, downloading and saving to disk.
* Similar to Discord's resolveMediaList().
*/
async function resolveFeishuMediaList(params: {
cfg: ClawdbotConfig;
messageId: string;
messageType: string;
content: string;
maxBytes: number;
log?: (msg: string) => void;
accountId?: string;
}): Promise<FeishuMediaInfo[]> {
const { cfg, messageId, messageType, content, maxBytes, log, accountId } = params;
// Only process media message types (including post for embedded images)
const mediaTypes = ["image", "file", "audio", "video", "media", "sticker", "post"];
if (!mediaTypes.includes(messageType)) {
return [];
}
const out: FeishuMediaInfo[] = [];
const core = getFeishuRuntime();
// Handle post (rich text) messages with embedded images/media.
if (messageType === "post") {
const { imageKeys, mediaKeys: postMediaKeys } = parsePostContent(content);
if (imageKeys.length === 0 && postMediaKeys.length === 0) {
return [];
}
if (imageKeys.length > 0) {
log?.(`feishu: post message contains ${imageKeys.length} embedded image(s)`);
}
if (postMediaKeys.length > 0) {
log?.(`feishu: post message contains ${postMediaKeys.length} embedded media file(s)`);
}
for (const imageKey of imageKeys) {
try {
// Embedded images in post use messageResource API with image_key as file_key
const result = await downloadMessageResourceFeishu({
cfg,
messageId,
fileKey: imageKey,
type: "image",
accountId,
});
let contentType = result.contentType;
if (!contentType) {
contentType = await core.media.detectMime({ buffer: result.buffer });
}
const saved = await core.channel.media.saveMediaBuffer(
result.buffer,
contentType,
"inbound",
maxBytes,
);
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: "<media:image>",
});
log?.(`feishu: downloaded embedded image ${imageKey}, saved to ${saved.path}`);
} catch (err) {
log?.(`feishu: failed to download embedded image ${imageKey}: ${String(err)}`);
}
}
for (const media of postMediaKeys) {
try {
const result = await downloadMessageResourceFeishu({
cfg,
messageId,
fileKey: media.fileKey,
type: "file",
accountId,
});
let contentType = result.contentType;
if (!contentType) {
contentType = await core.media.detectMime({ buffer: result.buffer });
}
const saved = await core.channel.media.saveMediaBuffer(
result.buffer,
contentType,
"inbound",
maxBytes,
);
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: "<media:video>",
});
log?.(`feishu: downloaded embedded media ${media.fileKey}, saved to ${saved.path}`);
} catch (err) {
log?.(`feishu: failed to download embedded media ${media.fileKey}: ${String(err)}`);
}
}
return out;
}
// Handle other media types
const mediaKeys = parseMediaKeys(content, messageType);
if (!mediaKeys.imageKey && !mediaKeys.fileKey) {
return [];
}
try {
let buffer: Buffer;
let contentType: string | undefined;
let fileName: string | undefined;
// For message media, always use messageResource API
// The image.get API is only for images uploaded via im/v1/images, not for message attachments
const fileKey = mediaKeys.fileKey || mediaKeys.imageKey;
if (!fileKey) {
return [];
}
const resourceType = toMessageResourceType(messageType);
const result = await downloadMessageResourceFeishu({
cfg,
messageId,
fileKey,
type: resourceType,
accountId,
});
buffer = result.buffer;
contentType = result.contentType;
fileName = result.fileName || mediaKeys.fileName;
// Detect mime type if not provided
if (!contentType) {
contentType = await core.media.detectMime({ buffer });
}
// Save to disk using core's saveMediaBuffer
const saved = await core.channel.media.saveMediaBuffer(
buffer,
contentType,
"inbound",
maxBytes,
fileName,
);
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: inferPlaceholder(messageType),
});
log?.(`feishu: downloaded ${messageType} media, saved to ${saved.path}`);
} catch (err) {
log?.(`feishu: failed to download ${messageType} media: ${String(err)}`);
}
return out;
}
// --- Broadcast support ---
// Resolve broadcast agent list for a given peer (group) ID.
// Returns null if no broadcast config exists or the peer is not in the broadcast list.

View File

@ -1,8 +1,5 @@
import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
import {
createHybridChannelConfigBase,
createScopedAccountConfigAccessors,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { createHybridChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers";
import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy";
import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime";
import type {
@ -130,17 +127,16 @@ function setFeishuNamedAccountEnabled(
};
}
const feishuConfigBase = createHybridChannelConfigBase<ResolvedFeishuAccount, ClawdbotConfig>({
const feishuConfigAdapter = createHybridChannelConfigAdapter<
ResolvedFeishuAccount,
ResolvedFeishuAccount,
ClawdbotConfig
>({
sectionKey: "feishu",
listAccountIds: listFeishuAccountIds,
resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultFeishuAccountId,
clearBaseFields: [],
});
const feishuConfigAccessors = createScopedAccountConfigAccessors<ResolvedFeishuAccount>({
resolveAccount: ({ cfg, accountId }) =>
resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId }),
resolveAllowFrom: (account) => account.config.allowFrom,
formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }),
});
@ -396,7 +392,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
reload: { configPrefixes: ["channels.feishu"] },
configSchema: buildChannelConfigSchema(FeishuConfigSchema),
config: {
...feishuConfigBase,
...feishuConfigAdapter,
setAccountEnabled: ({ cfg, accountId, enabled }) => {
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
if (isDefault) {
@ -454,7 +450,6 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
appId: account.appId,
domain: account.domain,
}),
...feishuConfigAccessors,
},
actions: {
describeMessageTool: describeFeishuMessageTool,

View File

@ -1,8 +1,33 @@
{
"id": "firecrawl",
"uiHints": {
"webSearch.apiKey": {
"label": "Firecrawl Search API Key",
"help": "Firecrawl API key for web search (fallback: FIRECRAWL_API_KEY env var).",
"sensitive": true,
"placeholder": "fc-..."
},
"webSearch.baseUrl": {
"label": "Firecrawl Search Base URL",
"help": "Firecrawl Search base URL override."
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
"properties": {
"webSearch": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": ["string", "object"]
},
"baseUrl": {
"type": "string"
}
}
}
}
}
}

View File

@ -26,6 +26,15 @@ type FirecrawlSearchConfig =
}
| undefined;
type PluginEntryConfig =
| {
webSearch?: {
apiKey?: unknown;
baseUrl?: string;
};
}
| undefined;
type FirecrawlFetchConfig =
| {
apiKey?: unknown;
@ -53,6 +62,11 @@ function resolveFetchConfig(cfg?: OpenClawConfig): WebFetchConfig {
}
export function resolveFirecrawlSearchConfig(cfg?: OpenClawConfig): FirecrawlSearchConfig {
const pluginConfig = cfg?.plugins?.entries?.firecrawl?.config as PluginEntryConfig;
const pluginWebSearch = pluginConfig?.webSearch;
if (pluginWebSearch && typeof pluginWebSearch === "object" && !Array.isArray(pluginWebSearch)) {
return pluginWebSearch;
}
const search = resolveSearchConfig(cfg);
if (!search || typeof search !== "object") {
return undefined;
@ -89,6 +103,10 @@ export function resolveFirecrawlApiKey(cfg?: OpenClawConfig): string | undefined
const search = resolveFirecrawlSearchConfig(cfg);
const fetch = resolveFirecrawlFetchConfig(cfg);
return (
normalizeConfiguredSecret(
search?.apiKey,
"plugins.entries.firecrawl.config.webSearch.apiKey",
) ||
normalizeConfiguredSecret(search?.apiKey, "tools.web.search.firecrawl.apiKey") ||
normalizeConfiguredSecret(fetch?.apiKey, "tools.web.fetch.firecrawl.apiKey") ||
normalizeSecretInput(process.env.FIRECRAWL_API_KEY) ||

View File

@ -1,4 +1,8 @@
import { Type } from "@sinclair/typebox";
import {
resolveProviderWebSearchPluginConfig,
setProviderWebSearchPluginConfigValue,
} from "../../../src/agents/tools/web-search-provider-config.js";
import { enablePluginInConfig } from "../../../src/plugins/enable.js";
import type { WebSearchProviderPlugin } from "../../../src/plugins/types.js";
import { runFirecrawlSearch } from "./firecrawl-client.js";
@ -47,10 +51,15 @@ export function createFirecrawlWebSearchProvider(): WebSearchProviderPlugin {
signupUrl: "https://www.firecrawl.dev/",
docsUrl: "https://docs.openclaw.ai/tools/firecrawl",
autoDetectOrder: 60,
credentialPath: "tools.web.search.firecrawl.apiKey",
inactiveSecretPaths: ["tools.web.search.firecrawl.apiKey"],
credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey",
inactiveSecretPaths: ["plugins.entries.firecrawl.config.webSearch.apiKey"],
getCredentialValue: getScopedCredentialValue,
setCredentialValue: setScopedCredentialValue,
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "firecrawl")?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
setProviderWebSearchPluginConfigValue(configTarget, "firecrawl", "apiKey", value);
},
applySelectionConfig: (config) => enablePluginInConfig(config, "firecrawl").config,
createTool: (ctx) => ({
description:

View File

@ -5,6 +5,7 @@ import {
GOOGLE_GEMINI_DEFAULT_MODEL,
applyGoogleGeminiModelDefault,
} from "openclaw/plugin-sdk/provider-models";
import { createGoogleThinkingPayloadWrapper } from "openclaw/plugin-sdk/provider-stream";
import { registerGoogleGeminiCliProvider } from "./gemini-cli-provider.js";
import { googleMediaUnderstandingProvider } from "./media-understanding-provider.js";
import { isModernGoogleModel, resolveGoogle31ForwardCompatModel } from "./provider-models.js";
@ -44,6 +45,7 @@ export default definePluginEntry({
],
resolveDynamicModel: (ctx) =>
resolveGoogle31ForwardCompatModel({ providerId: "google", ctx }),
wrapStreamFn: (ctx) => createGoogleThinkingPayloadWrapper(ctx.streamFn, ctx.thinkingLevel),
isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId),
});
registerGoogleGeminiCliProvider(api);

View File

@ -29,9 +29,34 @@
"groupHint": "Gemini API key + OAuth"
}
],
"uiHints": {
"webSearch.apiKey": {
"label": "Gemini Search API Key",
"help": "Gemini API key for Google Search grounding (fallback: GEMINI_API_KEY env var).",
"sensitive": true,
"placeholder": "AIza..."
},
"webSearch.model": {
"label": "Gemini Search Model",
"help": "Gemini model override for web search grounding."
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
"properties": {
"webSearch": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": ["string", "object"]
},
"model": {
"type": "string"
}
}
}
}
}
}

View File

@ -15,6 +15,11 @@ import {
withTrustedWebSearchEndpoint,
writeCachedSearchPayload,
} from "../../../src/agents/tools/web-search-provider-common.js";
import {
resolveProviderWebSearchPluginConfig,
setProviderWebSearchPluginConfigValue,
} from "../../../src/agents/tools/web-search-provider-config.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type {
WebSearchProviderPlugin,
WebSearchProviderToolDefinition,
@ -52,8 +57,15 @@ type GeminiGroundingResponse = {
};
};
function resolveGeminiConfig(searchConfig?: SearchConfigRecord): GeminiConfig {
const gemini = searchConfig?.gemini;
function resolveGeminiConfig(
config?: OpenClawConfig,
searchConfig?: SearchConfigRecord,
): GeminiConfig {
const pluginConfig = resolveProviderWebSearchPluginConfig(config, "google");
if (pluginConfig) {
return pluginConfig as GeminiConfig;
}
const gemini = (searchConfig as Record<string, unknown> | undefined)?.gemini;
return gemini && typeof gemini === "object" && !Array.isArray(gemini)
? (gemini as GeminiConfig)
: {};
@ -61,7 +73,7 @@ function resolveGeminiConfig(searchConfig?: SearchConfigRecord): GeminiConfig {
function resolveGeminiApiKey(gemini?: GeminiConfig): string | undefined {
return (
readConfiguredSecretString(gemini?.apiKey, "tools.web.search.gemini.apiKey") ??
readConfiguredSecretString(gemini?.apiKey, "plugins.entries.google.config.webSearch.apiKey") ??
readProviderEnvValue(["GEMINI_API_KEY"])
);
}
@ -168,6 +180,7 @@ function createGeminiSchema() {
}
function createGeminiToolDefinition(
config?: OpenClawConfig,
searchConfig?: SearchConfigRecord,
): WebSearchProviderToolDefinition {
return {
@ -194,13 +207,13 @@ function createGeminiToolDefinition(
}
}
const geminiConfig = resolveGeminiConfig(searchConfig);
const geminiConfig = resolveGeminiConfig(config, searchConfig);
const apiKey = resolveGeminiApiKey(geminiConfig);
if (!apiKey) {
return {
error: "missing_gemini_api_key",
message:
"web_search (gemini) needs an API key. Set GEMINI_API_KEY in the Gateway environment, or configure tools.web.search.gemini.apiKey.",
"web_search (gemini) needs an API key. Set GEMINI_API_KEY in the Gateway environment, or configure plugins.entries.google.config.webSearch.apiKey.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
@ -259,8 +272,8 @@ export function createGeminiWebSearchProvider(): WebSearchProviderPlugin {
signupUrl: "https://aistudio.google.com/apikey",
docsUrl: "https://docs.openclaw.ai/tools/web",
autoDetectOrder: 20,
credentialPath: "tools.web.search.gemini.apiKey",
inactiveSecretPaths: ["tools.web.search.gemini.apiKey"],
credentialPath: "plugins.entries.google.config.webSearch.apiKey",
inactiveSecretPaths: ["plugins.entries.google.config.webSearch.apiKey"],
getCredentialValue: (searchConfig) => {
const gemini = searchConfig?.gemini;
return gemini && typeof gemini === "object" && !Array.isArray(gemini)
@ -275,8 +288,13 @@ export function createGeminiWebSearchProvider(): WebSearchProviderPlugin {
}
(scoped as Record<string, unknown>).apiKey = value;
},
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "google")?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
setProviderWebSearchPluginConfigValue(configTarget, "google", "apiKey", value);
},
createTool: (ctx) =>
createGeminiToolDefinition(ctx.searchConfig as SearchConfigRecord | undefined),
createGeminiToolDefinition(ctx.config, ctx.searchConfig as SearchConfigRecord | undefined),
};
}

View File

@ -9,16 +9,19 @@ export {
readStringParam,
} from "../../src/agents/tools/common.js";
export {
createScopedChannelConfigAdapter,
createScopedAccountConfigAccessors,
createScopedChannelConfigBase,
createTopLevelChannelConfigAdapter,
createHybridChannelConfigAdapter,
createScopedDmSecurityResolver,
} from "../../src/plugin-sdk/channel-config-helpers.js";
export {
buildOpenGroupPolicyConfigureRouteAllowlistWarning,
collectAllowlistProviderGroupPolicyWarnings,
resolveMentionGatingWithBypass,
} from "../../src/channels/channel-policy.js";
export { formatNormalizedAllowFromEntries } from "../../src/channels/allow-from.js";
} from "../../src/plugin-sdk/channel-policy.js";
export { resolveMentionGatingWithBypass } from "../../src/channels/mention-gating.js";
export { formatNormalizedAllowFromEntries } from "../../src/plugin-sdk/allow-from.js";
export { buildComputedAccountStatusSnapshot } from "../../src/plugin-sdk/status-helpers.js";
export {
createAccountStatusSink,

View File

@ -1,7 +1,6 @@
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
import {
createScopedAccountConfigAccessors,
createScopedChannelConfigBase,
createScopedChannelConfigAdapter,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import {
@ -61,18 +60,7 @@ const formatAllowFromEntry = (entry: string) =>
.replace(/^users\//i, "")
.toLowerCase();
const googleChatConfigAccessors = createScopedAccountConfigAccessors({
resolveAccount: ({ cfg, accountId }) => resolveGoogleChatAccount({ cfg, accountId }),
resolveAllowFrom: (account: ResolvedGoogleChatAccount) => account.config.dm?.allowFrom,
formatAllowFrom: (allowFrom) =>
formatNormalizedAllowFromEntries({
allowFrom,
normalizeEntry: formatAllowFromEntry,
}),
resolveDefaultTo: (account: ResolvedGoogleChatAccount) => account.config.defaultTo,
});
const googleChatConfigBase = createScopedChannelConfigBase<ResolvedGoogleChatAccount>({
const googleChatConfigAdapter = createScopedChannelConfigAdapter<ResolvedGoogleChatAccount>({
sectionKey: "googlechat",
listAccountIds: listGoogleChatAccountIds,
resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg, accountId }),
@ -87,6 +75,13 @@ const googleChatConfigBase = createScopedChannelConfigBase<ResolvedGoogleChatAcc
"botUser",
"name",
],
resolveAllowFrom: (account: ResolvedGoogleChatAccount) => account.config.dm?.allowFrom,
formatAllowFrom: (allowFrom) =>
formatNormalizedAllowFromEntries({
allowFrom,
normalizeEntry: formatAllowFromEntry,
}),
resolveDefaultTo: (account: ResolvedGoogleChatAccount) => account.config.defaultTo,
});
const resolveGoogleChatDmPolicy = createScopedDmSecurityResolver<ResolvedGoogleChatAccount>({
@ -146,7 +141,7 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
reload: { configPrefixes: ["channels.googlechat"] },
configSchema: buildChannelConfigSchema(GoogleChatConfigSchema),
config: {
...googleChatConfigBase,
...googleChatConfigAdapter,
isConfigured: (account) => account.credentialSource !== "none",
describeAccount: (account) => ({
accountId: account.accountId,
@ -155,7 +150,6 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
configured: account.credentialSource !== "none",
credentialSource: account.credentialSource,
}),
...googleChatConfigAccessors,
},
security: {
resolveDmPolicy: resolveGoogleChatDmPolicy,

View File

@ -1,3 +1,29 @@
export * from "./src/monitor.js";
export * from "./src/probe.js";
export * from "./src/send.js";
export type { IMessageAccountConfig } from "../../src/config/types.imessage.js";
export type { ChannelPlugin } from "../../src/channels/plugins/types.plugin.js";
export {
DEFAULT_ACCOUNT_ID,
PAIRING_APPROVED_MESSAGE,
buildChannelConfigSchema,
getChatChannelMeta,
} from "../../src/plugin-sdk/channel-plugin-common.js";
export {
formatTrimmedAllowFromEntries,
resolveIMessageConfigAllowFrom,
resolveIMessageConfigDefaultTo,
} from "../../src/plugin-sdk/channel-config-helpers.js";
export { collectStatusIssuesFromLastError } from "../../src/plugin-sdk/status-helpers.js";
export { resolveChannelMediaMaxBytes } from "../../src/channels/plugins/media-limits.js";
export {
looksLikeIMessageTargetId,
normalizeIMessageMessagingTarget,
} from "../../src/channels/plugins/normalize/imessage.js";
export { IMessageConfigSchema } from "../../src/config/zod-schema.providers-core.js";
export {
resolveIMessageGroupRequireMention,
resolveIMessageGroupToolPolicy,
} from "./src/group-policy.js";
export { monitorIMessageProvider } from "./src/monitor.js";
export type { MonitorIMessageOpts } from "./src/monitor.js";
export { probeIMessage } from "./src/probe.js";
export { sendMessageIMessage } from "./src/send.js";

View File

@ -4,7 +4,7 @@ import {
resolveAccountEntry,
type OpenClawConfig,
} from "openclaw/plugin-sdk/account-resolution";
import type { IMessageAccountConfig } from "openclaw/plugin-sdk/imessage";
import type { IMessageAccountConfig } from "../runtime-api.js";
export type ResolvedIMessageAccount = {
accountId: string;

View File

@ -1,8 +1,5 @@
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
import {
PAIRING_APPROVED_MESSAGE,
resolveChannelMediaMaxBytes,
} from "openclaw/plugin-sdk/imessage";
import { PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes } from "../runtime-api.js";
import type { ResolvedIMessageAccount } from "./accounts.js";
import { monitorIMessageProvider } from "./monitor.js";
import { probeIMessage } from "./probe.js";
@ -55,7 +52,7 @@ export async function startIMessageGatewayAccount(
ctx: Parameters<
NonNullable<
NonNullable<
import("openclaw/plugin-sdk/imessage").ChannelPlugin<ResolvedIMessageAccount>["gateway"]
import("../runtime-api.js").ChannelPlugin<ResolvedIMessageAccount>["gateway"]
>["startAccount"]
>
>[0],

View File

@ -1,4 +1,4 @@
import { type ChannelPlugin } from "openclaw/plugin-sdk/imessage";
import { type ChannelPlugin } from "../runtime-api.js";
import { type ResolvedIMessageAccount } from "./accounts.js";
import { imessageSetupAdapter } from "./setup-core.js";
import { createIMessagePluginBase, imessageSetupWizard } from "./shared.js";

View File

@ -1,17 +1,16 @@
import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit";
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/core";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { type RoutePeer } from "openclaw/plugin-sdk/routing";
import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js";
import {
collectStatusIssuesFromLastError,
DEFAULT_ACCOUNT_ID,
formatTrimmedAllowFromEntries,
looksLikeIMessageTargetId,
normalizeIMessageMessagingTarget,
type ChannelPlugin,
} from "openclaw/plugin-sdk/imessage";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { type RoutePeer } from "openclaw/plugin-sdk/routing";
import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js";
} from "../runtime-api.js";
import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js";
import {
resolveIMessageGroupRequireMention,
@ -25,7 +24,12 @@ import {
imessageResolveDmPolicy,
imessageSetupWizard,
} from "./shared.js";
import { normalizeIMessageHandle, parseIMessageTarget } from "./targets.js";
import {
inferIMessageTargetChatType,
looksLikeIMessageExplicitTargetId,
normalizeIMessageHandle,
parseIMessageTarget,
} from "./targets.js";
const loadIMessageChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js"));
@ -139,10 +143,26 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
},
messaging: {
normalizeTarget: normalizeIMessageMessagingTarget,
inferTargetChatType: ({ to }) => inferIMessageTargetChatType(to),
resolveOutboundSessionRoute: (params) => resolveIMessageOutboundSessionRoute(params),
targetResolver: {
looksLikeId: looksLikeIMessageTargetId,
looksLikeId: looksLikeIMessageExplicitTargetId,
hint: "<handle|chat_id:ID>",
resolveTarget: async ({ normalized }) => {
const to = normalized?.trim();
if (!to) {
return null;
}
const chatType = inferIMessageTargetChatType(to);
if (!chatType) {
return null;
}
return {
to,
kind: chatType === "direct" ? "user" : "group",
source: "normalized" as const,
};
},
},
},
outbound: {

View File

@ -1,8 +1,8 @@
import {
collectAllowlistProviderRestrictSendersWarnings,
createScopedAccountConfigAccessors,
createScopedChannelConfigBase,
createScopedChannelConfigAdapter,
createScopedDmSecurityResolver,
formatTrimmedAllowFromEntries,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { createChannelPluginBase } from "openclaw/plugin-sdk/core";
import {
@ -10,7 +10,7 @@ import {
getChatChannelMeta,
IMessageConfigSchema,
type ChannelPlugin,
} from "openclaw/plugin-sdk/imessage-core";
} from "../runtime-api.js";
import {
listIMessageAccountIds,
resolveDefaultIMessageAccountId,
@ -29,19 +29,15 @@ export const imessageSetupWizard = createIMessageSetupWizardProxy(
async () => (await loadIMessageChannelRuntime()).imessageSetupWizard,
);
export const imessageConfigAccessors = createScopedAccountConfigAccessors({
resolveAccount: ({ cfg, accountId }) => resolveIMessageAccount({ cfg, accountId }),
resolveAllowFrom: (account: ResolvedIMessageAccount) => account.config.allowFrom,
formatAllowFrom: (allowFrom) => allowFrom.map((entry) => String(entry).trim()).filter(Boolean),
resolveDefaultTo: (account: ResolvedIMessageAccount) => account.config.defaultTo,
});
export const imessageConfigBase = createScopedChannelConfigBase<ResolvedIMessageAccount>({
export const imessageConfigAdapter = createScopedChannelConfigAdapter<ResolvedIMessageAccount>({
sectionKey: IMESSAGE_CHANNEL,
listAccountIds: listIMessageAccountIds,
resolveAccount: (cfg, accountId) => resolveIMessageAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultIMessageAccountId,
clearBaseFields: ["cliPath", "dbPath", "service", "region", "name"],
resolveAllowFrom: (account: ResolvedIMessageAccount) => account.config.allowFrom,
formatAllowFrom: (allowFrom) => formatTrimmedAllowFromEntries(allowFrom),
resolveDefaultTo: (account: ResolvedIMessageAccount) => account.config.defaultTo,
});
export const imessageResolveDmPolicy = createScopedDmSecurityResolver<ResolvedIMessageAccount>({
@ -97,7 +93,7 @@ export function createIMessagePluginBase(params: {
reload: { configPrefixes: ["channels.imessage"] },
configSchema: buildChannelConfigSchema(IMessageConfigSchema),
config: {
...imessageConfigBase,
...imessageConfigAdapter,
isConfigured: (account) => account.configured,
describeAccount: (account) => ({
accountId: account.accountId,
@ -105,7 +101,6 @@ export function createIMessagePluginBase(params: {
enabled: account.enabled,
configured: account.configured,
}),
...imessageConfigAccessors,
},
security: {
resolveDmPolicy: imessageResolveDmPolicy,

View File

@ -1,7 +1,9 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
formatIMessageChatTarget,
inferIMessageTargetChatType,
isAllowedIMessageSender,
looksLikeIMessageExplicitTargetId,
normalizeIMessageHandle,
parseIMessageTarget,
} from "./targets.js";
@ -83,6 +85,18 @@ describe("imessage targets", () => {
expect(formatIMessageChatTarget(42)).toBe("chat_id:42");
expect(formatIMessageChatTarget(undefined)).toBe("");
});
it("only treats explicit chat targets as immediate ids", () => {
expect(looksLikeIMessageExplicitTargetId("chat_id:42")).toBe(true);
expect(looksLikeIMessageExplicitTargetId("sms:+15552223333")).toBe(true);
expect(looksLikeIMessageExplicitTargetId("+15552223333")).toBe(false);
expect(looksLikeIMessageExplicitTargetId("user@example.com")).toBe(false);
});
it("infers direct and group chat types from normalized targets", () => {
expect(inferIMessageTargetChatType("+15552223333")).toBe("direct");
expect(inferIMessageTargetChatType("chat_id:42")).toBe("group");
});
});
describe("createIMessageRpcClient", () => {

View File

@ -107,6 +107,34 @@ export function parseIMessageTarget(raw: string): IMessageTarget {
return { kind: "handle", to: trimmed, service: "auto" };
}
export function looksLikeIMessageExplicitTargetId(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
const lower = trimmed.toLowerCase();
if (/^(imessage:|sms:|auto:)/.test(lower)) {
return true;
}
return (
CHAT_ID_PREFIXES.some((prefix) => lower.startsWith(prefix)) ||
CHAT_GUID_PREFIXES.some((prefix) => lower.startsWith(prefix)) ||
CHAT_IDENTIFIER_PREFIXES.some((prefix) => lower.startsWith(prefix))
);
}
export function inferIMessageTargetChatType(raw: string): "direct" | "group" | undefined {
try {
const parsed = parseIMessageTarget(raw);
if (parsed.kind === "handle") {
return "direct";
}
return "group";
} catch {
return undefined;
}
}
export function parseIMessageAllowTarget(raw: string): IMessageAllowTarget {
const trimmed = raw.trim();
if (!trimmed) {

View File

@ -1,13 +1,22 @@
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
import {
createScopedAccountConfigAccessors,
createScopedChannelConfigBase,
createScopedChannelConfigAdapter,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import {
buildOpenGroupPolicyWarning,
collectAllowlistProviderGroupPolicyWarnings,
} from "openclaw/plugin-sdk/channel-policy";
import {
buildBaseAccountStatusSnapshot,
buildBaseChannelStatusSummary,
buildChannelConfigSchema,
createAccountStatusSink,
DEFAULT_ACCOUNT_ID,
getChatChannelMeta,
PAIRING_APPROVED_MESSAGE,
type ChannelPlugin,
} from "openclaw/plugin-sdk/irc";
import { runStoppablePassiveMonitor } from "../../shared/passive-monitor.js";
import {
listIrcAccountIds,
@ -25,16 +34,6 @@ import {
} from "./normalize.js";
import { resolveIrcGroupMatch, resolveIrcRequireMention } from "./policy.js";
import { probeIrc } from "./probe.js";
import {
buildBaseAccountStatusSnapshot,
buildBaseChannelStatusSummary,
buildChannelConfigSchema,
createAccountStatusSink,
DEFAULT_ACCOUNT_ID,
getChatChannelMeta,
PAIRING_APPROVED_MESSAGE,
type ChannelPlugin,
} from "./runtime-api.js";
import { getIrcRuntime } from "./runtime.js";
import { sendMessageIrc } from "./send.js";
import { ircSetupAdapter } from "./setup-core.js";
@ -51,18 +50,11 @@ function normalizePairingTarget(raw: string): string {
return normalized.split(/[!@]/, 1)[0]?.trim() ?? "";
}
const ircConfigAccessors = createScopedAccountConfigAccessors({
resolveAccount: ({ cfg, accountId }) => resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }),
resolveAllowFrom: (account: ResolvedIrcAccount) => account.config.allowFrom,
formatAllowFrom: (allowFrom) =>
formatNormalizedAllowFromEntries({
allowFrom,
normalizeEntry: normalizeIrcAllowEntry,
}),
resolveDefaultTo: (account: ResolvedIrcAccount) => account.config.defaultTo,
});
const ircConfigBase = createScopedChannelConfigBase<ResolvedIrcAccount, CoreConfig>({
const ircConfigAdapter = createScopedChannelConfigAdapter<
ResolvedIrcAccount,
ResolvedIrcAccount,
CoreConfig
>({
sectionKey: "irc",
listAccountIds: listIrcAccountIds,
resolveAccount: (cfg, accountId) => resolveIrcAccount({ cfg, accountId }),
@ -79,6 +71,13 @@ const ircConfigBase = createScopedChannelConfigBase<ResolvedIrcAccount, CoreConf
"passwordFile",
"channels",
],
resolveAllowFrom: (account: ResolvedIrcAccount) => account.config.allowFrom,
formatAllowFrom: (allowFrom) =>
formatNormalizedAllowFromEntries({
allowFrom,
normalizeEntry: normalizeIrcAllowEntry,
}),
resolveDefaultTo: (account: ResolvedIrcAccount) => account.config.defaultTo,
});
const resolveIrcDmPolicy = createScopedDmSecurityResolver<ResolvedIrcAccount>({
@ -116,7 +115,7 @@ export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = {
reload: { configPrefixes: ["channels.irc"] },
configSchema: buildChannelConfigSchema(IrcConfigSchema),
config: {
...ircConfigBase,
...ircConfigAdapter,
isConfigured: (account) => account.configured,
describeAccount: (account) => ({
accountId: account.accountId,
@ -129,7 +128,6 @@ export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = {
nick: account.nick,
passwordSource: account.passwordSource,
}),
...ircConfigAccessors,
},
security: {
resolveDmPolicy: resolveIrcDmPolicy,

View File

@ -1,5 +1,3 @@
import { z } from "zod";
import { requireChannelOpenAllowFrom } from "../../shared/config-schema-helpers.js";
import {
BlockStreamingCoalesceSchema,
DmConfigSchema,
@ -9,7 +7,9 @@ import {
ReplyRuntimeConfigSchemaShape,
ToolPolicySchema,
requireOpenAllowFrom,
} from "./runtime-api.js";
} from "openclaw/plugin-sdk/irc";
import { z } from "zod";
import { requireChannelOpenAllowFrom } from "../../shared/config-schema-helpers.js";
const IrcGroupSchema = z
.object({

View File

@ -2,10 +2,9 @@ import {
buildChannelConfigSchema,
LineConfigSchema,
type ChannelPlugin,
type OpenClawConfig,
type ResolvedLineAccount,
} from "../api.js";
import { listLineAccountIds, resolveDefaultLineAccountId, resolveLineAccount } from "../api.js";
import { lineConfigAdapter } from "./config-adapter.js";
import { lineSetupAdapter } from "./setup-core.js";
import { lineSetupWizard } from "./setup-surface.js";
@ -20,8 +19,6 @@ const meta = {
systemImage: "message.fill",
} as const;
const normalizeLineAllowFrom = (entry: string) => entry.replace(/^line:(?:user:)?/i, "");
export const lineSetupPlugin: ChannelPlugin<ResolvedLineAccount> = {
id: "line",
meta: {
@ -39,10 +36,7 @@ export const lineSetupPlugin: ChannelPlugin<ResolvedLineAccount> = {
reload: { configPrefixes: ["channels.line"] },
configSchema: buildChannelConfigSchema(LineConfigSchema),
config: {
listAccountIds: (cfg: OpenClawConfig) => listLineAccountIds(cfg),
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) =>
resolveLineAccount({ cfg, accountId: accountId ?? undefined }),
defaultAccountId: (cfg: OpenClawConfig) => resolveDefaultLineAccountId(cfg),
...lineConfigAdapter,
isConfigured: (account) =>
Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()),
describeAccount: (account) => ({
@ -52,13 +46,6 @@ export const lineSetupPlugin: ChannelPlugin<ResolvedLineAccount> = {
configured: Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()),
tokenSource: account.tokenSource ?? undefined,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
resolveLineAccount({ cfg, accountId: accountId ?? undefined }).config.allowFrom,
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => normalizeLineAllowFrom(entry)),
},
setupWizard: lineSetupWizard,
setup: lineSetupAdapter,

View File

@ -1,8 +1,4 @@
import {
createScopedAccountConfigAccessors,
createScopedChannelConfigBase,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy";
import {
buildChannelConfigSchema,
@ -14,11 +10,11 @@ import {
processLineMessage,
type ChannelPlugin,
type ChannelStatusIssue,
type OpenClawConfig,
type LineConfig,
type LineChannelData,
type ResolvedLineAccount,
} from "../api.js";
import { lineConfigAdapter } from "./config-adapter.js";
import { resolveLineGroupRequireMention } from "./group-policy.js";
import { getLineRuntime } from "./runtime.js";
import { lineSetupAdapter } from "./setup-core.js";
@ -36,26 +32,6 @@ const meta = {
systemImage: "message.fill",
};
const lineConfigAccessors = createScopedAccountConfigAccessors({
resolveAccount: ({ cfg, accountId }) =>
getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId: accountId ?? undefined }),
resolveAllowFrom: (account: ResolvedLineAccount) => account.config.allowFrom,
formatAllowFrom: (allowFrom) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.replace(/^line:(?:user:)?/i, "")),
});
const lineConfigBase = createScopedChannelConfigBase<ResolvedLineAccount, OpenClawConfig>({
sectionKey: "line",
listAccountIds: (cfg) => getLineRuntime().channel.line.listLineAccountIds(cfg),
resolveAccount: (cfg, accountId) =>
getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId: accountId ?? undefined }),
defaultAccountId: (cfg) => getLineRuntime().channel.line.resolveDefaultLineAccountId(cfg),
clearBaseFields: ["channelSecret", "tokenFile", "secretFile"],
});
const resolveLineDmPolicy = createScopedDmSecurityResolver<ResolvedLineAccount>({
channelKey: "line",
resolvePolicy: (account) => account.config.dmPolicy,
@ -100,7 +76,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
configSchema: buildChannelConfigSchema(LineConfigSchema),
setupWizard: lineSetupWizard,
config: {
...lineConfigBase,
...lineConfigAdapter,
isConfigured: (account) =>
Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()),
describeAccount: (account) => ({
@ -110,7 +86,6 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
configured: Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()),
tokenSource: account.tokenSource ?? undefined,
}),
...lineConfigAccessors,
},
security: {
resolveDmPolicy: resolveLineDmPolicy,

View File

@ -0,0 +1,32 @@
import { createScopedChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers";
import type { OpenClawConfig, ResolvedLineAccount } from "../api.js";
import { getLineRuntime } from "./runtime.js";
function resolveLineRuntimeAccount(cfg: OpenClawConfig, accountId?: string | null) {
return getLineRuntime().channel.line.resolveLineAccount({
cfg,
accountId: accountId ?? undefined,
});
}
export function normalizeLineAllowFrom(entry: string): string {
return entry.replace(/^line:(?:user:)?/i, "");
}
export const lineConfigAdapter = createScopedChannelConfigAdapter<
ResolvedLineAccount,
ResolvedLineAccount,
OpenClawConfig
>({
sectionKey: "line",
listAccountIds: (cfg) => getLineRuntime().channel.line.listLineAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveLineRuntimeAccount(cfg, accountId),
defaultAccountId: (cfg) => getLineRuntime().channel.line.resolveDefaultLineAccountId(cfg),
clearBaseFields: ["channelSecret", "tokenFile", "secretFile"],
resolveAllowFrom: (account) => account.config.allowFrom,
formatAllowFrom: (allowFrom) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map(normalizeLineAllowFrom),
});

View File

@ -1,6 +1,5 @@
import {
createScopedAccountConfigAccessors,
createScopedChannelConfigBase,
createScopedChannelConfigAdapter,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import {
@ -69,17 +68,16 @@ function normalizeMatrixMessagingTarget(raw: string): string | undefined {
return stripped || undefined;
}
const matrixConfigAccessors = createScopedAccountConfigAccessors({
resolveAccount: ({ cfg, accountId }) =>
resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId }),
resolveAllowFrom: (account) => account.dm?.allowFrom,
formatAllowFrom: (allowFrom) => normalizeMatrixAllowList(allowFrom),
});
const matrixConfigBase = createScopedChannelConfigBase<ResolvedMatrixAccount, CoreConfig>({
const matrixConfigAdapter = createScopedChannelConfigAdapter<
ResolvedMatrixAccount,
ReturnType<typeof resolveMatrixAccountConfig>,
CoreConfig
>({
sectionKey: "matrix",
listAccountIds: listMatrixAccountIds,
resolveAccount: (cfg, accountId) => resolveMatrixAccount({ cfg, accountId }),
resolveAccessorAccount: ({ cfg, accountId }) =>
resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId }),
defaultAccountId: resolveDefaultMatrixAccountId,
clearBaseFields: [
"name",
@ -90,6 +88,8 @@ const matrixConfigBase = createScopedChannelConfigBase<ResolvedMatrixAccount, Co
"deviceName",
"initialSyncLimit",
],
resolveAllowFrom: (account) => account.dm?.allowFrom,
formatAllowFrom: (allowFrom) => normalizeMatrixAllowList(allowFrom),
});
const resolveMatrixDmPolicy = createScopedDmSecurityResolver<ResolvedMatrixAccount>({
@ -122,7 +122,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
reload: { configPrefixes: ["channels.matrix"] },
configSchema: buildChannelConfigSchema(MatrixConfigSchema),
config: {
...matrixConfigBase,
...matrixConfigAdapter,
isConfigured: (account) => account.configured,
describeAccount: (account) => ({
accountId: account.accountId,
@ -131,7 +131,6 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
configured: account.configured,
baseUrl: account.homeserver,
}),
...matrixConfigAccessors,
},
security: {
resolveDmPolicy: resolveMatrixDmPolicy,

View File

@ -1,7 +1,6 @@
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
import {
createScopedAccountConfigAccessors,
createScopedChannelConfigBase,
createScopedChannelConfigAdapter,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy";
@ -248,8 +247,12 @@ function formatAllowEntry(entry: string): string {
return trimmed.replace(/^(mattermost|user):/i, "").toLowerCase();
}
const mattermostConfigAccessors = createScopedAccountConfigAccessors({
resolveAccount: ({ cfg, accountId }) => resolveMattermostAccount({ cfg, accountId }),
const mattermostConfigAdapter = createScopedChannelConfigAdapter<ResolvedMattermostAccount>({
sectionKey: "mattermost",
listAccountIds: listMattermostAccountIds,
resolveAccount: (cfg, accountId) => resolveMattermostAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultMattermostAccountId,
clearBaseFields: ["botToken", "baseUrl", "name"],
resolveAllowFrom: (account: ResolvedMattermostAccount) => account.config.allowFrom,
formatAllowFrom: (allowFrom) =>
formatNormalizedAllowFromEntries({
@ -258,14 +261,6 @@ const mattermostConfigAccessors = createScopedAccountConfigAccessors({
}),
});
const mattermostConfigBase = createScopedChannelConfigBase<ResolvedMattermostAccount>({
sectionKey: "mattermost",
listAccountIds: listMattermostAccountIds,
resolveAccount: (cfg, accountId) => resolveMattermostAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultMattermostAccountId,
clearBaseFields: ["botToken", "baseUrl", "name"],
});
const resolveMattermostDmPolicy = createScopedDmSecurityResolver<ResolvedMattermostAccount>({
channelKey: "mattermost",
resolvePolicy: (account) => account.config.dmPolicy,
@ -311,7 +306,7 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
reload: { configPrefixes: ["channels.mattermost"] },
configSchema: buildChannelConfigSchema(MattermostConfigSchema),
config: {
...mattermostConfigBase,
...mattermostConfigAdapter,
isConfigured: (account) => Boolean(account.botToken && account.baseUrl),
describeAccount: (account) => ({
accountId: account.accountId,
@ -321,7 +316,6 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
botTokenSource: account.botTokenSource,
baseUrl: account.baseUrl,
}),
...mattermostConfigAccessors,
},
security: {
resolveDmPolicy: resolveMattermostDmPolicy,

View File

@ -0,0 +1,183 @@
import {
fetchMattermostChannel,
fetchMattermostUser,
sendMattermostTyping,
updateMattermostPost,
type MattermostChannel,
type MattermostClient,
type MattermostUser,
} from "./client.js";
import { buildButtonProps, type MattermostInteractionResponse } from "./interactions.js";
export type MattermostMediaKind = "image" | "audio" | "video" | "document" | "unknown";
export type MattermostMediaInfo = {
path: string;
contentType?: string;
kind: MattermostMediaKind;
};
const CHANNEL_CACHE_TTL_MS = 5 * 60_000;
const USER_CACHE_TTL_MS = 10 * 60_000;
type FetchRemoteMedia = (params: {
url: string;
requestInit?: RequestInit;
filePathHint?: string;
maxBytes: number;
ssrfPolicy?: { allowedHostnames?: string[] };
}) => Promise<{ buffer: Uint8Array; contentType?: string | null }>;
type SaveMediaBuffer = (
buffer: Uint8Array,
contentType: string | undefined,
direction: "inbound" | "outbound",
maxBytes: number,
) => Promise<{ path: string; contentType?: string | null }>;
export function createMattermostMonitorResources(params: {
accountId: string;
callbackUrl: string;
client: MattermostClient;
logger: { debug?: (...args: unknown[]) => void };
mediaMaxBytes: number;
fetchRemoteMedia: FetchRemoteMedia;
saveMediaBuffer: SaveMediaBuffer;
mediaKindFromMime: (contentType?: string) => MattermostMediaKind | null | undefined;
}) {
const {
accountId,
callbackUrl,
client,
logger,
mediaMaxBytes,
fetchRemoteMedia,
saveMediaBuffer,
mediaKindFromMime,
} = params;
const channelCache = new Map<string, { value: MattermostChannel | null; expiresAt: number }>();
const userCache = new Map<string, { value: MattermostUser | null; expiresAt: number }>();
const resolveMattermostMedia = async (
fileIds?: string[] | null,
): Promise<MattermostMediaInfo[]> => {
const ids = (fileIds ?? []).map((id) => id?.trim()).filter(Boolean);
if (ids.length === 0) {
return [];
}
const out: MattermostMediaInfo[] = [];
for (const fileId of ids) {
try {
const fetched = await fetchRemoteMedia({
url: `${client.apiBaseUrl}/files/${fileId}`,
requestInit: {
headers: {
Authorization: `Bearer ${client.token}`,
},
},
filePathHint: fileId,
maxBytes: mediaMaxBytes,
ssrfPolicy: { allowedHostnames: [new URL(client.baseUrl).hostname] },
});
const saved = await saveMediaBuffer(
Buffer.from(fetched.buffer),
fetched.contentType ?? undefined,
"inbound",
mediaMaxBytes,
);
const contentType = saved.contentType ?? fetched.contentType ?? undefined;
out.push({
path: saved.path,
contentType,
kind: mediaKindFromMime(contentType) ?? "unknown",
});
} catch (err) {
logger.debug?.(`mattermost: failed to download file ${fileId}: ${String(err)}`);
}
}
return out;
};
const sendTypingIndicator = async (channelId: string, parentId?: string) => {
await sendMattermostTyping(client, { channelId, parentId });
};
const resolveChannelInfo = async (channelId: string): Promise<MattermostChannel | null> => {
const cached = channelCache.get(channelId);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
try {
const info = await fetchMattermostChannel(client, channelId);
channelCache.set(channelId, {
value: info,
expiresAt: Date.now() + CHANNEL_CACHE_TTL_MS,
});
return info;
} catch (err) {
logger.debug?.(`mattermost: channel lookup failed: ${String(err)}`);
channelCache.set(channelId, {
value: null,
expiresAt: Date.now() + CHANNEL_CACHE_TTL_MS,
});
return null;
}
};
const resolveUserInfo = async (userId: string): Promise<MattermostUser | null> => {
const cached = userCache.get(userId);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
try {
const info = await fetchMattermostUser(client, userId);
userCache.set(userId, {
value: info,
expiresAt: Date.now() + USER_CACHE_TTL_MS,
});
return info;
} catch (err) {
logger.debug?.(`mattermost: user lookup failed: ${String(err)}`);
userCache.set(userId, {
value: null,
expiresAt: Date.now() + USER_CACHE_TTL_MS,
});
return null;
}
};
const buildModelPickerProps = (
channelId: string,
buttons: Array<unknown>,
): Record<string, unknown> | undefined =>
buildButtonProps({
callbackUrl,
accountId,
channelId,
buttons,
});
const updateModelPickerPost = async (params: {
channelId: string;
postId: string;
message: string;
buttons?: Array<unknown>;
}): Promise<MattermostInteractionResponse> => {
const props = buildModelPickerProps(params.channelId, params.buttons ?? []) ?? {
attachments: [],
};
await updateMattermostPost(client, params.postId, {
message: params.message,
props,
});
return {};
};
return {
resolveMattermostMedia,
sendTypingIndicator,
resolveChannelInfo,
resolveUserInfo,
updateModelPickerPost,
};
}

View File

@ -44,7 +44,6 @@ import {
type MattermostUser,
} from "./client.js";
import {
buildButtonProps,
computeInteractionCallbackUrl,
createMattermostInteractionHandler,
resolveInteractionCallbackPath,
@ -75,6 +74,7 @@ import {
resolveThreadSessionKeys,
} from "./monitor-helpers.js";
import { resolveOncharPrefixes, stripOncharPrefix } from "./monitor-onchar.js";
import { createMattermostMonitorResources, type MattermostMediaInfo } from "./monitor-resources.js";
import { registerMattermostMonitorSlashCommands } from "./monitor-slash.js";
import {
createMattermostConnectOnce,
@ -117,8 +117,6 @@ type MattermostReaction = {
};
const RECENT_MATTERMOST_MESSAGE_TTL_MS = 5 * 60_000;
const RECENT_MATTERMOST_MESSAGE_MAX = 2000;
const CHANNEL_CACHE_TTL_MS = 5 * 60_000;
const USER_CACHE_TTL_MS = 10 * 60_000;
function isLoopbackHost(hostname: string): boolean {
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
@ -215,12 +213,6 @@ export function resolveMattermostThreadSessionContext(params: {
parentSessionKey: threadKeys.parentSessionKey,
};
}
type MattermostMediaInfo = {
path: string;
contentType?: string;
kind: MediaKind;
};
function buildMattermostAttachmentPlaceholder(mediaList: MattermostMediaInfo[]): string {
if (mediaList.length === 0) {
return "";
@ -286,6 +278,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
baseUrl,
botUserId,
});
const slashEnabled = getSlashCommandState(account.accountId) != null;
// ─── Interactive buttons registration ──────────────────────────────────────
// Derive a stable HMAC secret from the bot token so CLI and gateway share it.
@ -536,8 +529,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
log: (msg: string) => runtime.log?.(msg),
});
const channelCache = new Map<string, { value: MattermostChannel | null; expiresAt: number }>();
const userCache = new Map<string, { value: MattermostUser | null; expiresAt: number }>();
const logger = core.logging.getChildLogger({ module: "mattermost" });
const logVerboseMessage = (message: string) => {
if (!core.logging.shouldLogVerbose()) {
@ -570,124 +561,26 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
log: (message) => logVerboseMessage(message),
});
const resolveMattermostMedia = async (
fileIds?: string[] | null,
): Promise<MattermostMediaInfo[]> => {
const ids = (fileIds ?? []).map((id) => id?.trim()).filter(Boolean);
if (ids.length === 0) {
return [];
}
const out: MattermostMediaInfo[] = [];
for (const fileId of ids) {
try {
const fetched = await core.channel.media.fetchRemoteMedia({
url: `${client.apiBaseUrl}/files/${fileId}`,
requestInit: {
headers: {
Authorization: `Bearer ${client.token}`,
},
},
filePathHint: fileId,
maxBytes: mediaMaxBytes,
// Allow fetching from the Mattermost server host (may be localhost or
// a private IP). Without this, SSRF guards block media downloads.
// Credit: #22594 (@webclerk)
ssrfPolicy: { allowedHostnames: [new URL(client.baseUrl).hostname] },
});
const saved = await core.channel.media.saveMediaBuffer(
fetched.buffer,
fetched.contentType ?? undefined,
"inbound",
mediaMaxBytes,
);
const contentType = saved.contentType ?? fetched.contentType ?? undefined;
out.push({
path: saved.path,
contentType,
kind: core.media.mediaKindFromMime(contentType) ?? "unknown",
});
} catch (err) {
logger.debug?.(`mattermost: failed to download file ${fileId}: ${String(err)}`);
}
}
return out;
};
const sendTypingIndicator = async (channelId: string, parentId?: string) => {
await sendMattermostTyping(client, { channelId, parentId });
};
const resolveChannelInfo = async (channelId: string): Promise<MattermostChannel | null> => {
const cached = channelCache.get(channelId);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
try {
const info = await fetchMattermostChannel(client, channelId);
channelCache.set(channelId, {
value: info,
expiresAt: Date.now() + CHANNEL_CACHE_TTL_MS,
});
return info;
} catch (err) {
logger.debug?.(`mattermost: channel lookup failed: ${String(err)}`);
channelCache.set(channelId, {
value: null,
expiresAt: Date.now() + CHANNEL_CACHE_TTL_MS,
});
return null;
}
};
const resolveUserInfo = async (userId: string): Promise<MattermostUser | null> => {
const cached = userCache.get(userId);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
try {
const info = await fetchMattermostUser(client, userId);
userCache.set(userId, {
value: info,
expiresAt: Date.now() + USER_CACHE_TTL_MS,
});
return info;
} catch (err) {
logger.debug?.(`mattermost: user lookup failed: ${String(err)}`);
userCache.set(userId, {
value: null,
expiresAt: Date.now() + USER_CACHE_TTL_MS,
});
return null;
}
};
const buildModelPickerProps = (
channelId: string,
buttons: Array<unknown>,
): Record<string, unknown> | undefined =>
buildButtonProps({
callbackUrl,
const {
resolveMattermostMedia,
sendTypingIndicator,
resolveChannelInfo,
resolveUserInfo,
updateModelPickerPost,
} = createMattermostMonitorResources({
accountId: account.accountId,
channelId,
buttons,
callbackUrl,
client,
logger: {
debug: (message) => logger.debug?.(String(message)),
},
mediaMaxBytes,
fetchRemoteMedia: (params) => core.channel.media.fetchRemoteMedia(params),
saveMediaBuffer: (buffer, contentType, direction, maxBytes) =>
core.channel.media.saveMediaBuffer(Buffer.from(buffer), contentType, direction, maxBytes),
mediaKindFromMime: (contentType) => core.media.mediaKindFromMime(contentType) as MediaKind,
});
const updateModelPickerPost = async (params: {
channelId: string;
postId: string;
message: string;
buttons?: Array<unknown>;
}): Promise<MattermostInteractionResponse> => {
const props = buildModelPickerProps(params.channelId, params.buttons ?? []) ?? {
attachments: [],
};
await updateMattermostPost(client, params.postId, {
message: params.message,
props,
});
return {};
};
const runModelPickerCommand = async (params: {
commandText: string;
commandAuthorized: boolean;

View File

@ -1,6 +1,6 @@
import { definePluginEntry } from "openclaw/plugin-sdk/core";
import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth";
import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog";
import {
createMoonshotThinkingWrapper,
resolveMoonshotThinkingType,

View File

@ -32,9 +32,40 @@
"cliDescription": "Moonshot API key"
}
],
"uiHints": {
"webSearch.apiKey": {
"label": "Kimi Search API Key",
"help": "Moonshot/Kimi API key (fallback: KIMI_API_KEY or MOONSHOT_API_KEY env var).",
"sensitive": true
},
"webSearch.baseUrl": {
"label": "Kimi Search Base URL",
"help": "Kimi base URL override."
},
"webSearch.model": {
"label": "Kimi Search Model",
"help": "Kimi model override."
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
"properties": {
"webSearch": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": ["string", "object"]
},
"baseUrl": {
"type": "string"
},
"model": {
"type": "string"
}
}
}
}
}
}

View File

@ -14,6 +14,11 @@ import {
withTrustedWebSearchEndpoint,
writeCachedSearchPayload,
} from "../../../src/agents/tools/web-search-provider-common.js";
import {
resolveProviderWebSearchPluginConfig,
setProviderWebSearchPluginConfigValue,
} from "../../../src/agents/tools/web-search-provider-config.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type {
WebSearchProviderPlugin,
WebSearchProviderToolDefinition,
@ -61,14 +66,18 @@ type KimiSearchResponse = {
}>;
};
function resolveKimiConfig(searchConfig?: SearchConfigRecord): KimiConfig {
const kimi = searchConfig?.kimi;
function resolveKimiConfig(config?: OpenClawConfig, searchConfig?: SearchConfigRecord): KimiConfig {
const pluginConfig = resolveProviderWebSearchPluginConfig(config, "moonshot");
if (pluginConfig) {
return pluginConfig as KimiConfig;
}
const kimi = (searchConfig as Record<string, unknown> | undefined)?.kimi;
return kimi && typeof kimi === "object" && !Array.isArray(kimi) ? (kimi as KimiConfig) : {};
}
function resolveKimiApiKey(kimi?: KimiConfig): string | undefined {
return (
readConfiguredSecretString(kimi?.apiKey, "tools.web.search.kimi.apiKey") ??
readConfiguredSecretString(kimi?.apiKey, "plugins.entries.moonshot.config.webSearch.apiKey") ??
readProviderEnvValue(["KIMI_API_KEY", "MOONSHOT_API_KEY"])
);
}
@ -237,6 +246,7 @@ function createKimiSchema() {
}
function createKimiToolDefinition(
config?: OpenClawConfig,
searchConfig?: SearchConfigRecord,
): WebSearchProviderToolDefinition {
return {
@ -263,13 +273,13 @@ function createKimiToolDefinition(
}
}
const kimiConfig = resolveKimiConfig(searchConfig);
const kimiConfig = resolveKimiConfig(config, searchConfig);
const apiKey = resolveKimiApiKey(kimiConfig);
if (!apiKey) {
return {
error: "missing_kimi_api_key",
message:
"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.",
"web_search (kimi) needs a Moonshot API key. Set KIMI_API_KEY or MOONSHOT_API_KEY in the Gateway environment, or configure plugins.entries.moonshot.config.webSearch.apiKey.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
@ -331,8 +341,8 @@ export function createKimiWebSearchProvider(): WebSearchProviderPlugin {
signupUrl: "https://platform.moonshot.cn/",
docsUrl: "https://docs.openclaw.ai/tools/web",
autoDetectOrder: 40,
credentialPath: "tools.web.search.kimi.apiKey",
inactiveSecretPaths: ["tools.web.search.kimi.apiKey"],
credentialPath: "plugins.entries.moonshot.config.webSearch.apiKey",
inactiveSecretPaths: ["plugins.entries.moonshot.config.webSearch.apiKey"],
getCredentialValue: (searchConfig) => {
const kimi = searchConfig?.kimi;
return kimi && typeof kimi === "object" && !Array.isArray(kimi)
@ -347,8 +357,13 @@ export function createKimiWebSearchProvider(): WebSearchProviderPlugin {
}
(scoped as Record<string, unknown>).apiKey = value;
},
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "moonshot")?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
setProviderWebSearchPluginConfigValue(configTarget, "moonshot", "apiKey", value);
},
createTool: (ctx) =>
createKimiToolDefinition(ctx.searchConfig as SearchConfigRecord | undefined),
createKimiToolDefinition(ctx.config, ctx.searchConfig as SearchConfigRecord | undefined),
};
}

View File

@ -1,8 +1,5 @@
import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
import {
createScopedAccountConfigAccessors,
createTopLevelChannelConfigBase,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { createTopLevelChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers";
import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy";
import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime";
import type {
@ -73,20 +70,20 @@ const resolveMSTeamsChannelConfig = (cfg: OpenClawConfig) => ({
defaultTo: cfg.channels?.msteams?.defaultTo,
});
const msteamsConfigBase = createTopLevelChannelConfigBase<ResolvedMSTeamsAccount>({
const msteamsConfigAdapter = createTopLevelChannelConfigAdapter<
ResolvedMSTeamsAccount,
{
allowFrom?: Array<string | number>;
defaultTo?: string;
}
>({
sectionKey: "msteams",
resolveAccount: (cfg) => ({
accountId: DEFAULT_ACCOUNT_ID,
enabled: cfg.channels?.msteams?.enabled !== false,
configured: Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)),
}),
});
const msteamsConfigAccessors = createScopedAccountConfigAccessors<{
allowFrom?: Array<string | number>;
defaultTo?: string;
}>({
resolveAccount: ({ cfg }) => resolveMSTeamsChannelConfig(cfg),
resolveAccessorAccount: ({ cfg }) => resolveMSTeamsChannelConfig(cfg),
resolveAllowFrom: (account) => account.allowFrom,
formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }),
resolveDefaultTo: (account) => account.defaultTo,
@ -157,14 +154,13 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
reload: { configPrefixes: ["channels.msteams"] },
configSchema: buildChannelConfigSchema(MSTeamsConfigSchema),
config: {
...msteamsConfigBase,
...msteamsConfigAdapter,
isConfigured: (_account, cfg) => Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)),
describeAccount: (account) => ({
accountId: account.accountId,
enabled: account.enabled,
configured: account.configured,
}),
...msteamsConfigAccessors,
},
security: {
collectWarnings: ({ cfg }) => {

View File

@ -1,7 +1,6 @@
import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
import {
createScopedAccountConfigAccessors,
createScopedChannelConfigBase,
createScopedChannelConfigAdapter,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
@ -51,19 +50,8 @@ const meta = {
quickstartAllowFrom: true,
};
const nextcloudTalkConfigAccessors =
createScopedAccountConfigAccessors<ResolvedNextcloudTalkAccount>({
resolveAccount: ({ cfg, accountId }) =>
resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }),
resolveAllowFrom: (account) => account.config.allowFrom,
formatAllowFrom: (allowFrom) =>
formatAllowFromLowercase({
allowFrom,
stripPrefixRe: /^(nextcloud-talk|nc-talk|nc):/i,
}),
});
const nextcloudTalkConfigBase = createScopedChannelConfigBase<
const nextcloudTalkConfigAdapter = createScopedChannelConfigAdapter<
ResolvedNextcloudTalkAccount,
ResolvedNextcloudTalkAccount,
CoreConfig
>({
@ -72,6 +60,12 @@ const nextcloudTalkConfigBase = createScopedChannelConfigBase<
resolveAccount: (cfg, accountId) => resolveNextcloudTalkAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultNextcloudTalkAccountId,
clearBaseFields: ["botSecret", "botSecretFile", "baseUrl", "name"],
resolveAllowFrom: (account) => account.config.allowFrom,
formatAllowFrom: (allowFrom) =>
formatAllowFromLowercase({
allowFrom,
stripPrefixRe: /^(nextcloud-talk|nc-talk|nc):/i,
}),
});
const resolveNextcloudTalkDmPolicy = createScopedDmSecurityResolver<ResolvedNextcloudTalkAccount>({
@ -105,7 +99,7 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
reload: { configPrefixes: ["channels.nextcloud-talk"] },
configSchema: buildChannelConfigSchema(NextcloudTalkConfigSchema),
config: {
...nextcloudTalkConfigBase,
...nextcloudTalkConfigAdapter,
isConfigured: (account) => Boolean(account.secret?.trim() && account.baseUrl?.trim()),
describeAccount: (account) => ({
accountId: account.accountId,
@ -115,7 +109,6 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
secretSource: account.secretSource,
baseUrl: account.baseUrl ? "[set]" : "[missing]",
}),
...nextcloudTalkConfigAccessors,
},
security: {
resolveDmPolicy: resolveNextcloudTalkDmPolicy,

View File

@ -1,11 +1,13 @@
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
import {
createScopedDmSecurityResolver,
createTopLevelChannelConfigAdapter,
} from "openclaw/plugin-sdk/channel-config-helpers";
import {
buildChannelConfigSchema,
collectStatusIssuesFromLastError,
createDefaultChannelRuntimeState,
DEFAULT_ACCOUNT_ID,
formatPairingApproveHint,
mapAllowFromEntries,
type ChannelPlugin,
} from "openclaw/plugin-sdk/nostr";
import {
@ -49,6 +51,39 @@ const resolveNostrDmPolicy = createScopedDmSecurityResolver<ResolvedNostrAccount
},
});
const nostrConfigAdapter = createTopLevelChannelConfigAdapter<ResolvedNostrAccount>({
sectionKey: "nostr",
resolveAccount: (cfg) => resolveNostrAccount({ cfg }),
listAccountIds: listNostrAccountIds,
defaultAccountId: resolveDefaultNostrAccountId,
deleteMode: "clear-fields",
clearBaseFields: [
"name",
"defaultAccount",
"privateKey",
"relays",
"dmPolicy",
"allowFrom",
"profile",
],
resolveAllowFrom: (account) => account.config.allowFrom,
formatAllowFrom: (allowFrom) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => {
if (entry === "*") {
return "*";
}
try {
return normalizePubkey(entry);
} catch {
return entry;
}
})
.filter(Boolean),
});
export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
id: "nostr",
meta: {
@ -70,9 +105,7 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
setupWizard: nostrSetupWizard,
config: {
listAccountIds: (cfg) => listNostrAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveNostrAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultNostrAccountId(cfg),
...nostrConfigAdapter,
isConfigured: (account) => account.configured,
describeAccount: (account) => ({
accountId: account.accountId,
@ -81,23 +114,6 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
configured: account.configured,
publicKey: account.publicKey,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
mapAllowFromEntries(resolveNostrAccount({ cfg, accountId }).config.allowFrom),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => {
if (entry === "*") {
return "*";
}
try {
return normalizePubkey(entry);
} catch {
return entry; // Keep as-is if normalization fails
}
})
.filter(Boolean),
},
pairing: {

View File

@ -18,6 +18,7 @@ import {
normalizeProviderId,
type ProviderPlugin,
} from "openclaw/plugin-sdk/provider-models";
import { createOpenAIAttributionHeadersWrapper } from "openclaw/plugin-sdk/provider-stream";
import { fetchCodexUsage } from "openclaw/plugin-sdk/provider-usage";
import { buildOpenAICodexProvider } from "./openai-codex-catalog.js";
import {
@ -248,6 +249,7 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin {
transport: "auto",
};
},
wrapStreamFn: (ctx) => createOpenAIAttributionHeadersWrapper(ctx.streamFn),
normalizeResolvedModel: (ctx) => {
if (normalizeProviderId(ctx.provider) !== PROVIDER_ID) {
return undefined;

View File

@ -11,6 +11,10 @@ import {
OPENAI_DEFAULT_MODEL,
type ProviderPlugin,
} from "openclaw/plugin-sdk/provider-models";
import {
createOpenAIAttributionHeadersWrapper,
createOpenAIDefaultTransportWrapper,
} from "openclaw/plugin-sdk/provider-stream";
import {
cloneFirstTemplateModel,
findCatalogTemplate,
@ -169,6 +173,8 @@ export function buildOpenAIProvider(): ProviderPlugin {
capabilities: {
providerFamily: "openai",
},
wrapStreamFn: (ctx) =>
createOpenAIAttributionHeadersWrapper(createOpenAIDefaultTransportWrapper(ctx.streamFn)),
supportsXHighThinking: ({ modelId }) => matchesExactOrPrefix(modelId, OPENAI_XHIGH_MODEL_IDS),
isModernModelRef: ({ modelId }) => matchesExactOrPrefix(modelId, OPENAI_MODERN_MODEL_IDS),
buildMissingAuthMessage: (ctx) => {

View File

@ -5,7 +5,7 @@ import {
type ProviderRuntimeModel,
} from "openclaw/plugin-sdk/core";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth";
import { DEFAULT_CONTEXT_TOKENS } from "openclaw/plugin-sdk/provider-models";
import { applyXaiModelCompat, DEFAULT_CONTEXT_TOKENS } from "openclaw/plugin-sdk/provider-models";
import {
getOpenRouterModelCapabilities,
loadOpenRouterModelCapabilities,
@ -73,6 +73,10 @@ function isOpenRouterCacheTtlModel(modelId: string): boolean {
return OPENROUTER_CACHE_TTL_MODEL_PREFIXES.some((prefix) => modelId.startsWith(prefix));
}
function isXaiOpenRouterModel(modelId: string): boolean {
return modelId.trim().toLowerCase().startsWith("x-ai/");
}
export default definePluginEntry({
id: "openrouter",
name: "OpenRouter Provider",
@ -129,6 +133,8 @@ export default definePluginEntry({
geminiThoughtSignatureSanitization: true,
geminiThoughtSignatureModelHints: ["gemini"],
},
normalizeResolvedModel: ({ modelId, model }) =>
isXaiOpenRouterModel(modelId) ? applyXaiModelCompat(model) : undefined,
isModernModelRef: () => true,
wrapStreamFn: (ctx) => {
let streamFn = ctx.streamFn;

View File

@ -1,8 +1,40 @@
{
"id": "perplexity",
"uiHints": {
"webSearch.apiKey": {
"label": "Perplexity API Key",
"help": "Perplexity or OpenRouter API key for web search.",
"sensitive": true,
"placeholder": "pplx-..."
},
"webSearch.baseUrl": {
"label": "Perplexity Base URL",
"help": "Optional Perplexity/OpenRouter chat-completions base URL override."
},
"webSearch.model": {
"label": "Perplexity Model",
"help": "Optional Sonar/OpenRouter model override."
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
"properties": {
"webSearch": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": ["string", "object"]
},
"baseUrl": {
"type": "string"
},
"model": {
"type": "string"
}
}
}
}
}
}

View File

@ -23,6 +23,11 @@ import {
withTrustedWebSearchEndpoint,
writeCachedSearchPayload,
} from "../../../src/agents/tools/web-search-provider-common.js";
import {
resolveProviderWebSearchPluginConfig,
setProviderWebSearchPluginConfigValue,
} from "../../../src/agents/tools/web-search-provider-config.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type {
WebSearchCredentialResolutionSource,
WebSearchProviderPlugin,
@ -71,8 +76,15 @@ type PerplexitySearchApiResponse = {
}>;
};
function resolvePerplexityConfig(searchConfig?: SearchConfigRecord): PerplexityConfig {
const perplexity = searchConfig?.perplexity;
function resolvePerplexityConfig(
config?: OpenClawConfig,
searchConfig?: SearchConfigRecord,
): PerplexityConfig {
const pluginConfig = resolveProviderWebSearchPluginConfig(config, "perplexity");
if (pluginConfig) {
return pluginConfig as PerplexityConfig;
}
const perplexity = (searchConfig as Record<string, unknown> | undefined)?.perplexity;
return perplexity && typeof perplexity === "object" && !Array.isArray(perplexity)
? (perplexity as PerplexityConfig)
: {};
@ -98,7 +110,7 @@ function resolvePerplexityApiKey(perplexity?: PerplexityConfig): {
} {
const fromConfig = readConfiguredSecretString(
perplexity?.apiKey,
"tools.web.search.perplexity.apiKey",
"plugins.entries.perplexity.config.webSearch.apiKey",
);
if (fromConfig) {
return { apiKey: fromConfig, source: "config" };
@ -313,16 +325,16 @@ async function runPerplexitySearch(params: {
}
function resolveRuntimeTransport(params: {
config?: OpenClawConfig;
searchConfig?: Record<string, unknown>;
resolvedKey?: string;
keySource: WebSearchCredentialResolutionSource;
fallbackEnvVar?: string;
}): PerplexityTransport | undefined {
const perplexity = params.searchConfig?.perplexity;
const scoped =
perplexity && typeof perplexity === "object" && !Array.isArray(perplexity)
? (perplexity as { baseUrl?: string; model?: string })
: undefined;
const scoped = resolvePerplexityConfig(
params.config,
params.searchConfig as SearchConfigRecord | undefined,
);
const configuredBaseUrl = typeof scoped?.baseUrl === "string" ? scoped.baseUrl.trim() : "";
const configuredModel = typeof scoped?.model === "string" ? scoped.model.trim() : "";
const baseUrl = (() => {
@ -404,10 +416,11 @@ function createPerplexitySchema(transport?: PerplexityTransport) {
}
function createPerplexityToolDefinition(
config?: OpenClawConfig,
searchConfig?: SearchConfigRecord,
runtimeTransport?: PerplexityTransport,
): WebSearchProviderToolDefinition {
const perplexityConfig = resolvePerplexityConfig(searchConfig);
const perplexityConfig = resolvePerplexityConfig(config, searchConfig);
const schemaTransport =
runtimeTransport ??
(perplexityConfig.baseUrl || perplexityConfig.model ? "chat_completions" : undefined);
@ -424,7 +437,7 @@ function createPerplexityToolDefinition(
return {
error: "missing_perplexity_api_key",
message:
"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.",
"web_search (perplexity) needs an API key. Set PERPLEXITY_API_KEY or OPENROUTER_API_KEY in the Gateway environment, or configure plugins.entries.perplexity.config.webSearch.apiKey.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
@ -656,8 +669,8 @@ export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin {
signupUrl: "https://www.perplexity.ai/settings/api",
docsUrl: "https://docs.openclaw.ai/perplexity",
autoDetectOrder: 50,
credentialPath: "tools.web.search.perplexity.apiKey",
inactiveSecretPaths: ["tools.web.search.perplexity.apiKey"],
credentialPath: "plugins.entries.perplexity.config.webSearch.apiKey",
inactiveSecretPaths: ["plugins.entries.perplexity.config.webSearch.apiKey"],
getCredentialValue: (searchConfig) => {
const perplexity = searchConfig?.perplexity;
return perplexity && typeof perplexity === "object" && !Array.isArray(perplexity)
@ -672,8 +685,14 @@ export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin {
}
(scoped as Record<string, unknown>).apiKey = value;
},
getConfiguredCredentialValue: (config) =>
resolveProviderWebSearchPluginConfig(config, "perplexity")?.apiKey,
setConfiguredCredentialValue: (configTarget, value) => {
setProviderWebSearchPluginConfigValue(configTarget, "perplexity", "apiKey", value);
},
resolveRuntimeMetadata: (ctx) => ({
perplexityTransport: resolveRuntimeTransport({
config: ctx.config,
searchConfig: ctx.searchConfig,
resolvedKey: ctx.resolvedCredential?.value,
keySource: ctx.resolvedCredential?.source ?? "missing",
@ -682,6 +701,7 @@ export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin {
}),
createTool: (ctx) =>
createPerplexityToolDefinition(
ctx.config,
ctx.searchConfig as SearchConfigRecord | undefined,
ctx.runtimeMetadata?.perplexityTransport as PerplexityTransport | undefined,
),

View File

@ -1,2 +1 @@
export * from "openclaw/plugin-sdk/signal";
export * from "openclaw/plugin-sdk/signal-core";
export * from "./src/index.js";

View File

@ -16,7 +16,7 @@ import {
PAIRING_APPROVED_MESSAGE,
resolveChannelMediaMaxBytes,
type ChannelPlugin,
} from "../runtime-api.js";
} from "openclaw/plugin-sdk/signal";
import { resolveSignalAccount, type ResolvedSignalAccount } from "./accounts.js";
import { markdownToSignalTextChunks } from "./format.js";
import {
@ -31,8 +31,8 @@ import { getSignalRuntime } from "./runtime.js";
import { signalSetupAdapter } from "./setup-core.js";
import {
collectSignalSecurityWarnings,
signalConfigAdapter,
createSignalPluginBase,
signalConfigAccessors,
signalResolveDmPolicy,
signalSetupWizard,
} from "./shared.js";
@ -290,7 +290,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
applyConfigEdit: buildAccountScopedAllowlistConfigEditor({
channelId: "signal",
normalize: ({ cfg, accountId, values }) =>
signalConfigAccessors.formatAllowFrom!({ cfg, accountId, allowFrom: values }),
signalConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }),
resolvePaths: (scope) => ({
readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]],
writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"],

View File

@ -1,7 +1,6 @@
import {
collectAllowlistProviderRestrictSendersWarnings,
createScopedAccountConfigAccessors,
createScopedChannelConfigBase,
createScopedChannelConfigAdapter,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { createChannelPluginBase } from "openclaw/plugin-sdk/core";
@ -11,7 +10,7 @@ import {
normalizeE164,
SignalConfigSchema,
type ChannelPlugin,
} from "../runtime-api.js";
} from "openclaw/plugin-sdk/signal";
import {
listSignalAccountIds,
resolveDefaultSignalAccountId,
@ -30,8 +29,12 @@ export const signalSetupWizard = createSignalSetupWizardProxy(
async () => (await loadSignalChannelRuntime()).signalSetupWizard,
);
export const signalConfigAccessors = createScopedAccountConfigAccessors({
resolveAccount: ({ cfg, accountId }) => resolveSignalAccount({ cfg, accountId }),
export const signalConfigAdapter = createScopedChannelConfigAdapter<ResolvedSignalAccount>({
sectionKey: SIGNAL_CHANNEL,
listAccountIds: listSignalAccountIds,
resolveAccount: (cfg, accountId) => resolveSignalAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultSignalAccountId,
clearBaseFields: ["account", "httpUrl", "httpHost", "httpPort", "cliPath", "name"],
resolveAllowFrom: (account: ResolvedSignalAccount) => account.config.allowFrom,
formatAllowFrom: (allowFrom) =>
allowFrom
@ -42,14 +45,6 @@ export const signalConfigAccessors = createScopedAccountConfigAccessors({
resolveDefaultTo: (account: ResolvedSignalAccount) => account.config.defaultTo,
});
export const signalConfigBase = createScopedChannelConfigBase<ResolvedSignalAccount>({
sectionKey: SIGNAL_CHANNEL,
listAccountIds: listSignalAccountIds,
resolveAccount: (cfg, accountId) => resolveSignalAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultSignalAccountId,
clearBaseFields: ["account", "httpUrl", "httpHost", "httpPort", "cliPath", "name"],
});
export const signalResolveDmPolicy = createScopedDmSecurityResolver<ResolvedSignalAccount>({
channelKey: SIGNAL_CHANNEL,
resolvePolicy: (account) => account.config.dmPolicy,
@ -107,7 +102,7 @@ export function createSignalPluginBase(params: {
reload: { configPrefixes: ["channels.signal"] },
configSchema: buildChannelConfigSchema(SignalConfigSchema),
config: {
...signalConfigBase,
...signalConfigAdapter,
isConfigured: (account) => account.configured,
describeAccount: (account) => ({
accountId: account.accountId,
@ -116,7 +111,6 @@ export function createSignalPluginBase(params: {
configured: account.configured,
baseUrl: account.baseUrl,
}),
...signalConfigAccessors,
},
security: {
resolveDmPolicy: signalResolveDmPolicy,

View File

@ -7,13 +7,13 @@ import {
hasConfiguredSecretInput,
normalizeSecretInputString,
} from "openclaw/plugin-sdk/config-runtime";
import type { SlackAccountConfig } from "openclaw/plugin-sdk/slack";
import type { SlackAccountSurfaceFields } from "./account-surface-fields.js";
import {
mergeSlackAccountConfig,
resolveDefaultSlackAccountId,
type SlackTokenSource,
} from "./accounts.js";
import type { SlackAccountConfig } from "./runtime-api.js";
export type SlackCredentialStatus = "available" | "configured_unavailable" | "missing";

View File

@ -6,8 +6,8 @@ import {
resolveAccountEntry,
type OpenClawConfig,
} from "openclaw/plugin-sdk/account-resolution";
import type { SlackAccountConfig } from "openclaw/plugin-sdk/slack";
import type { SlackAccountSurfaceFields } from "./account-surface-fields.js";
import type { SlackAccountConfig } from "./runtime-api.js";
import { resolveSlackAppToken, resolveSlackBotToken, resolveSlackUserToken } from "./token.js";
export type SlackTokenSource = "env" | "config" | "none";

View File

@ -1,14 +1,4 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import {
createActionGate,
imageResultFromFile,
jsonResult,
readNumberParam,
readReactionParams,
readStringParam,
type OpenClawConfig,
withNormalizedTimestamp,
} from "openclaw/plugin-sdk/slack-core";
import { resolveSlackAccount } from "./accounts.js";
import {
deleteSlackMessage,
@ -27,6 +17,16 @@ import {
unpinSlackMessage,
} from "./actions.js";
import { parseSlackBlocksInput } from "./blocks-input.js";
import {
createActionGate,
imageResultFromFile,
jsonResult,
readNumberParam,
readReactionParams,
readStringParam,
type OpenClawConfig,
withNormalizedTimestamp,
} from "./runtime-api.js";
import { recordSlackThreadParticipation } from "./sent-thread-cache.js";
import { parseSlackTarget, resolveSlackChannelId } from "./targets.js";

View File

@ -4,11 +4,11 @@ import {
type ChannelMessageActionAdapter,
type ChannelMessageToolDiscovery,
} from "openclaw/plugin-sdk/channel-runtime";
import { isSlackInteractiveRepliesEnabled } from "openclaw/plugin-sdk/slack";
import type { SlackActionContext } from "./action-runtime.js";
import { handleSlackAction } from "./action-runtime.js";
import { handleSlackMessageAction } from "./message-action-dispatch.js";
import { extractSlackToolSend, listSlackMessageActions } from "./message-actions.js";
import { isSlackInteractiveRepliesEnabled } from "./runtime-api.js";
import { resolveSlackChannelId } from "./targets.js";
type SlackActionInvoke = (

View File

@ -1,5 +1,5 @@
import { type ChannelPlugin } from "openclaw/plugin-sdk/slack";
import { type ResolvedSlackAccount } from "./accounts.js";
import { type ChannelPlugin } from "./runtime-api.js";
import { slackSetupAdapter } from "./setup-core.js";
import { slackSetupWizard } from "./setup-surface.js";
import { createSlackPluginBase } from "./shared.js";

View File

@ -10,20 +10,6 @@ import {
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
import { buildOutboundBaseSessionKey, normalizeOutboundThreadId } from "openclaw/plugin-sdk/core";
import { resolveThreadSessionKeys, type RoutePeer } from "openclaw/plugin-sdk/routing";
import {
buildComputedAccountStatusSnapshot,
DEFAULT_ACCOUNT_ID,
listSlackDirectoryGroupsFromConfig,
listSlackDirectoryPeersFromConfig,
looksLikeSlackTargetId,
normalizeSlackMessagingTarget,
PAIRING_APPROVED_MESSAGE,
projectCredentialSnapshotFields,
resolveConfiguredFromRequiredCredentialStatuses,
type ChannelPlugin,
type OpenClawConfig,
type SlackActionContext,
} from "openclaw/plugin-sdk/slack";
import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js";
import {
listEnabledSlackAccounts,
@ -31,6 +17,7 @@ import {
resolveSlackReplyToMode,
type ResolvedSlackAccount,
} from "./accounts.js";
import type { SlackActionContext } from "./action-runtime.js";
import { parseSlackBlocksInput } from "./blocks-input.js";
import { createSlackActions } from "./channel-actions.js";
import { createSlackWebClient } from "./client.js";
@ -39,6 +26,17 @@ import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js";
import { normalizeAllowListLower } from "./monitor/allow-list.js";
import type { SlackProbe } from "./probe.js";
import { resolveSlackUserAllowlist } from "./resolve-users.js";
import {
buildComputedAccountStatusSnapshot,
DEFAULT_ACCOUNT_ID,
looksLikeSlackTargetId,
normalizeSlackMessagingTarget,
PAIRING_APPROVED_MESSAGE,
projectCredentialSnapshotFields,
resolveConfiguredFromRequiredCredentialStatuses,
type ChannelPlugin,
type OpenClawConfig,
} from "./runtime-api.js";
import { getSlackRuntime } from "./runtime.js";
import { fetchSlackScopes } from "./scopes.js";
import { slackSetupAdapter } from "./setup-core.js";
@ -46,7 +44,7 @@ import { slackSetupWizard } from "./setup-surface.js";
import {
createSlackPluginBase,
isSlackPluginAccountConfigured,
slackConfigAccessors,
slackConfigAdapter,
SLACK_CHANNEL,
} from "./shared.js";
import { parseSlackTarget } from "./targets.js";
@ -354,7 +352,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
applyConfigEdit: buildAccountScopedAllowlistConfigEditor({
channelId: "slack",
normalize: ({ cfg, accountId, values }) =>
slackConfigAccessors.formatAllowFrom!({ cfg, accountId, allowFrom: values }),
slackConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }),
resolvePaths: resolveLegacyDmAllowlistConfigPaths,
}),
},

View File

@ -0,0 +1,60 @@
import {
applyDirectoryQueryAndLimit,
collectNormalizedDirectoryIds,
listDirectoryGroupEntriesFromMapKeys,
toDirectoryEntries,
type DirectoryConfigParams,
} from "openclaw/plugin-sdk/directory-runtime";
import { normalizeSlackMessagingTarget } from "../../../src/channels/plugins/normalize/slack.js";
import { inspectReadOnlyChannelAccount } from "../../../src/channels/read-only-account-inspect.js";
import type { InspectedSlackAccount } from "../../../src/channels/read-only-account-inspect.slack.runtime.js";
export async function listSlackDirectoryPeersFromConfig(params: DirectoryConfigParams) {
const account = (await inspectReadOnlyChannelAccount({
channelId: "slack",
cfg: params.cfg,
accountId: params.accountId,
})) as InspectedSlackAccount | null;
if (!account || !("config" in account)) {
return [];
}
const allowFrom = account.config.allowFrom ?? account.dm?.allowFrom ?? [];
const channelUsers = Object.values(account.config.channels ?? {}).flatMap(
(channel) => channel.users ?? [],
);
const ids = collectNormalizedDirectoryIds({
sources: [allowFrom, Object.keys(account.config.dms ?? {}), channelUsers],
normalizeId: (raw) => {
const mention = raw.match(/^<@([A-Z0-9]+)>$/i);
const normalizedUserId = (mention?.[1] ?? raw).replace(/^(slack|user):/i, "").trim();
if (!normalizedUserId) {
return null;
}
const target = `user:${normalizedUserId}`;
const normalized = normalizeSlackMessagingTarget(target) ?? target.toLowerCase();
return normalized.startsWith("user:") ? normalized : null;
},
});
return toDirectoryEntries("user", applyDirectoryQueryAndLimit(ids, params));
}
export async function listSlackDirectoryGroupsFromConfig(params: DirectoryConfigParams) {
const account = (await inspectReadOnlyChannelAccount({
channelId: "slack",
cfg: params.cfg,
accountId: params.accountId,
})) as InspectedSlackAccount | null;
if (!account || !("config" in account)) {
return [];
}
return listDirectoryGroupEntriesFromMapKeys({
groups: account.config.channels,
query: params.query,
limit: params.limit,
normalizeId: (raw) => {
const normalized = normalizeSlackMessagingTarget(raw) ?? raw.toLowerCase();
return normalized.startsWith("channel:") ? normalized : null;
},
});
}

View File

@ -3,9 +3,9 @@ import {
normalizeInteractiveReply,
type ChannelMessageActionContext,
} from "openclaw/plugin-sdk/channel-runtime";
import { readNumberParam, readStringParam } from "openclaw/plugin-sdk/slack-core";
import { parseSlackBlocksInput } from "./blocks-input.js";
import { buildSlackInteractiveBlocks } from "./blocks-render.js";
import { readNumberParam, readStringParam } from "./runtime-api.js";
type SlackActionInvoke = (
action: Record<string, unknown>,

View File

@ -0,0 +1,34 @@
export type { OpenClawConfig } from "../../../src/config/config.js";
export type { SlackAccountConfig } from "../../../src/config/types.slack.js";
export type { ChannelPlugin } from "../../../src/channels/plugins/types.js";
export {
DEFAULT_ACCOUNT_ID,
buildChannelConfigSchema,
getChatChannelMeta,
PAIRING_APPROVED_MESSAGE,
} from "../../../src/plugin-sdk/channel-plugin-common.js";
export { buildComputedAccountStatusSnapshot } from "../../../src/plugin-sdk/status-helpers.js";
export {
listSlackDirectoryGroupsFromConfig,
listSlackDirectoryPeersFromConfig,
} from "./directory-config.js";
export {
looksLikeSlackTargetId,
normalizeSlackMessagingTarget,
} from "../../../src/channels/plugins/normalize/slack.js";
export {
projectCredentialSnapshotFields,
resolveConfiguredFromRequiredCredentialStatuses,
} from "../../../src/channels/account-snapshot-fields.js";
export { SlackConfigSchema } from "../../../src/config/zod-schema.providers-core.js";
export {
createActionGate,
imageResultFromFile,
jsonResult,
readNumberParam,
readReactionParams,
readStringParam,
} from "../../../src/agents/tools/common.js";
export { withNormalizedTimestamp } from "../../../src/agents/date-time.js";
export { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js";

View File

@ -1,21 +1,11 @@
import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
import {
createScopedAccountConfigAccessors,
createScopedChannelConfigBase,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { createScopedChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers";
import { createChannelPluginBase } from "openclaw/plugin-sdk/core";
import {
formatDocsLink,
hasConfiguredSecretInput,
patchChannelConfigForAccount,
} from "openclaw/plugin-sdk/setup";
import {
buildChannelConfigSchema,
getChatChannelMeta,
SlackConfigSchema,
type ChannelPlugin,
type OpenClawConfig,
} from "openclaw/plugin-sdk/slack-core";
import { inspectSlackAccount } from "./account-inspect.js";
import {
listSlackAccountIds,
@ -24,6 +14,13 @@ import {
type ResolvedSlackAccount,
} from "./accounts.js";
import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js";
import {
buildChannelConfigSchema,
getChatChannelMeta,
SlackConfigSchema,
type ChannelPlugin,
type OpenClawConfig,
} from "./runtime-api.js";
export const SLACK_CHANNEL = "slack" as const;
@ -145,20 +142,16 @@ export function isSlackSetupAccountConfigured(account: ResolvedSlackAccount): bo
return hasConfiguredBotToken && hasConfiguredAppToken;
}
export const slackConfigAccessors = createScopedAccountConfigAccessors({
resolveAccount: ({ cfg, accountId }) => resolveSlackAccount({ cfg, accountId }),
resolveAllowFrom: (account: ResolvedSlackAccount) => account.dm?.allowFrom,
formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }),
resolveDefaultTo: (account: ResolvedSlackAccount) => account.config.defaultTo,
});
export const slackConfigBase = createScopedChannelConfigBase({
export const slackConfigAdapter = createScopedChannelConfigAdapter<ResolvedSlackAccount>({
sectionKey: SLACK_CHANNEL,
listAccountIds: listSlackAccountIds,
resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }),
inspectAccount: (cfg, accountId) => inspectSlackAccount({ cfg, accountId }),
defaultAccountId: resolveDefaultSlackAccountId,
clearBaseFields: ["botToken", "appToken", "name"],
resolveAllowFrom: (account: ResolvedSlackAccount) => account.dm?.allowFrom,
formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }),
resolveDefaultTo: (account: ResolvedSlackAccount) => account.config.defaultTo,
});
export function createSlackPluginBase(params: {
@ -208,7 +201,7 @@ export function createSlackPluginBase(params: {
reload: { configPrefixes: ["channels.slack"] },
configSchema: buildChannelConfigSchema(SlackConfigSchema),
config: {
...slackConfigBase,
...slackConfigAdapter,
isConfigured: (account) => isSlackPluginAccountConfigured(account),
describeAccount: (account) => ({
accountId: account.accountId,
@ -218,7 +211,6 @@ export function createSlackPluginBase(params: {
botTokenSource: account.botTokenSource,
appTokenSource: account.appTokenSource,
}),
...slackConfigAccessors,
},
setup: params.setup,
}) as Pick<

View File

@ -57,6 +57,16 @@ describe("createSynologyChatPlugin", () => {
const plugin = createSynologyChatPlugin();
expect(plugin.config.defaultAccountId?.({})).toBe("default");
});
it("formats allowFrom entries through the shared adapter", () => {
const plugin = createSynologyChatPlugin();
expect(
plugin.config.formatAllowFrom?.({
cfg: {},
allowFrom: [" USER1 ", 42],
}),
).toEqual(["user1", "42"]);
});
});
describe("security", () => {

View File

@ -5,7 +5,7 @@
*/
import {
createHybridChannelConfigBase,
createHybridChannelConfigAdapter,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { z } from "zod";
@ -32,7 +32,7 @@ const resolveSynologyChatDmPolicy = createScopedDmSecurityResolver<ResolvedSynol
normalizeEntry: (raw) => raw.toLowerCase().trim(),
});
const synologyChatConfigBase = createHybridChannelConfigBase<ResolvedSynologyChatAccount>({
const synologyChatConfigAdapter = createHybridChannelConfigAdapter<ResolvedSynologyChatAccount>({
sectionKey: CHANNEL_ID,
listAccountIds: (cfg: any) => listAccountIds(cfg),
resolveAccount: (cfg: any, accountId?: string | null) => resolveAccount(cfg, accountId),
@ -48,6 +48,9 @@ const synologyChatConfigBase = createHybridChannelConfigBase<ResolvedSynologyCha
"botName",
"allowInsecureSsl",
],
resolveAllowFrom: (account) => account.allowedUserIds,
formatAllowFrom: (allowFrom) =>
allowFrom.map((entry) => String(entry).trim().toLowerCase()).filter(Boolean),
});
function waitUntilAbort(signal?: AbortSignal, onAbort?: () => void): Promise<void> {
@ -100,7 +103,7 @@ export function createSynologyChatPlugin() {
setupWizard: synologyChatSetupWizard,
config: {
...synologyChatConfigBase,
...synologyChatConfigAdapter,
},
pairing: {

View File

@ -0,0 +1,28 @@
import { loadConfig, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime";
import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime";
import {
dispatchReplyWithBufferedBlockDispatcher,
listSkillCommandsForAgents,
} from "openclaw/plugin-sdk/reply-runtime";
import { wasSentByBot } from "./sent-message-cache.js";
export type TelegramBotDeps = {
loadConfig: typeof loadConfig;
resolveStorePath: typeof resolveStorePath;
readChannelAllowFromStore: typeof readChannelAllowFromStore;
enqueueSystemEvent: typeof enqueueSystemEvent;
dispatchReplyWithBufferedBlockDispatcher: typeof dispatchReplyWithBufferedBlockDispatcher;
listSkillCommandsForAgents: typeof listSkillCommandsForAgents;
wasSentByBot: typeof wasSentByBot;
};
export const defaultTelegramBotDeps: TelegramBotDeps = {
loadConfig,
resolveStorePath,
readChannelAllowFromStore,
enqueueSystemEvent,
dispatchReplyWithBufferedBlockDispatcher,
listSkillCommandsForAgents,
wasSentByBot,
};

View File

@ -0,0 +1,373 @@
import type { Message } from "@grammyjs/types";
import { shouldDebounceTextInbound } from "openclaw/plugin-sdk/channel-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
createInboundDebouncer,
resolveInboundDebounceMs,
} from "openclaw/plugin-sdk/reply-runtime";
import { danger, logVerbose, warn } from "openclaw/plugin-sdk/runtime-env";
import {
hasInboundMedia,
isRecoverableMediaGroupError,
resolveInboundMediaFileId,
} from "./bot-handlers.media.js";
import type { TelegramMediaRef } from "./bot-message-context.js";
import { MEDIA_GROUP_TIMEOUT_MS, type MediaGroupEntry } from "./bot-updates.js";
import { resolveMedia } from "./bot/delivery.js";
import type { TelegramContext } from "./bot/types.js";
import type { TelegramTransport } from "./fetch.js";
export type TelegramDebounceLane = "default" | "forward";
export type TelegramDebounceEntry = {
ctx: TelegramContext;
msg: Message;
allMedia: TelegramMediaRef[];
storeAllowFrom: string[];
debounceKey: string | null;
debounceLane: TelegramDebounceLane;
botUsername?: string;
};
export type TextFragmentEntry = {
key: string;
messages: Array<{ msg: Message; ctx: TelegramContext; receivedAtMs: number }>;
timer: ReturnType<typeof setTimeout>;
};
const DEFAULT_TEXT_FRAGMENT_MAX_GAP_MS = 1500;
type TelegramBotApi = {
sendMessage: (
chatId: number | string,
text: string,
params?: { message_thread_id?: number },
) => Promise<unknown>;
getFile: (fileId: string) => Promise<{ file_path?: string }>;
};
export function createTelegramInboundBufferRuntime(params: {
accountId?: string | null;
bot: { api: TelegramBotApi };
cfg: OpenClawConfig;
logger: { warn: (...args: unknown[]) => void };
mediaMaxBytes: number;
opts: {
token: string;
testTimings?: {
textFragmentGapMs?: number;
mediaGroupFlushMs?: number;
};
};
processMessage: (
ctx: TelegramContext,
media: TelegramMediaRef[],
storeAllowFrom: string[],
metadata?: { messageIdOverride?: string },
replyMedia?: TelegramMediaRef[],
) => Promise<void>;
loadStoreAllowFrom: () => Promise<string[]>;
runtime: {
error?: (message: string) => void;
};
telegramTransport?: TelegramTransport;
}) {
const {
accountId,
bot,
cfg,
logger,
mediaMaxBytes,
opts,
processMessage,
loadStoreAllowFrom,
runtime,
telegramTransport,
} = params;
const TELEGRAM_TEXT_FRAGMENT_START_THRESHOLD_CHARS = 4000;
const TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS =
typeof opts.testTimings?.textFragmentGapMs === "number" &&
Number.isFinite(opts.testTimings.textFragmentGapMs)
? Math.max(10, Math.floor(opts.testTimings.textFragmentGapMs))
: DEFAULT_TEXT_FRAGMENT_MAX_GAP_MS;
const TELEGRAM_TEXT_FRAGMENT_MAX_ID_GAP = 1;
const TELEGRAM_TEXT_FRAGMENT_MAX_PARTS = 12;
const TELEGRAM_TEXT_FRAGMENT_MAX_TOTAL_CHARS = 50_000;
const mediaGroupTimeoutMs =
typeof opts.testTimings?.mediaGroupFlushMs === "number" &&
Number.isFinite(opts.testTimings.mediaGroupFlushMs)
? Math.max(10, Math.floor(opts.testTimings.mediaGroupFlushMs))
: MEDIA_GROUP_TIMEOUT_MS;
const debounceMs = resolveInboundDebounceMs({ cfg, channel: "telegram" });
const FORWARD_BURST_DEBOUNCE_MS = 80;
const mediaGroupBuffer = new Map<string, MediaGroupEntry>();
let mediaGroupProcessing: Promise<void> = Promise.resolve();
const textFragmentBuffer = new Map<string, TextFragmentEntry>();
let textFragmentProcessing: Promise<void> = Promise.resolve();
const resolveTelegramDebounceLane = (msg: Message): TelegramDebounceLane => {
const forwardMeta = msg as {
forward_origin?: unknown;
forward_from?: unknown;
forward_from_chat?: unknown;
forward_sender_name?: unknown;
forward_date?: unknown;
};
return (forwardMeta.forward_origin ??
forwardMeta.forward_from ??
forwardMeta.forward_from_chat ??
forwardMeta.forward_sender_name ??
forwardMeta.forward_date)
? "forward"
: "default";
};
const buildSyntheticTextMessage = (params: {
base: Message;
text: string;
date?: number;
from?: Message["from"];
}): Message => ({
...params.base,
...(params.from ? { from: params.from } : {}),
text: params.text,
caption: undefined,
caption_entities: undefined,
entities: undefined,
...(params.date != null ? { date: params.date } : {}),
});
const buildSyntheticContext = (
ctx: Pick<TelegramContext, "me"> & { getFile?: unknown },
message: Message,
): TelegramContext => {
const getFile =
typeof ctx.getFile === "function"
? (ctx.getFile as TelegramContext["getFile"]).bind(ctx as object)
: async () => ({});
return { message, me: ctx.me, getFile };
};
const resolveReplyMediaForMessage = async (
ctx: TelegramContext,
msg: Message,
): Promise<TelegramMediaRef[]> => {
const replyMessage = msg.reply_to_message;
if (!replyMessage || !hasInboundMedia(replyMessage)) {
return [];
}
const replyFileId = resolveInboundMediaFileId(replyMessage);
if (!replyFileId) {
return [];
}
try {
const media = await resolveMedia(
{
message: replyMessage,
me: ctx.me,
getFile: async () => await bot.api.getFile(replyFileId),
},
mediaMaxBytes,
opts.token,
telegramTransport,
);
if (!media) {
return [];
}
return [
{
path: media.path,
contentType: media.contentType,
stickerMetadata: media.stickerMetadata,
},
];
} catch (err) {
logger.warn({ chatId: msg.chat.id, error: String(err) }, "reply media fetch failed");
return [];
}
};
const processMediaGroup = async (entry: MediaGroupEntry) => {
try {
entry.messages.sort(
(a: { msg: Message; ctx: TelegramContext }, b: { msg: Message; ctx: TelegramContext }) =>
a.msg.message_id - b.msg.message_id,
);
const captionMsg = entry.messages.find((item) => item.msg.caption || item.msg.text);
const primaryEntry = captionMsg ?? entry.messages[0];
const allMedia: TelegramMediaRef[] = [];
for (const { ctx } of entry.messages) {
let media;
try {
media = await resolveMedia(ctx, mediaMaxBytes, opts.token, telegramTransport);
} catch (mediaErr) {
if (!isRecoverableMediaGroupError(mediaErr)) {
throw mediaErr;
}
runtime.error?.(
warn(`media group: skipping photo that failed to fetch: ${String(mediaErr)}`),
);
continue;
}
if (media) {
allMedia.push({
path: media.path,
contentType: media.contentType,
stickerMetadata: media.stickerMetadata,
});
}
}
const storeAllowFrom = await loadStoreAllowFrom();
const replyMedia = await resolveReplyMediaForMessage(primaryEntry.ctx, primaryEntry.msg);
await processMessage(primaryEntry.ctx, allMedia, storeAllowFrom, undefined, replyMedia);
} catch (err) {
runtime.error?.(danger(`media group handler failed: ${String(err)}`));
}
};
const flushTextFragments = async (entry: TextFragmentEntry) => {
try {
entry.messages.sort((a, b) => a.msg.message_id - b.msg.message_id);
const first = entry.messages[0];
const last = entry.messages.at(-1);
if (!first || !last) {
return;
}
const combinedText = entry.messages.map((item) => item.msg.text ?? "").join("");
if (!combinedText.trim()) {
return;
}
const syntheticMessage = buildSyntheticTextMessage({
base: first.msg,
text: combinedText,
date: last.msg.date ?? first.msg.date,
});
const storeAllowFrom = await loadStoreAllowFrom();
await processMessage(buildSyntheticContext(first.ctx, syntheticMessage), [], storeAllowFrom, {
messageIdOverride: String(last.msg.message_id),
});
} catch (err) {
runtime.error?.(danger(`text fragment handler failed: ${String(err)}`));
}
};
const queueTextFragmentFlush = async (entry: TextFragmentEntry) => {
textFragmentProcessing = textFragmentProcessing
.then(async () => {
await flushTextFragments(entry);
})
.catch(() => undefined);
await textFragmentProcessing;
};
const runTextFragmentFlush = async (entry: TextFragmentEntry) => {
textFragmentBuffer.delete(entry.key);
await queueTextFragmentFlush(entry);
};
const scheduleTextFragmentFlush = (entry: TextFragmentEntry) => {
clearTimeout(entry.timer);
entry.timer = setTimeout(async () => {
await runTextFragmentFlush(entry);
}, TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS);
};
const inboundDebouncer = createInboundDebouncer<TelegramDebounceEntry>({
debounceMs,
resolveDebounceMs: (entry) =>
entry.debounceLane === "forward" ? FORWARD_BURST_DEBOUNCE_MS : debounceMs,
buildKey: (entry) => entry.debounceKey,
shouldDebounce: (entry) => {
const text = entry.msg.text ?? entry.msg.caption ?? "";
const hasDebounceableText = shouldDebounceTextInbound({
text,
cfg,
commandOptions: { botUsername: entry.botUsername },
});
if (entry.debounceLane === "forward") {
return hasDebounceableText || entry.allMedia.length > 0;
}
return hasDebounceableText && entry.allMedia.length === 0;
},
onFlush: async (entries) => {
const last = entries.at(-1);
if (!last) {
return;
}
if (entries.length === 1) {
const replyMedia = await resolveReplyMediaForMessage(last.ctx, last.msg);
await processMessage(last.ctx, last.allMedia, last.storeAllowFrom, undefined, replyMedia);
return;
}
const combinedText = entries
.map((entry) => entry.msg.text ?? entry.msg.caption ?? "")
.filter(Boolean)
.join("\n");
const combinedMedia = entries.flatMap((entry) => entry.allMedia);
if (!combinedText.trim() && combinedMedia.length === 0) {
return;
}
const first = entries[0];
const syntheticMessage = buildSyntheticTextMessage({
base: first.msg,
text: combinedText,
date: last.msg.date ?? first.msg.date,
});
const messageIdOverride = last.msg.message_id ? String(last.msg.message_id) : undefined;
const replyMedia = await resolveReplyMediaForMessage(first.ctx, syntheticMessage);
await processMessage(
buildSyntheticContext(first.ctx, syntheticMessage),
combinedMedia,
first.storeAllowFrom,
messageIdOverride ? { messageIdOverride } : undefined,
replyMedia,
);
},
onError: (err, items) => {
runtime.error?.(danger(`telegram debounce flush failed: ${String(err)}`));
const chatId = items[0]?.msg.chat.id;
if (chatId != null) {
const threadId = items[0]?.msg.message_thread_id;
void bot.api
.sendMessage(
chatId,
"Something went wrong while processing your message. Please try again.",
threadId != null ? { message_thread_id: threadId } : undefined,
)
.catch((sendErr) => {
logVerbose(`telegram: error fallback send failed: ${String(sendErr)}`);
});
}
},
});
return {
buildSyntheticContext,
buildSyntheticTextMessage,
inboundDebouncer,
mediaGroupBuffer,
mediaGroupProcessing: () => mediaGroupProcessing,
setMediaGroupProcessing: (next: Promise<void>) => {
mediaGroupProcessing = next;
},
mediaGroupTimeoutMs,
processMediaGroup,
textFragmentBuffer,
textFragmentProcessing: () => textFragmentProcessing,
setTextFragmentProcessing: (next: Promise<void>) => {
textFragmentProcessing = next;
},
scheduleTextFragmentFlush,
flushTextFragments,
resolveReplyMediaForMessage,
resolveTelegramDebounceLane,
TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS,
TELEGRAM_TEXT_FRAGMENT_MAX_ID_GAP,
TELEGRAM_TEXT_FRAGMENT_MAX_PARTS,
TELEGRAM_TEXT_FRAGMENT_MAX_TOTAL_CHARS,
TELEGRAM_TEXT_FRAGMENT_START_THRESHOLD_CHARS,
};
}

View File

@ -3,12 +3,10 @@ import { resolveAgentDir, resolveDefaultAgentId } from "openclaw/plugin-sdk/agen
import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime";
import { shouldDebounceTextInbound } from "openclaw/plugin-sdk/channel-runtime";
import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-runtime";
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import { writeConfigFile } from "openclaw/plugin-sdk/config-runtime";
import {
loadSessionStore,
resolveSessionStoreEntry,
resolveStorePath,
updateSessionStore,
} from "openclaw/plugin-sdk/config-runtime";
import type { DmPolicy } from "openclaw/plugin-sdk/config-runtime";
@ -18,13 +16,11 @@ import type {
TelegramTopicConfig,
} from "openclaw/plugin-sdk/config-runtime";
import { applyModelOverrideToSessionEntry } from "openclaw/plugin-sdk/config-runtime";
import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime";
import {
buildPluginBindingResolvedText,
parsePluginBindingApprovalCustomId,
resolvePluginConversationBindingApproval,
} from "openclaw/plugin-sdk/conversation-runtime";
import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime";
import { dispatchPluginInteractiveHandler } from "openclaw/plugin-sdk/plugin-runtime";
import {
createInboundDebouncer,
@ -36,7 +32,6 @@ import {
formatModelsAvailableHeader,
} from "openclaw/plugin-sdk/reply-runtime";
import { resolveStoredModelOverride } from "openclaw/plugin-sdk/reply-runtime";
import { listSkillCommandsForAgents } from "openclaw/plugin-sdk/reply-runtime";
import { buildCommandsMessagePaginated } from "openclaw/plugin-sdk/reply-runtime";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
@ -47,6 +42,7 @@ import {
normalizeDmAllowFromWithStore,
type NormalizedAllowFrom,
} from "./bot-access.js";
import { defaultTelegramBotDeps } from "./bot-deps.js";
import {
APPROVE_CALLBACK_DATA_RE,
hasInboundMedia,
@ -97,7 +93,6 @@ import {
type ProviderInfo,
} from "./model-buttons.js";
import { buildInlineKeyboard } from "./send.js";
import { wasSentByBot } from "./sent-message-cache.js";
export const registerTelegramHandlers = ({
cfg,
@ -115,6 +110,7 @@ export const registerTelegramHandlers = ({
shouldSkipUpdate,
processMessage,
logger,
telegramDeps = defaultTelegramBotDeps,
}: RegisterTelegramHandlerParams) => {
const DEFAULT_TEXT_FRAGMENT_MAX_GAP_MS = 1500;
const TELEGRAM_TEXT_FRAGMENT_START_THRESHOLD_CHARS = 4000;
@ -315,7 +311,9 @@ export const registerTelegramHandlers = ({
? resolveThreadSessionKeys({ baseSessionKey, threadId: `${params.chatId}:${dmThreadId}` })
: null;
const sessionKey = threadKeys?.sessionKey ?? baseSessionKey;
const storePath = resolveStorePath(cfg.session?.store, { agentId: route.agentId });
const storePath = telegramDeps.resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const store = loadSessionStore(storePath);
const entry = resolveSessionStoreEntry({ store, sessionKey }).existing;
const storedOverride = resolveStoredModelOverride({
@ -444,7 +442,7 @@ export const registerTelegramHandlers = ({
};
const loadStoreAllowFrom = async () =>
readChannelAllowFromStore("telegram", process.env, accountId).catch(() => []);
telegramDeps.readChannelAllowFromStore("telegram", process.env, accountId).catch(() => []);
const resolveReplyMediaForMessage = async (
ctx: TelegramContext,
@ -760,7 +758,7 @@ export const registerTelegramHandlers = ({
if (user?.is_bot) {
return;
}
if (reactionMode === "own" && !wasSentByBot(chatId, messageId)) {
if (reactionMode === "own" && !telegramDeps.wasSentByBot(chatId, messageId)) {
return;
}
const eventAuthContext = await resolveTelegramEventAuthorizationContext({
@ -835,7 +833,7 @@ export const registerTelegramHandlers = ({
const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId });
// Fresh config for bindings lookup; other routing inputs are payload-derived.
const route = resolveAgentRoute({
cfg: loadConfig(),
cfg: telegramDeps.loadConfig(),
channel: "telegram",
accountId,
peer: { kind: isGroup ? "group" : "direct", id: peerId },
@ -847,7 +845,7 @@ export const registerTelegramHandlers = ({
for (const r of addedReactions) {
const emoji = r.emoji;
const text = `Telegram reaction added: ${emoji} by ${senderLabel} on msg ${messageId}`;
enqueueSystemEvent(text, {
telegramDeps.enqueueSystemEvent(text, {
sessionKey,
contextKey: `telegram:reaction:add:${chatId}:${messageId}:${user?.id ?? "anon"}:${emoji}`,
});
@ -1303,7 +1301,7 @@ export const registerTelegramHandlers = ({
}
const agentId = paginationMatch[2]?.trim() || resolveDefaultAgentId(cfg);
const skillCommands = listSkillCommandsForAgents({
const skillCommands = telegramDeps.listSkillCommandsForAgents({
cfg,
agentIds: [agentId],
});
@ -1460,7 +1458,7 @@ export const registerTelegramHandlers = ({
// Directly set model override in session
try {
// Get session store path
const storePath = resolveStorePath(cfg.session?.store, {
const storePath = telegramDeps.resolveStorePath(cfg.session?.store, {
agentId: sessionState.agentId,
});
@ -1540,7 +1538,7 @@ export const registerTelegramHandlers = ({
}
// Check if old chat ID has config and migrate it
const currentConfig = loadConfig();
const currentConfig = telegramDeps.loadConfig();
const migration = migrateTelegramGroupConfig({
cfg: currentConfig,
accountId,

View File

@ -24,10 +24,10 @@ import type {
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
import { resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime";
import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-runtime";
import { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { defaultTelegramBotDeps, type TelegramBotDeps } from "./bot-deps.js";
import type { TelegramMessageContext } from "./bot-message-context.js";
import type { TelegramBotOptions } from "./bot.js";
import { deliverReplies } from "./bot/delivery.js";
@ -110,6 +110,7 @@ type DispatchTelegramMessageParams = {
streamMode: TelegramStreamMode;
textLimit: number;
telegramCfg: TelegramAccountConfig;
telegramDeps?: TelegramBotDeps;
opts: Pick<TelegramBotOptions, "token">;
};
@ -147,6 +148,7 @@ export const dispatchTelegramMessage = async ({
streamMode,
textLimit,
telegramCfg,
telegramDeps = defaultTelegramBotDeps,
opts,
}: DispatchTelegramMessageParams) => {
const {
@ -535,7 +537,7 @@ export const dispatchTelegramMessage = async ({
let dispatchError: unknown;
try {
({ queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
({ queuedFinal } = await telegramDeps.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg,
dispatcherOptions: {

View File

@ -2,6 +2,7 @@ import type { ReplyToMode } from "openclaw/plugin-sdk/config-runtime";
import type { TelegramAccountConfig } from "openclaw/plugin-sdk/config-runtime";
import { danger } from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import type { TelegramBotDeps } from "./bot-deps.js";
import {
buildTelegramMessageContext,
type BuildTelegramMessageContextParams,
@ -21,6 +22,7 @@ type TelegramMessageProcessorDeps = Omit<
replyToMode: ReplyToMode;
streamMode: TelegramStreamMode;
textLimit: number;
telegramDeps: TelegramBotDeps;
opts: Pick<TelegramBotOptions, "token">;
};
@ -45,6 +47,7 @@ export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDep
replyToMode,
streamMode,
textLimit,
telegramDeps,
opts,
} = deps;
@ -89,6 +92,7 @@ export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDep
streamMode,
textLimit,
telegramCfg,
telegramDeps,
opts,
});
} catch (err) {

View File

@ -56,6 +56,7 @@ export function createNativeCommandTestParams(
params.resolveTelegramGroupConfig ??
((_chatId, _messageThreadId) => ({ groupConfig: undefined, topicConfig: undefined })),
shouldSkipUpdate: params.shouldSkipUpdate ?? (() => false),
telegramDeps: params.telegramDeps,
opts: params.opts ?? { token: "token" },
};
}

View File

@ -1,6 +1,7 @@
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import type { OpenClawConfig } from "openclaw/plugin-sdk/telegram";
import { expect, vi } from "vitest";
import type { TelegramBotDeps } from "./bot-deps.js";
import {
createNativeCommandTestParams as createBaseNativeCommandTestParams,
createTelegramPrivateCommandContext,
@ -78,10 +79,23 @@ export function createNativeCommandTestParams(
cfg: OpenClawConfig,
params: Partial<RegisterTelegramNativeCommandsParams> = {},
): RegisterTelegramNativeCommandsParams {
const telegramDeps: TelegramBotDeps = {
loadConfig: vi.fn(() => ({})),
resolveStorePath: vi.fn((storePath?: string) => storePath ?? "/tmp/sessions.json"),
readChannelAllowFromStore: vi.fn(async () => []),
enqueueSystemEvent: vi.fn(),
dispatchReplyWithBufferedBlockDispatcher: vi.fn(async () => ({
queuedFinal: false,
counts: {},
})),
listSkillCommandsForAgents,
wasSentByBot: vi.fn(() => false),
};
return createBaseNativeCommandTestParams({
cfg,
runtime: params.runtime ?? ({} as RuntimeEnv),
nativeSkillsEnabled: true,
telegramDeps,
...params,
});
}

View File

@ -9,6 +9,7 @@ import {
pluginCommandMocks,
resetPluginCommandMocks,
} from "../../../test/helpers/extensions/telegram-plugin-command.js";
import type { TelegramBotDeps } from "./bot-deps.js";
const skillCommandMocks = vi.hoisted(() => ({
listSkillCommandsForAgents: vi.fn(() => []),
}));
@ -31,11 +32,33 @@ vi.mock("./bot/delivery.js", () => ({
import { registerTelegramNativeCommands } from "./bot-native-commands.js";
import {
createCommandBot,
createNativeCommandTestParams,
createNativeCommandTestParams as createNativeCommandTestParamsBase,
createPrivateCommandContext,
waitForRegisteredCommands,
} from "./bot-native-commands.menu-test-support.js";
function createNativeCommandTestParams(
cfg: OpenClawConfig,
params: Partial<Parameters<typeof registerTelegramNativeCommands>[0]> = {},
) {
const telegramDeps: TelegramBotDeps = {
loadConfig: vi.fn(() => ({})),
resolveStorePath: vi.fn((storePath?: string) => storePath ?? "/tmp/sessions.json"),
readChannelAllowFromStore: vi.fn(async () => []),
enqueueSystemEvent: vi.fn(),
dispatchReplyWithBufferedBlockDispatcher: vi.fn(async () => ({
queuedFinal: false,
counts: {},
})),
listSkillCommandsForAgents: skillCommandMocks.listSkillCommandsForAgents,
wasSentByBot: vi.fn(() => false),
};
return createNativeCommandTestParamsBase(cfg, {
telegramDeps,
...params,
});
}
describe("registerTelegramNativeCommands", () => {
beforeEach(() => {
skillCommandMocks.listSkillCommandsForAgents.mockClear();

View File

@ -37,8 +37,6 @@ import {
resolveCommandArgMenu,
} from "openclaw/plugin-sdk/reply-runtime";
import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime";
import { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-runtime";
import { listSkillCommandsForAgents } from "openclaw/plugin-sdk/reply-runtime";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
@ -46,6 +44,7 @@ import { getChildLogger } from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { withTelegramApiErrorLogging } from "./api-logging.js";
import { isSenderAllowed, normalizeDmAllowFromWithStore } from "./bot-access.js";
import { defaultTelegramBotDeps, type TelegramBotDeps } from "./bot-deps.js";
import type { TelegramMediaRef } from "./bot-message-context.js";
import {
buildCappedTelegramMenuCommands,
@ -101,6 +100,7 @@ export type RegisterTelegramHandlerParams = {
telegramTransport?: TelegramTransport;
runtime: RuntimeEnv;
telegramCfg: TelegramAccountConfig;
telegramDeps?: TelegramBotDeps;
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
resolveGroupPolicy: (chatId: string | number) => ChannelGroupPolicy;
@ -142,6 +142,7 @@ export type RegisterTelegramNativeCommandsParams = {
messageThreadId?: number,
) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig };
shouldSkipUpdate: (ctx: TelegramUpdateKeyContext) => boolean;
telegramDeps?: TelegramBotDeps;
opts: { token: string };
};
@ -364,6 +365,7 @@ export const registerTelegramNativeCommands = ({
resolveGroupPolicy,
resolveTelegramGroupConfig,
shouldSkipUpdate,
telegramDeps = defaultTelegramBotDeps,
opts,
}: RegisterTelegramNativeCommandsParams) => {
const silentErrorReplies = telegramCfg.silentErrorReplies === true;
@ -378,7 +380,10 @@ export const registerTelegramNativeCommands = ({
}
const skillCommands =
nativeEnabled && nativeSkillsEnabled && boundRoute
? listSkillCommandsForAgents({ cfg, agentIds: [boundRoute.agentId] })
? telegramDeps.listSkillCommandsForAgents({
cfg,
agentIds: [boundRoute.agentId],
})
: [];
const nativeCommands = nativeEnabled
? listNativeCommandSpecsForConfig(cfg, {
@ -756,7 +761,7 @@ export const registerTelegramNativeCommands = ({
accountId: route.accountId,
});
await dispatchReplyWithBufferedBlockDispatcher({
await telegramDeps.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg,
dispatcherOptions: {

View File

@ -7,6 +7,21 @@ import { beforeEach, vi } from "vitest";
type AnyMock = MockFn<(...args: unknown[]) => unknown>;
type AnyAsyncMock = MockFn<(...args: unknown[]) => Promise<unknown>>;
type DispatchReplyWithBufferedBlockDispatcherFn =
typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithBufferedBlockDispatcher;
type DispatchReplyWithBufferedBlockDispatcherResult = Awaited<
ReturnType<DispatchReplyWithBufferedBlockDispatcherFn>
>;
type DispatchReplyHarnessParams = {
ctx: MsgContext;
replyOptions?: GetReplyOptions;
dispatcherOptions?: {
typingCallbacks?: {
start?: () => void | Promise<void>;
};
deliver?: (payload: ReplyPayload, info: { kind: "final" }) => void | Promise<void>;
};
};
const { sessionStorePath } = vi.hoisted(() => ({
sessionStorePath: `/tmp/openclaw-telegram-${process.pid}-${process.env.VITEST_POOL_ID ?? "0"}.json`,
@ -20,18 +35,21 @@ export function getLoadWebMediaMock(): AnyMock {
return loadWebMedia;
}
vi.mock("openclaw/plugin-sdk/web-media", () => ({
vi.doMock("openclaw/plugin-sdk/web-media", () => ({
loadWebMedia,
}));
const { loadConfig } = vi.hoisted((): { loadConfig: AnyMock } => ({
loadConfig: vi.fn(() => ({})),
}));
const { resolveStorePathMock } = vi.hoisted((): { resolveStorePathMock: AnyMock } => ({
resolveStorePathMock: vi.fn((storePath?: string) => storePath ?? sessionStorePath),
}));
export function getLoadConfigMock(): AnyMock {
return loadConfig;
}
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
vi.doMock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
return {
...actual,
@ -39,11 +57,11 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
};
});
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
vi.doMock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
return {
...actual,
resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath),
resolveStorePath: resolveStorePathMock,
};
});
@ -68,7 +86,7 @@ export function getUpsertChannelPairingRequestMock(): AnyAsyncMock {
return upsertChannelPairingRequest;
}
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
vi.doMock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
return {
...actual,
@ -89,23 +107,36 @@ const skillCommandsHoisted = vi.hoisted(() => ({
configOverride?: OpenClawConfig,
) => Promise<ReplyPayload | ReplyPayload[] | undefined>
>,
dispatchReplyWithBufferedBlockDispatcher: vi.fn<DispatchReplyWithBufferedBlockDispatcherFn>(
async (params: DispatchReplyHarnessParams) => {
const result: DispatchReplyWithBufferedBlockDispatcherResult = {
queuedFinal: false,
counts: {} as DispatchReplyWithBufferedBlockDispatcherResult["counts"],
};
await params.dispatcherOptions?.typingCallbacks?.start?.();
const reply = await skillCommandsHoisted.replySpy(params.ctx, params.replyOptions);
const payloads = reply === undefined ? [] : Array.isArray(reply) ? reply : [reply];
for (const payload of payloads) {
await params.dispatcherOptions?.deliver?.(payload, { kind: "final" });
}
return result;
},
),
}));
export const listSkillCommandsForAgents = skillCommandsHoisted.listSkillCommandsForAgents;
export const replySpy = skillCommandsHoisted.replySpy;
export const dispatchReplyWithBufferedBlockDispatcher =
skillCommandsHoisted.dispatchReplyWithBufferedBlockDispatcher;
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
vi.doMock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
return {
...actual,
listSkillCommandsForAgents: skillCommandsHoisted.listSkillCommandsForAgents,
getReplyFromConfig: skillCommandsHoisted.replySpy,
__replySpy: skillCommandsHoisted.replySpy,
dispatchReplyWithBufferedBlockDispatcher: vi.fn(
async ({ ctx, replyOptions }: { ctx: MsgContext; replyOptions?: GetReplyOptions }) => {
await skillCommandsHoisted.replySpy(ctx, replyOptions);
return { queuedFinal: false };
},
),
dispatchReplyWithBufferedBlockDispatcher:
skillCommandsHoisted.dispatchReplyWithBufferedBlockDispatcher,
};
});
@ -114,7 +145,7 @@ const systemEventsHoisted = vi.hoisted(() => ({
}));
export const enqueueSystemEventSpy: AnyMock = systemEventsHoisted.enqueueSystemEventSpy;
vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
vi.doMock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
return {
...actual,
@ -127,7 +158,7 @@ const sentMessageCacheHoisted = vi.hoisted(() => ({
}));
export const wasSentByBot = sentMessageCacheHoisted.wasSentByBot;
vi.mock("./sent-message-cache.js", () => ({
vi.doMock("./sent-message-cache.js", () => ({
wasSentByBot: sentMessageCacheHoisted.wasSentByBot,
recordSentMessage: vi.fn(),
clearSentMessageCache: vi.fn(),
@ -181,7 +212,19 @@ export const {
getFileSpy,
} = grammySpies;
vi.mock("grammy", () => ({
const runnerHoisted = vi.hoisted(() => ({
sequentializeMiddleware: vi.fn(async (_ctx: unknown, next?: () => Promise<void>) => {
if (typeof next === "function") {
await next();
}
}),
sequentializeSpy: vi.fn(() => runnerHoisted.sequentializeMiddleware),
throttlerSpy: vi.fn(() => "throttler"),
}));
export const sequentializeSpy: AnyMock = runnerHoisted.sequentializeSpy;
export let sequentializeKey: ((ctx: unknown) => string) | undefined;
export const throttlerSpy: AnyMock = runnerHoisted.throttlerSpy;
export const telegramBotRuntimeForTest = {
Bot: class {
api = {
config: { use: grammySpies.useSpy },
@ -210,32 +253,23 @@ vi.mock("grammy", () => ({
grammySpies.botCtorSpy(token, options);
}
},
InputFile: class {},
}));
const runnerHoisted = vi.hoisted(() => ({
sequentializeMiddleware: vi.fn(async (_ctx: unknown, next?: () => Promise<void>) => {
if (typeof next === "function") {
await next();
}
}),
sequentializeSpy: vi.fn(() => runnerHoisted.sequentializeMiddleware),
throttlerSpy: vi.fn(() => "throttler"),
}));
export const sequentializeSpy: AnyMock = runnerHoisted.sequentializeSpy;
export let sequentializeKey: ((ctx: unknown) => string) | undefined;
vi.mock("@grammyjs/runner", () => ({
sequentialize: (keyFn: (ctx: unknown) => string) => {
sequentializeKey = keyFn;
return runnerHoisted.sequentializeSpy();
},
}));
export const throttlerSpy: AnyMock = runnerHoisted.throttlerSpy;
vi.mock("@grammyjs/transformer-throttler", () => ({
apiThrottler: () => runnerHoisted.throttlerSpy(),
}));
};
export const telegramBotDepsForTest = {
loadConfig,
resolveStorePath: resolveStorePathMock,
readChannelAllowFromStore,
enqueueSystemEvent: enqueueSystemEventSpy,
dispatchReplyWithBufferedBlockDispatcher,
listSkillCommandsForAgents,
wasSentByBot,
};
vi.doMock("./bot.runtime.js", () => telegramBotRuntimeForTest);
export const getOnHandler = (event: string) => {
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
@ -310,6 +344,8 @@ beforeEach(() => {
resetInboundDedupe();
loadConfig.mockReset();
loadConfig.mockReturnValue(DEFAULT_TELEGRAM_TEST_CONFIG);
resolveStorePathMock.mockReset();
resolveStorePathMock.mockImplementation((storePath?: string) => storePath ?? sessionStorePath);
loadWebMedia.mockReset();
readChannelAllowFromStore.mockReset();
readChannelAllowFromStore.mockResolvedValue([]);
@ -324,6 +360,22 @@ beforeEach(() => {
await opts?.onReplyStart?.();
return undefined;
});
dispatchReplyWithBufferedBlockDispatcher.mockReset();
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
async (params: DispatchReplyHarnessParams) => {
const result: DispatchReplyWithBufferedBlockDispatcherResult = {
queuedFinal: false,
counts: {} as DispatchReplyWithBufferedBlockDispatcherResult["counts"],
};
await params.dispatcherOptions?.typingCallbacks?.start?.();
const reply = await replySpy(params.ctx, params.replyOptions);
const payloads = reply === undefined ? [] : Array.isArray(reply) ? reply : [reply];
for (const payload of payloads) {
await params.dispatcherOptions?.deliver?.(payload, { kind: "final" });
}
return result;
},
);
sendAnimationSpy.mockReset();
sendAnimationSpy.mockResolvedValue({ message_id: 78 });

View File

@ -5,7 +5,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js";
import { withEnvAsync } from "../../../test/helpers/extensions/env.js";
import { useFrozenTime, useRealTime } from "../../../test/helpers/extensions/frozen-time.js";
import {
const {
answerCallbackQuerySpy,
botCtorSpy,
commandSpy,
@ -26,13 +26,25 @@ import {
sequentializeSpy,
setMessageReactionSpy,
setMyCommandsSpy,
telegramBotDepsForTest,
telegramBotRuntimeForTest,
throttlerSpy,
useSpy,
} from "./bot.create-telegram-bot.test-harness.js";
} = await import("./bot.create-telegram-bot.test-harness.js");
import { resolveTelegramFetch } from "./fetch.js";
// Import after the harness registers `vi.mock(...)` for grammY and Telegram internals.
const { createTelegramBot, getTelegramSequentialKey } = await import("./bot.js");
const {
createTelegramBot: createTelegramBotBase,
getTelegramSequentialKey,
setTelegramBotRuntimeForTest,
} = await import("./bot.js");
setTelegramBotRuntimeForTest(telegramBotRuntimeForTest);
const createTelegramBot = (opts: Parameters<typeof createTelegramBotBase>[0]) =>
createTelegramBotBase({
...opts,
telegramDeps: telegramBotDepsForTest,
});
const loadConfig = getLoadConfigMock();
const loadWebMedia = getLoadWebMediaMock();

View File

@ -1,8 +1,18 @@
import { describe, expect, it, vi } from "vitest";
import { botCtorSpy } from "./bot.create-telegram-bot.test-harness.js";
import { createTelegramBot } from "./bot.js";
import { getTelegramNetworkErrorOrigin } from "./network-errors.js";
const { botCtorSpy, telegramBotDepsForTest } =
await import("./bot.create-telegram-bot.test-harness.js");
const { telegramBotRuntimeForTest } = await import("./bot.create-telegram-bot.test-harness.js");
const { createTelegramBot: createTelegramBotBase, setTelegramBotRuntimeForTest } =
await import("./bot.js");
setTelegramBotRuntimeForTest(telegramBotRuntimeForTest);
const createTelegramBot = (opts: Parameters<typeof createTelegramBotBase>[0]) =>
createTelegramBotBase({
...opts,
telegramDeps: telegramBotDepsForTest,
});
function createWrappedTelegramClientFetch(proxyFetch: typeof fetch) {
const shutdown = new AbortController();
botCtorSpy.mockClear();

View File

@ -56,12 +56,7 @@ const apiStub: ApiStub = {
setMyCommands: vi.fn(async () => undefined),
};
beforeEach(() => {
resetInboundDedupe();
resetSaveMediaBufferMock();
});
vi.mock("grammy", () => ({
export const telegramBotRuntimeForTest = {
Bot: class {
api = apiStub;
use = middlewareUseSpy;
@ -71,20 +66,51 @@ vi.mock("grammy", () => ({
catch = vi.fn();
constructor(public token: string) {}
},
InputFile: class {},
webhookCallback: vi.fn(),
}));
vi.mock("@grammyjs/runner", () => ({
sequentialize: () => vi.fn(),
}));
apiThrottler: () => throttlerSpy(),
};
const mediaHarnessReplySpy = vi.hoisted(() =>
vi.fn(async (_ctx, opts) => {
await opts?.onReplyStart?.();
return undefined;
}),
);
const mediaHarnessDispatchReplyWithBufferedBlockDispatcher = vi.hoisted(() =>
vi.fn(async (params) => {
await params.dispatcherOptions?.typingCallbacks?.start?.();
const reply = await mediaHarnessReplySpy(params.ctx, params.replyOptions);
const payloads = reply === undefined ? [] : Array.isArray(reply) ? reply : [reply];
for (const payload of payloads) {
await params.dispatcherOptions?.deliver?.(payload, { kind: "final" });
}
return { queuedFinal: false, counts: {} };
}),
);
export const telegramBotDepsForTest = {
loadConfig: () => ({
channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } },
}),
resolveStorePath: vi.fn((storePath?: string) => storePath ?? "/tmp/telegram-media-sessions.json"),
readChannelAllowFromStore: vi.fn(async () => [] as string[]),
enqueueSystemEvent: vi.fn(),
dispatchReplyWithBufferedBlockDispatcher: mediaHarnessDispatchReplyWithBufferedBlockDispatcher,
listSkillCommandsForAgents: vi.fn(() => []),
wasSentByBot: vi.fn(() => false),
};
beforeEach(() => {
resetInboundDedupe();
resetSaveMediaBufferMock();
});
const throttlerSpy = vi.fn(() => "throttler");
vi.mock("@grammyjs/transformer-throttler", () => ({
apiThrottler: () => throttlerSpy(),
vi.doMock("./bot.runtime.js", () => ({
...telegramBotRuntimeForTest,
}));
vi.mock("undici", async (importOriginal) => {
vi.doMock("undici", async (importOriginal) => {
const actual = await importOriginal<typeof import("undici")>();
return {
...actual,
@ -92,7 +118,7 @@ vi.mock("undici", async (importOriginal) => {
};
});
vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => {
vi.doMock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/media-runtime")>();
const mockModule = Object.create(null) as Record<string, unknown>;
Object.defineProperties(mockModule, Object.getOwnPropertyDescriptors(actual));
@ -105,7 +131,7 @@ vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => {
return mockModule;
});
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
vi.doMock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
return {
...actual,
@ -115,7 +141,7 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
};
});
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
vi.doMock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
return {
...actual,
@ -123,7 +149,7 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
};
});
vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({
vi.doMock("openclaw/plugin-sdk/conversation-runtime", () => ({
readChannelAllowFromStore: vi.fn(async () => [] as string[]),
upsertChannelPairingRequest: vi.fn(async () => ({
code: "PAIRCODE",
@ -131,10 +157,11 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({
})),
}));
vi.mock("openclaw/plugin-sdk/reply-runtime", () => {
const replySpy = vi.fn(async (_ctx, opts) => {
await opts?.onReplyStart?.();
return undefined;
});
return { getReplyFromConfig: replySpy, __replySpy: replySpy };
vi.doMock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
return {
...actual,
getReplyFromConfig: mediaHarnessReplySpy,
__replySpy: mediaHarnessReplySpy,
};
});

View File

@ -1,6 +1,5 @@
import * as ssrf from "openclaw/plugin-sdk/infra-runtime";
import { afterEach, beforeAll, beforeEach, expect, vi, type Mock } from "vitest";
import { onSpy, sendChatActionSpy } from "./bot.media.e2e-harness.js";
type StickerSpy = Mock<(...args: unknown[]) => unknown>;
@ -21,6 +20,8 @@ const TELEGRAM_BOT_IMPORT_TIMEOUT_MS = process.platform === "win32" ? 180_000 :
let createTelegramBotRef: typeof import("./bot.js").createTelegramBot;
let replySpyRef: ReturnType<typeof vi.fn>;
let onSpyRef: Mock;
let sendChatActionSpyRef: Mock;
export async function createBotHandler(): Promise<{
handler: (ctx: Record<string, unknown>) => Promise<void>;
@ -39,9 +40,9 @@ export async function createBotHandlerWithOptions(options: {
replySpy: ReturnType<typeof vi.fn>;
runtimeError: ReturnType<typeof vi.fn>;
}> {
onSpy.mockClear();
onSpyRef.mockClear();
replySpyRef.mockClear();
sendChatActionSpy.mockClear();
sendChatActionSpyRef.mockClear();
const runtimeError = options.runtimeError ?? vi.fn();
const runtimeLog = options.runtimeLog ?? vi.fn();
@ -57,7 +58,7 @@ export async function createBotHandlerWithOptions(options: {
},
},
});
const handler = onSpy.mock.calls.find((call) => call[0] === "message")?.[1] as (
const handler = onSpyRef.mock.calls.find((call) => call[0] === "message")?.[1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
expect(handler).toBeDefined();
@ -102,7 +103,16 @@ afterEach(() => {
});
beforeAll(async () => {
({ createTelegramBot: createTelegramBotRef } = await import("./bot.js"));
const harness = await import("./bot.media.e2e-harness.js");
onSpyRef = harness.onSpy;
sendChatActionSpyRef = harness.sendChatActionSpy;
const botModule = await import("./bot.js");
botModule.setTelegramBotRuntimeForTest(harness.telegramBotRuntimeForTest);
createTelegramBotRef = (opts) =>
botModule.createTelegramBot({
...opts,
telegramDeps: harness.telegramBotDepsForTest,
});
const replyModule = await import("openclaw/plugin-sdk/reply-runtime");
replySpyRef = (replyModule as unknown as { __replySpy: ReturnType<typeof vi.fn> }).__replySpy;
}, TELEGRAM_BOT_IMPORT_TIMEOUT_MS);

View File

@ -0,0 +1,4 @@
export { sequentialize } from "@grammyjs/runner";
export { apiThrottler } from "@grammyjs/transformer-throttler";
export { Bot } from "grammy";
export type { ApiClientOptions } from "grammy";

View File

@ -7,7 +7,7 @@ import {
registerPluginInteractiveHandler,
} from "../../../src/plugins/interactive.js";
import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js";
import {
const {
answerCallbackQuerySpy,
commandSpy,
editMessageReplyMarkupSpy,
@ -22,8 +22,10 @@ import {
replySpy,
sendMessageSpy,
setMyCommandsSpy,
telegramBotDepsForTest,
telegramBotRuntimeForTest,
wasSentByBot,
} from "./bot.create-telegram-bot.test-harness.js";
} = await import("./bot.create-telegram-bot.test-harness.js");
// Import after the harness registers `vi.mock(...)` for grammY and Telegram internals.
const { listNativeCommandSpecs, listNativeCommandSpecsForConfig } =
@ -31,7 +33,14 @@ const { listNativeCommandSpecs, listNativeCommandSpecsForConfig } =
const { loadSessionStore } = await import("../../../src/config/sessions.js");
const { normalizeTelegramCommandName } =
await import("../../../src/config/telegram-custom-commands.js");
const { createTelegramBot } = await import("./bot.js");
const { createTelegramBot: createTelegramBotBase, setTelegramBotRuntimeForTest } =
await import("./bot.js");
setTelegramBotRuntimeForTest(telegramBotRuntimeForTest);
const createTelegramBot = (opts: Parameters<typeof createTelegramBotBase>[0]) =>
createTelegramBotBase({
...opts,
telegramDeps: telegramBotDepsForTest,
});
const loadConfig = getLoadConfigMock();
const readChannelAllowFromStore = getReadChannelAllowFromStoreMock();

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