Compare commits
35 Commits
main
...
ui/touch-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9b7ccbd6e | ||
|
|
f7483b35c3 | ||
|
|
026be9dfae | ||
|
|
18a8477f03 | ||
|
|
b41aa861d5 | ||
|
|
1dfeb21cb5 | ||
|
|
3bf540170a | ||
|
|
e5fd061470 | ||
|
|
3a4ed87599 | ||
|
|
97ae0158e8 | ||
|
|
1662f3f5da | ||
|
|
b00f3f9b64 | ||
|
|
5484225b2d | ||
|
|
a03f43d5bd | ||
|
|
84cf8c32aa | ||
|
|
65dff0c2a3 | ||
|
|
5d439a43f4 | ||
|
|
10c16d0de8 | ||
|
|
d3ca5fb8a1 | ||
|
|
cd60db8f54 | ||
|
|
9b4468fb82 | ||
|
|
49c5f4eb1e | ||
|
|
3186cbbbe2 | ||
|
|
348016d8a8 | ||
|
|
0d438921ea | ||
|
|
50a2be72fe | ||
|
|
371732f399 | ||
|
|
473ba3149b | ||
|
|
5116dbeb60 | ||
|
|
0bd4c7cd43 | ||
|
|
feddfb4859 | ||
|
|
95e20e8a35 | ||
|
|
52662d39d6 | ||
|
|
3d0b3526e0 | ||
|
|
0a30d5a4e1 |
4
.github/labeler.yml
vendored
4
.github/labeler.yml
vendored
@ -314,3 +314,7 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/xiaomi/**"
|
||||
"extensions: fal":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/fal/**"
|
||||
|
||||
94
.github/workflows/ci.yml
vendored
94
.github/workflows/ci.yml
vendored
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
61
docs/providers/xai.md
Normal 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).
|
||||
@ -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 **in‑process** 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
|
||||
@ -1609,7 +1658,7 @@ openclaw plugins install ./extensions/voice-call # relative path ok
|
||||
openclaw plugins install ./plugin.tgz # install from a local tarball
|
||||
openclaw plugins install ./plugin.zip # install from a local zip
|
||||
openclaw plugins install -l ./extensions/voice-call # link (no copy) for dev
|
||||
openclaw plugins install @openclaw/voice-call # install from npm
|
||||
openclaw plugins install @openclaw/voice-call # install from npm
|
||||
openclaw plugins install @openclaw/voice-call --pin # store exact resolved name@version
|
||||
openclaw plugins update <id>
|
||||
openclaw plugins update --all
|
||||
@ -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 top‑level 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.
|
||||
|
||||
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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 => {
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
},
|
||||
|
||||
57
extensions/discord/src/directory-config.ts
Normal file
57
extensions/discord/src/directory-config.ts
Normal 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));
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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({
|
||||
eventQueue: { listenerTimeout: 300_000 },
|
||||
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({
|
||||
inboundWorker: { runTimeoutMs: 300_000 },
|
||||
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(),
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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
44
extensions/fal/index.ts
Normal 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
21
extensions/fal/onboard.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
27
extensions/fal/openclaw.plugin.json
Normal file
27
extensions/fal/openclaw.plugin.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
12
extensions/fal/package.json
Normal file
12
extensions/fal/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
471
extensions/feishu/src/bot-content.ts
Normal file
471
extensions/feishu/src/bot-content.ts
Normal 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, "<").replace(/>/g, ">");
|
||||
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;
|
||||
}
|
||||
@ -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, "<").replace(/>/g, ">");
|
||||
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.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) ||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
32
extensions/line/src/config-adapter.ts
Normal file
32
extensions/line/src/config-adapter.ts
Normal 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),
|
||||
});
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
183
extensions/mattermost/src/mattermost/monitor-resources.ts
Normal file
183
extensions/mattermost/src/mattermost/monitor-resources.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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,123 +561,25 @@ 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,
|
||||
accountId: account.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 {};
|
||||
};
|
||||
const {
|
||||
resolveMattermostMedia,
|
||||
sendTypingIndicator,
|
||||
resolveChannelInfo,
|
||||
resolveUserInfo,
|
||||
updateModelPickerPost,
|
||||
} = createMattermostMonitorResources({
|
||||
accountId: account.accountId,
|
||||
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 runModelPickerCommand = async (params: {
|
||||
commandText: string;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
),
|
||||
|
||||
@ -1,2 +1 @@
|
||||
export * from "openclaw/plugin-sdk/signal";
|
||||
export * from "openclaw/plugin-sdk/signal-core";
|
||||
export * from "./src/index.js";
|
||||
|
||||
@ -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"],
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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 = (
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
},
|
||||
|
||||
60
extensions/slack/src/directory-config.ts
Normal file
60
extensions/slack/src/directory-config.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -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>,
|
||||
|
||||
34
extensions/slack/src/runtime-api.ts
Normal file
34
extensions/slack/src/runtime-api.ts
Normal 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";
|
||||
@ -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<
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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: {
|
||||
|
||||
28
extensions/telegram/src/bot-deps.ts
Normal file
28
extensions/telegram/src/bot-deps.ts
Normal 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,
|
||||
};
|
||||
373
extensions/telegram/src/bot-handlers.buffers.ts
Normal file
373
extensions/telegram/src/bot-handlers.buffers.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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" },
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
4
extensions/telegram/src/bot.runtime.ts
Normal file
4
extensions/telegram/src/bot.runtime.ts
Normal 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";
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user