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:
|
- changed-files:
|
||||||
- any-glob-to-any-file:
|
- any-glob-to-any-file:
|
||||||
- "extensions/xiaomi/**"
|
- "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}"
|
echo "::error::Web search provider boundary grace period ended at ${WEB_SEARCH_PROVIDER_BOUNDARY_ENFORCE_AFTER}. ${fix_instructions}"
|
||||||
exit "$status"
|
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:
|
build-smoke:
|
||||||
name: "build-smoke"
|
name: "build-smoke"
|
||||||
needs: [docs-scope, changed-scope]
|
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.
|
- 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.
|
- 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/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
|
### 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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
|
### Breaking
|
||||||
|
|
||||||
|
|||||||
@ -276,9 +276,9 @@ Note: plugins can add additional top-level commands (for example `openclaw voice
|
|||||||
## Secrets
|
## Secrets
|
||||||
|
|
||||||
- `openclaw secrets reload` — re-resolve refs and atomically swap the runtime snapshot.
|
- `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 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.
|
- `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).
|
- `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
|
## Plugins
|
||||||
|
|
||||||
|
|||||||
@ -14,9 +14,9 @@ Use `openclaw secrets` to manage SecretRefs and keep the active runtime snapshot
|
|||||||
Command roles:
|
Command roles:
|
||||||
|
|
||||||
- `reload`: gateway RPC (`secrets.reload`) that re-resolves refs and swaps runtime snapshot only on full success (no config writes).
|
- `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).
|
- `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:
|
Recommended operator loop:
|
||||||
|
|
||||||
@ -29,6 +29,8 @@ openclaw secrets audit --check
|
|||||||
openclaw secrets reload
|
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:
|
Exit code note for CI/gates:
|
||||||
|
|
||||||
- `audit --check` returns `1` on findings.
|
- `audit --check` returns `1` on findings.
|
||||||
@ -73,6 +75,7 @@ Header residue note:
|
|||||||
openclaw secrets audit
|
openclaw secrets audit
|
||||||
openclaw secrets audit --check
|
openclaw secrets audit --check
|
||||||
openclaw secrets audit --json
|
openclaw secrets audit --json
|
||||||
|
openclaw secrets audit --allow-exec
|
||||||
```
|
```
|
||||||
|
|
||||||
Exit behavior:
|
Exit behavior:
|
||||||
@ -83,6 +86,7 @@ Exit behavior:
|
|||||||
Report shape highlights:
|
Report shape highlights:
|
||||||
|
|
||||||
- `status`: `clean | findings | unresolved`
|
- `status`: `clean | findings | unresolved`
|
||||||
|
- `resolution`: `refsChecked`, `skippedExecRefs`, `resolvabilityComplete`
|
||||||
- `summary`: `plaintextCount`, `unresolvedRefCount`, `shadowedRefCount`, `legacyResidueCount`
|
- `summary`: `plaintextCount`, `unresolvedRefCount`, `shadowedRefCount`, `legacyResidueCount`
|
||||||
- finding codes:
|
- finding codes:
|
||||||
- `PLAINTEXT_FOUND`
|
- `PLAINTEXT_FOUND`
|
||||||
@ -115,6 +119,7 @@ Flags:
|
|||||||
- `--providers-only`: configure `secrets.providers` only, skip credential mapping.
|
- `--providers-only`: configure `secrets.providers` only, skip credential mapping.
|
||||||
- `--skip-provider-setup`: skip provider setup and map credentials to existing providers.
|
- `--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.
|
- `--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:
|
Notes:
|
||||||
|
|
||||||
@ -124,6 +129,7 @@ Notes:
|
|||||||
- `configure` supports creating new `auth-profiles.json` mappings directly in the picker flow.
|
- `configure` supports creating new `auth-profiles.json` mappings directly in the picker flow.
|
||||||
- Canonical supported surface: [SecretRef Credential Surface](/reference/secretref-credential-surface).
|
- Canonical supported surface: [SecretRef Credential Surface](/reference/secretref-credential-surface).
|
||||||
- It performs preflight resolution before apply.
|
- 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).
|
- Generated plans default to scrub options (`scrubEnv`, `scrubAuthProfilesForProviderTargets`, `scrubLegacyAuthJson` all enabled).
|
||||||
- Apply path is one-way for scrubbed plaintext values.
|
- Apply path is one-way for scrubbed plaintext values.
|
||||||
- Without `--apply`, CLI still prompts `Apply this plan now?` after preflight.
|
- Without `--apply`, CLI still prompts `Apply this plan now?` after preflight.
|
||||||
@ -141,10 +147,19 @@ Apply or preflight a plan generated previously:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json
|
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
|
||||||
|
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run --allow-exec
|
||||||
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --json
|
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):
|
Plan contract details (allowed target paths, validation rules, and failure semantics):
|
||||||
|
|
||||||
- [Secrets Apply Plan Contract](/gateway/secrets-plan-contract)
|
- [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.
|
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
|
## Runtime and audit scope notes
|
||||||
|
|
||||||
- Ref-only `auth-profiles.json` entries (`keyRef`/`tokenRef`) are included in runtime resolution and audit coverage.
|
- 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
|
# Then apply for real
|
||||||
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json
|
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.
|
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)
|
- precedence shadowing (`auth-profiles.json` taking priority over `openclaw.json` refs)
|
||||||
- legacy residues (`auth.json`, OAuth reminders)
|
- 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:
|
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`).
|
- 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
|
- runs preflight resolution
|
||||||
- can apply immediately
|
- 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:
|
Helpful modes:
|
||||||
|
|
||||||
- `openclaw secrets configure --providers-only`
|
- `openclaw secrets configure --providers-only`
|
||||||
@ -447,9 +457,16 @@ Apply a saved plan:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json
|
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
|
||||||
|
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:
|
For strict target/path contract details and exact rejection rules, see:
|
||||||
|
|
||||||
- [Secrets Apply Plan Contract](/gateway/secrets-plan-contract)
|
- [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
|
- If plugin config exists but the plugin is **disabled**, the config is kept and
|
||||||
a **warning** is surfaced in Doctor + logs.
|
a **warning** is surfaced in Doctor + logs.
|
||||||
|
|
||||||
|
See [Configuration reference](/configuration) for the full `plugins.*` schema.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- The manifest is **required for native OpenClaw plugins**, including local filesystem loads.
|
- The manifest is **required for native OpenClaw plugins**, including local filesystem loads.
|
||||||
@ -131,7 +133,9 @@ Example:
|
|||||||
runtime just to inspect env names.
|
runtime just to inspect env names.
|
||||||
- `providerAuthChoices` is the cheap metadata path for auth-choice pickers,
|
- `providerAuthChoices` is the cheap metadata path for auth-choice pickers,
|
||||||
`--auth-choice` resolution, preferred-provider mapping, and simple onboarding
|
`--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.*`.
|
- Exclusive plugin kinds are selected through `plugins.slots.*`.
|
||||||
- `kind: "memory"` is selected by `plugins.slots.memory`.
|
- `kind: "memory"` is selected by `plugins.slots.memory`.
|
||||||
- `kind: "context-engine"` is selected by `plugins.slots.contextEngine`
|
- `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)
|
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
|
||||||
- [Venice (Venice AI, privacy-focused)](/providers/venice)
|
- [Venice (Venice AI, privacy-focused)](/providers/venice)
|
||||||
- [vLLM (local models)](/providers/vllm)
|
- [vLLM (local models)](/providers/vllm)
|
||||||
|
- [xAI](/providers/xai)
|
||||||
- [Xiaomi](/providers/xiaomi)
|
- [Xiaomi](/providers/xiaomi)
|
||||||
- [Z.AI](/providers/zai)
|
- [Z.AI](/providers/zai)
|
||||||
|
|
||||||
|
|||||||
@ -39,6 +39,7 @@ model as `provider/model`.
|
|||||||
- [Venice (Venice AI)](/providers/venice)
|
- [Venice (Venice AI)](/providers/venice)
|
||||||
- [Amazon Bedrock](/providers/bedrock)
|
- [Amazon Bedrock](/providers/bedrock)
|
||||||
- [Qianfan](/providers/qianfan)
|
- [Qianfan](/providers/qianfan)
|
||||||
|
- [xAI](/providers/xai)
|
||||||
|
|
||||||
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
|
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
|
||||||
see [Model providers](/concepts/model-providers).
|
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)
|
- **Skills** (by listing `skills` directories in the plugin manifest)
|
||||||
- **Auto-reply commands** (execute without invoking the AI agent)
|
- **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).
|
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
|
||||||
|
|
||||||
Think of these registrations as **capability claims**. A plugin is not supposed
|
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/twitch`, `openclaw/plugin-sdk/voice-call`,
|
||||||
`openclaw/plugin-sdk/zalo`, and `openclaw/plugin-sdk/zalouser`.
|
`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 catalogs
|
||||||
|
|
||||||
Provider plugins can define model catalogs for inference with
|
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.tgz # install from a local tarball
|
||||||
openclaw plugins install ./plugin.zip # install from a local zip
|
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 -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 install @openclaw/voice-call --pin # store exact resolved name@version
|
||||||
openclaw plugins update <id>
|
openclaw plugins update <id>
|
||||||
openclaw plugins update --all
|
openclaw plugins update --all
|
||||||
@ -1618,14 +1667,11 @@ openclaw plugins disable <id>
|
|||||||
openclaw plugins doctor
|
openclaw plugins doctor
|
||||||
```
|
```
|
||||||
|
|
||||||
`openclaw plugins list` shows the top-level format as `openclaw` or `bundle`.
|
See [`openclaw plugins` CLI reference](/cli/plugins) for full details on each
|
||||||
Verbose list/inspect output also shows bundle subtype (`codex`, `claude`, or
|
command (install rules, inspect output, marketplace installs, uninstall).
|
||||||
`cursor`) plus detected bundle capabilities.
|
|
||||||
|
|
||||||
`plugins update` only works for npm installs tracked under `plugins.installs`.
|
Plugins may also register their own top-level commands (example:
|
||||||
If stored integrity metadata changes between updates, OpenClaw warns and asks for confirmation (use global `--yes` to bypass prompts).
|
`openclaw voicecall`).
|
||||||
|
|
||||||
Plugins may also register their own top‑level commands (example: `openclaw voicecall`).
|
|
||||||
|
|
||||||
## Plugin API (overview)
|
## Plugin API (overview)
|
||||||
|
|
||||||
@ -2433,7 +2479,7 @@ See [Voice Call](/plugins/voice-call) and `extensions/voice-call/README.md` for
|
|||||||
|
|
||||||
## Safety notes
|
## 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.
|
- Only install plugins you trust.
|
||||||
- Prefer `plugins.allow` allowlists.
|
- Prefer `plugins.allow` allowlists.
|
||||||
|
|||||||
@ -19,4 +19,27 @@ describe("amazon-bedrock provider plugin", () => {
|
|||||||
} as never),
|
} as never),
|
||||||
).toBeUndefined();
|
).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 { definePluginEntry } from "openclaw/plugin-sdk/core";
|
||||||
|
import {
|
||||||
|
createBedrockNoCacheWrapper,
|
||||||
|
isAnthropicBedrockModel,
|
||||||
|
} from "openclaw/plugin-sdk/provider-stream";
|
||||||
|
|
||||||
const PROVIDER_ID = "amazon-bedrock";
|
const PROVIDER_ID = "amazon-bedrock";
|
||||||
const CLAUDE_46_MODEL_RE = /claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i;
|
const CLAUDE_46_MODEL_RE = /claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i;
|
||||||
@ -13,6 +17,8 @@ export default definePluginEntry({
|
|||||||
label: "Amazon Bedrock",
|
label: "Amazon Bedrock",
|
||||||
docsPath: "/providers/models",
|
docsPath: "/providers/models",
|
||||||
auth: [],
|
auth: [],
|
||||||
|
wrapStreamFn: ({ modelId, streamFn }) =>
|
||||||
|
isAnthropicBedrockModel(modelId) ? streamFn : createBedrockNoCacheWrapper(streamFn),
|
||||||
resolveDefaultThinkingLevel: ({ modelId }) =>
|
resolveDefaultThinkingLevel: ({ modelId }) =>
|
||||||
CLAUDE_46_MODEL_RE.test(modelId.trim()) ? "adaptive" : undefined,
|
CLAUDE_46_MODEL_RE.test(modelId.trim()) ? "adaptive" : undefined,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
|
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
|
||||||
import {
|
import {
|
||||||
createScopedAccountConfigAccessors,
|
createScopedChannelConfigAdapter,
|
||||||
createScopedChannelConfigBase,
|
|
||||||
createScopedDmSecurityResolver,
|
createScopedDmSecurityResolver,
|
||||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
} from "openclaw/plugin-sdk/channel-config-helpers";
|
||||||
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
|
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 { blueBubblesSetupWizard } from "./setup-surface.js";
|
||||||
import {
|
import {
|
||||||
extractHandleFromChatGuid,
|
extractHandleFromChatGuid,
|
||||||
|
inferBlueBubblesTargetChatType,
|
||||||
|
looksLikeBlueBubblesExplicitTargetId,
|
||||||
looksLikeBlueBubblesTargetId,
|
looksLikeBlueBubblesTargetId,
|
||||||
normalizeBlueBubblesHandle,
|
normalizeBlueBubblesHandle,
|
||||||
normalizeBlueBubblesMessagingTarget,
|
normalizeBlueBubblesMessagingTarget,
|
||||||
@ -45,8 +46,12 @@ const loadBlueBubblesChannelRuntime = createLazyRuntimeNamedExport(
|
|||||||
"blueBubblesChannelRuntime",
|
"blueBubblesChannelRuntime",
|
||||||
);
|
);
|
||||||
|
|
||||||
const bluebubblesConfigAccessors = createScopedAccountConfigAccessors({
|
const bluebubblesConfigAdapter = createScopedChannelConfigAdapter<ResolvedBlueBubblesAccount>({
|
||||||
resolveAccount: ({ cfg, accountId }) => resolveBlueBubblesAccount({ cfg, accountId }),
|
sectionKey: "bluebubbles",
|
||||||
|
listAccountIds: listBlueBubblesAccountIds,
|
||||||
|
resolveAccount: (cfg, accountId) => resolveBlueBubblesAccount({ cfg, accountId }),
|
||||||
|
defaultAccountId: resolveDefaultBlueBubblesAccountId,
|
||||||
|
clearBaseFields: ["serverUrl", "password", "name", "webhookPath"],
|
||||||
resolveAllowFrom: (account: ResolvedBlueBubblesAccount) => account.config.allowFrom,
|
resolveAllowFrom: (account: ResolvedBlueBubblesAccount) => account.config.allowFrom,
|
||||||
formatAllowFrom: (allowFrom) =>
|
formatAllowFrom: (allowFrom) =>
|
||||||
formatNormalizedAllowFromEntries({
|
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>({
|
const resolveBlueBubblesDmPolicy = createScopedDmSecurityResolver<ResolvedBlueBubblesAccount>({
|
||||||
channelKey: "bluebubbles",
|
channelKey: "bluebubbles",
|
||||||
resolvePolicy: (account) => account.config.dmPolicy,
|
resolvePolicy: (account) => account.config.dmPolicy,
|
||||||
@ -113,7 +110,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema),
|
configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema),
|
||||||
setupWizard: blueBubblesSetupWizard,
|
setupWizard: blueBubblesSetupWizard,
|
||||||
config: {
|
config: {
|
||||||
...bluebubblesConfigBase,
|
...bluebubblesConfigAdapter,
|
||||||
isConfigured: (account) => account.configured,
|
isConfigured: (account) => account.configured,
|
||||||
describeAccount: (account): ChannelAccountSnapshot => ({
|
describeAccount: (account): ChannelAccountSnapshot => ({
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
@ -122,7 +119,6 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
configured: account.configured,
|
configured: account.configured,
|
||||||
baseUrl: account.baseUrl,
|
baseUrl: account.baseUrl,
|
||||||
}),
|
}),
|
||||||
...bluebubblesConfigAccessors,
|
|
||||||
},
|
},
|
||||||
actions: bluebubblesMessageActions,
|
actions: bluebubblesMessageActions,
|
||||||
security: {
|
security: {
|
||||||
@ -141,10 +137,26 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
|||||||
},
|
},
|
||||||
messaging: {
|
messaging: {
|
||||||
normalizeTarget: normalizeBlueBubblesMessagingTarget,
|
normalizeTarget: normalizeBlueBubblesMessagingTarget,
|
||||||
|
inferTargetChatType: ({ to }) => inferBlueBubblesTargetChatType(to),
|
||||||
resolveOutboundSessionRoute: (params) => resolveBlueBubblesOutboundSessionRoute(params),
|
resolveOutboundSessionRoute: (params) => resolveBlueBubblesOutboundSessionRoute(params),
|
||||||
targetResolver: {
|
targetResolver: {
|
||||||
looksLikeId: looksLikeBlueBubblesTargetId,
|
looksLikeId: looksLikeBlueBubblesExplicitTargetId,
|
||||||
hint: "<handle|chat_guid:GUID|chat_id:ID|chat_identifier:ID>",
|
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 }) => {
|
formatTargetDisplay: ({ target, display }) => {
|
||||||
const shouldParseDisplay = (value: string): boolean => {
|
const shouldParseDisplay = (value: string): boolean => {
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
inferBlueBubblesTargetChatType,
|
||||||
|
looksLikeBlueBubblesExplicitTargetId,
|
||||||
isAllowedBlueBubblesSender,
|
isAllowedBlueBubblesSender,
|
||||||
looksLikeBlueBubblesTargetId,
|
looksLikeBlueBubblesTargetId,
|
||||||
normalizeBlueBubblesMessagingTarget,
|
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", () => {
|
describe("parseBlueBubblesTarget", () => {
|
||||||
it("parses chat<digits> pattern as chat_identifier", () => {
|
it("parses chat<digits> pattern as chat_identifier", () => {
|
||||||
expect(parseBlueBubblesTarget("chat660250192681427962")).toEqual({
|
expect(parseBlueBubblesTarget("chat660250192681427962")).toEqual({
|
||||||
|
|||||||
@ -237,6 +237,63 @@ export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string):
|
|||||||
return false;
|
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 {
|
export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
|
||||||
const trimmed = stripBlueBubblesPrefix(raw);
|
const trimmed = stripBlueBubblesPrefix(raw);
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
|
|||||||
@ -1,8 +1,34 @@
|
|||||||
{
|
{
|
||||||
"id": "brave",
|
"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": {
|
"configSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"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,
|
withTrustedWebSearchEndpoint,
|
||||||
writeCachedSearchPayload,
|
writeCachedSearchPayload,
|
||||||
} from "../../../src/agents/tools/web-search-provider-common.js";
|
} 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 { formatCliCommand } from "../../../src/cli/command-format.js";
|
||||||
|
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||||
import type {
|
import type {
|
||||||
WebSearchProviderPlugin,
|
WebSearchProviderPlugin,
|
||||||
WebSearchProviderToolDefinition,
|
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;
|
const BRAVE_UI_LANG_LOCALE = /^([a-z]{2})-([a-z]{2})$/i;
|
||||||
|
|
||||||
type BraveConfig = {
|
type BraveConfig = {
|
||||||
|
apiKey?: unknown;
|
||||||
mode?: string;
|
mode?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -112,18 +118,41 @@ type BraveLlmContextResponse = {
|
|||||||
sources?: { url?: string; hostname?: string; date?: string }[];
|
sources?: { url?: string; hostname?: string; date?: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolveBraveConfig(searchConfig?: SearchConfigRecord): BraveConfig {
|
function resolveBraveConfig(
|
||||||
const brave = searchConfig?.brave;
|
config?: OpenClawConfig,
|
||||||
return brave && typeof brave === "object" && !Array.isArray(brave) ? (brave as BraveConfig) : {};
|
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" {
|
function resolveBraveMode(brave: BraveConfig): "web" | "llm-context" {
|
||||||
return brave.mode === "llm-context" ? "llm-context" : "web";
|
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 (
|
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"])
|
readProviderEnvValue(["BRAVE_API_KEY"])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -384,9 +413,10 @@ function missingBraveKeyPayload() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createBraveToolDefinition(
|
function createBraveToolDefinition(
|
||||||
|
config?: OpenClawConfig,
|
||||||
searchConfig?: SearchConfigRecord,
|
searchConfig?: SearchConfigRecord,
|
||||||
): WebSearchProviderToolDefinition {
|
): WebSearchProviderToolDefinition {
|
||||||
const braveConfig = resolveBraveConfig(searchConfig);
|
const braveConfig = resolveBraveConfig(config, searchConfig);
|
||||||
const braveMode = resolveBraveMode(braveConfig);
|
const braveMode = resolveBraveMode(braveConfig);
|
||||||
|
|
||||||
return {
|
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.",
|
: "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(),
|
parameters: createBraveSchema(),
|
||||||
execute: async (args) => {
|
execute: async (args) => {
|
||||||
const apiKey = resolveBraveApiKey(searchConfig);
|
const apiKey = resolveBraveApiKey(config, searchConfig);
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return missingBraveKeyPayload();
|
return missingBraveKeyPayload();
|
||||||
}
|
}
|
||||||
@ -594,14 +624,19 @@ export function createBraveWebSearchProvider(): WebSearchProviderPlugin {
|
|||||||
signupUrl: "https://brave.com/search/api/",
|
signupUrl: "https://brave.com/search/api/",
|
||||||
docsUrl: "https://docs.openclaw.ai/brave-search",
|
docsUrl: "https://docs.openclaw.ai/brave-search",
|
||||||
autoDetectOrder: 10,
|
autoDetectOrder: 10,
|
||||||
credentialPath: "tools.web.search.apiKey",
|
credentialPath: "plugins.entries.brave.config.webSearch.apiKey",
|
||||||
inactiveSecretPaths: ["tools.web.search.apiKey"],
|
inactiveSecretPaths: ["plugins.entries.brave.config.webSearch.apiKey"],
|
||||||
getCredentialValue: (searchConfig) => searchConfig?.apiKey,
|
getCredentialValue: (searchConfig) => searchConfig?.apiKey,
|
||||||
setCredentialValue: (searchConfigTarget, value) => {
|
setCredentialValue: (searchConfigTarget, value) => {
|
||||||
searchConfigTarget.apiKey = value;
|
searchConfigTarget.apiKey = value;
|
||||||
},
|
},
|
||||||
|
getConfiguredCredentialValue: (config) =>
|
||||||
|
resolveProviderWebSearchPluginConfig(config, "brave")?.apiKey,
|
||||||
|
setConfiguredCredentialValue: (configTarget, value) => {
|
||||||
|
setProviderWebSearchPluginConfigValue(configTarget, "brave", "apiKey", value);
|
||||||
|
},
|
||||||
createTool: (ctx) =>
|
createTool: (ctx) =>
|
||||||
createBraveToolDefinition(ctx.searchConfig as SearchConfigRecord | undefined),
|
createBraveToolDefinition(ctx.config, ctx.searchConfig as SearchConfigRecord | undefined),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,10 @@ import {
|
|||||||
type ResolvedDiscordAccount,
|
type ResolvedDiscordAccount,
|
||||||
} from "./accounts.js";
|
} from "./accounts.js";
|
||||||
import { auditDiscordChannelPermissions, collectDiscordAuditChannelIds } from "./audit.js";
|
import { auditDiscordChannelPermissions, collectDiscordAuditChannelIds } from "./audit.js";
|
||||||
|
import {
|
||||||
|
listDiscordDirectoryGroupsFromConfig,
|
||||||
|
listDiscordDirectoryPeersFromConfig,
|
||||||
|
} from "./directory-config.js";
|
||||||
import {
|
import {
|
||||||
isDiscordExecApprovalClientEnabled,
|
isDiscordExecApprovalClientEnabled,
|
||||||
shouldSuppressLocalDiscordExecApprovalPrompt,
|
shouldSuppressLocalDiscordExecApprovalPrompt,
|
||||||
@ -41,8 +45,6 @@ import {
|
|||||||
type ChannelPlugin,
|
type ChannelPlugin,
|
||||||
DEFAULT_ACCOUNT_ID,
|
DEFAULT_ACCOUNT_ID,
|
||||||
getChatChannelMeta,
|
getChatChannelMeta,
|
||||||
listDiscordDirectoryGroupsFromConfig,
|
|
||||||
listDiscordDirectoryPeersFromConfig,
|
|
||||||
PAIRING_APPROVED_MESSAGE,
|
PAIRING_APPROVED_MESSAGE,
|
||||||
projectCredentialSnapshotFields,
|
projectCredentialSnapshotFields,
|
||||||
resolveConfiguredFromCredentialStatuses,
|
resolveConfiguredFromCredentialStatuses,
|
||||||
@ -51,7 +53,7 @@ import {
|
|||||||
import { getDiscordRuntime } from "./runtime.js";
|
import { getDiscordRuntime } from "./runtime.js";
|
||||||
import { fetchChannelPermissionsDiscord } from "./send.js";
|
import { fetchChannelPermissionsDiscord } from "./send.js";
|
||||||
import { discordSetupAdapter } from "./setup-core.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 { collectDiscordStatusIssues } from "./status-issues.js";
|
||||||
import { parseDiscordTarget } from "./targets.js";
|
import { parseDiscordTarget } from "./targets.js";
|
||||||
import { DiscordUiContainer } from "./ui.js";
|
import { DiscordUiContainer } from "./ui.js";
|
||||||
@ -305,7 +307,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
|||||||
applyConfigEdit: buildAccountScopedAllowlistConfigEditor({
|
applyConfigEdit: buildAccountScopedAllowlistConfigEditor({
|
||||||
channelId: "discord",
|
channelId: "discord",
|
||||||
normalize: ({ cfg, accountId, values }) =>
|
normalize: ({ cfg, accountId, values }) =>
|
||||||
discordConfigAccessors.formatAllowFrom!({ cfg, accountId, allowFrom: values }),
|
discordConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }),
|
||||||
resolvePaths: resolveLegacyDmAllowlistConfigPaths,
|
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 { 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 { OpenClawConfig } from "../../../src/config/config.js";
|
||||||
|
import type { DirectoryConfigParams } from "../../../src/plugin-sdk/directory-runtime.js";
|
||||||
import { listDiscordDirectoryGroupsLive, listDiscordDirectoryPeersLive } from "./directory-live.js";
|
import { listDiscordDirectoryGroupsLive, listDiscordDirectoryPeersLive } from "./directory-live.js";
|
||||||
|
|
||||||
function makeParams(overrides: Partial<DirectoryConfigParams> = {}): DirectoryConfigParams {
|
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 { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { NativeCommandSpec } from "../../../../src/auto-reply/commands-registry.js";
|
import type { NativeCommandSpec } from "../../../../src/auto-reply/commands-registry.js";
|
||||||
import * as dispatcherModule from "../../../../src/auto-reply/reply/provider-dispatcher.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 type { OpenClawConfig } from "../../../../src/config/config.js";
|
||||||
import * as pluginCommandsModule from "../../../../src/plugins/commands.js";
|
import * as pluginCommandsModule from "../../../../src/plugins/commands.js";
|
||||||
import { clearPluginCommands, registerPluginCommand } from "../../../../src/plugins/commands.js";
|
import { clearPluginCommands, registerPluginCommand } from "../../../../src/plugins/commands.js";
|
||||||
@ -12,32 +12,26 @@ import {
|
|||||||
} from "./native-command.test-helpers.js";
|
} from "./native-command.test-helpers.js";
|
||||||
import { createNoopThreadBindingManager } from "./thread-bindings.js";
|
import { createNoopThreadBindingManager } from "./thread-bindings.js";
|
||||||
|
|
||||||
type ResolveConfiguredBindingRouteFn =
|
|
||||||
typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConfiguredBindingRoute;
|
|
||||||
type EnsureConfiguredBindingRouteReadyFn =
|
type EnsureConfiguredBindingRouteReadyFn =
|
||||||
typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredBindingRouteReady;
|
typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredBindingRouteReady;
|
||||||
|
|
||||||
const persistentBindingMocks = vi.hoisted(() => ({
|
const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() =>
|
||||||
resolveConfiguredAcpBindingRecord: vi.fn<ResolveConfiguredBindingRouteFn>((params) => ({
|
vi.fn<EnsureConfiguredBindingRouteReadyFn>(async () => ({
|
||||||
bindingResolution: null,
|
|
||||||
route: params.route,
|
|
||||||
})),
|
|
||||||
ensureConfiguredAcpBindingSession: vi.fn<EnsureConfiguredBindingRouteReadyFn>(async () => ({
|
|
||||||
ok: true,
|
ok: true,
|
||||||
})),
|
})),
|
||||||
}));
|
);
|
||||||
|
|
||||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
|
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
resolveConfiguredBindingRoute: persistentBindingMocks.resolveConfiguredAcpBindingRecord,
|
ensureConfiguredBindingRouteReady: (...args: unknown[]) =>
|
||||||
ensureConfiguredBindingRouteReady: persistentBindingMocks.ensureConfiguredAcpBindingSession,
|
ensureConfiguredBindingRouteReadyMock(
|
||||||
|
...(args as Parameters<EnsureConfiguredBindingRouteReadyFn>),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
import { createDiscordNativeCommand } from "./native-command.js";
|
|
||||||
|
|
||||||
function createInteraction(params?: {
|
function createInteraction(params?: {
|
||||||
channelType?: ChannelType;
|
channelType?: ChannelType;
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
@ -66,7 +60,12 @@ function createConfig(): OpenClawConfig {
|
|||||||
} as 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({
|
return createDiscordNativeCommand({
|
||||||
command: commandSpec,
|
command: commandSpec,
|
||||||
cfg,
|
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({
|
return createDiscordNativeCommand({
|
||||||
command: {
|
command: {
|
||||||
name: params.name,
|
name: params.name,
|
||||||
@ -119,7 +119,7 @@ async function expectPairCommandReply(params: {
|
|||||||
commandName: string;
|
commandName: string;
|
||||||
interaction: MockCommandInteraction;
|
interaction: MockCommandInteraction;
|
||||||
}) {
|
}) {
|
||||||
const command = createPluginCommand({
|
const command = await createPluginCommand({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
name: params.commandName,
|
name: params.commandName,
|
||||||
});
|
});
|
||||||
@ -143,150 +143,14 @@ async function expectPairCommandReply(params: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createStatusCommand(cfg: OpenClawConfig) {
|
async function createStatusCommand(cfg: OpenClawConfig) {
|
||||||
return createNativeCommand(cfg, {
|
return await createNativeCommand(cfg, {
|
||||||
name: "status",
|
name: "status",
|
||||||
description: "Status",
|
description: "Status",
|
||||||
acceptsArgs: false,
|
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() {
|
function createDispatchSpy() {
|
||||||
return vi.spyOn(dispatcherModule, "dispatchReplyWithDispatcher").mockResolvedValue({
|
return vi.spyOn(dispatcherModule, "dispatchReplyWithDispatcher").mockResolvedValue({
|
||||||
counts: {
|
counts: {
|
||||||
@ -299,26 +163,23 @@ function createDispatchSpy() {
|
|||||||
|
|
||||||
function expectBoundSessionDispatch(
|
function expectBoundSessionDispatch(
|
||||||
dispatchSpy: ReturnType<typeof createDispatchSpy>,
|
dispatchSpy: ReturnType<typeof createDispatchSpy>,
|
||||||
boundSessionKey: string,
|
expectedPattern: RegExp,
|
||||||
) {
|
) {
|
||||||
expect(dispatchSpy).toHaveBeenCalledTimes(1);
|
expect(dispatchSpy).toHaveBeenCalledTimes(1);
|
||||||
const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as {
|
const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as {
|
||||||
ctx?: { SessionKey?: string; CommandTargetSessionKey?: string };
|
ctx?: { SessionKey?: string; CommandTargetSessionKey?: string };
|
||||||
};
|
};
|
||||||
expect(dispatchCall.ctx?.SessionKey).toBe(boundSessionKey);
|
expect(dispatchCall.ctx?.SessionKey).toMatch(expectedPattern);
|
||||||
expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(boundSessionKey);
|
expect(dispatchCall.ctx?.CommandTargetSessionKey).toMatch(expectedPattern);
|
||||||
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1);
|
expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1);
|
||||||
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).toHaveBeenCalledTimes(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectBoundStatusCommandDispatch(params: {
|
async function expectBoundStatusCommandDispatch(params: {
|
||||||
cfg: OpenClawConfig;
|
cfg: OpenClawConfig;
|
||||||
interaction: MockCommandInteraction;
|
interaction: MockCommandInteraction;
|
||||||
channelId: string;
|
expectedPattern: RegExp;
|
||||||
boundSessionKey: string;
|
|
||||||
}) {
|
}) {
|
||||||
const command = createStatusCommand(params.cfg);
|
const command = await createStatusCommand(params.cfg);
|
||||||
setConfiguredBinding(params.channelId, params.boundSessionKey);
|
|
||||||
|
|
||||||
vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null);
|
vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null);
|
||||||
const dispatchSpy = createDispatchSpy();
|
const dispatchSpy = createDispatchSpy();
|
||||||
@ -327,20 +188,16 @@ async function expectBoundStatusCommandDispatch(params: {
|
|||||||
params.interaction as unknown,
|
params.interaction as unknown,
|
||||||
);
|
);
|
||||||
|
|
||||||
expectBoundSessionDispatch(dispatchSpy, params.boundSessionKey);
|
expectBoundSessionDispatch(dispatchSpy, params.expectedPattern);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("Discord native plugin command dispatch", () => {
|
describe("Discord native plugin command dispatch", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
clearPluginCommands();
|
clearPluginCommands();
|
||||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReset();
|
setDefaultChannelPluginRegistryForTests();
|
||||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockImplementation((params) => ({
|
ensureConfiguredBindingRouteReadyMock.mockReset();
|
||||||
bindingResolution: null,
|
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({
|
||||||
route: params.route,
|
|
||||||
}));
|
|
||||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockReset();
|
|
||||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
|
|
||||||
ok: true,
|
ok: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -397,15 +254,7 @@ describe("Discord native plugin command dispatch", () => {
|
|||||||
description: "Pair",
|
description: "Pair",
|
||||||
acceptsArgs: true,
|
acceptsArgs: true,
|
||||||
};
|
};
|
||||||
const command = createDiscordNativeCommand({
|
const command = await createNativeCommand(cfg, commandSpec);
|
||||||
command: commandSpec,
|
|
||||||
cfg,
|
|
||||||
discordConfig: cfg.channels?.discord ?? {},
|
|
||||||
accountId: "default",
|
|
||||||
sessionPrefix: "discord:slash",
|
|
||||||
ephemeralDefault: true,
|
|
||||||
threadBindings: createNoopThreadBindingManager("default"),
|
|
||||||
});
|
|
||||||
const interaction = createInteraction({
|
const interaction = createInteraction({
|
||||||
channelType: ChannelType.GuildText,
|
channelType: ChannelType.GuildText,
|
||||||
channelId: "234567890123456789",
|
channelId: "234567890123456789",
|
||||||
@ -449,15 +298,7 @@ describe("Discord native plugin command dispatch", () => {
|
|||||||
description: "List cron jobs",
|
description: "List cron jobs",
|
||||||
acceptsArgs: false,
|
acceptsArgs: false,
|
||||||
};
|
};
|
||||||
const command = createDiscordNativeCommand({
|
const command = await createNativeCommand(cfg, commandSpec);
|
||||||
command: commandSpec,
|
|
||||||
cfg,
|
|
||||||
discordConfig: cfg.channels?.discord ?? {},
|
|
||||||
accountId: "default",
|
|
||||||
sessionPrefix: "discord:slash",
|
|
||||||
ephemeralDefault: true,
|
|
||||||
threadBindings: createNoopThreadBindingManager("default"),
|
|
||||||
});
|
|
||||||
const interaction = createInteraction();
|
const interaction = createInteraction();
|
||||||
const pluginMatch = {
|
const pluginMatch = {
|
||||||
command: {
|
command: {
|
||||||
@ -492,11 +333,21 @@ describe("Discord native plugin command dispatch", () => {
|
|||||||
it("routes native slash commands through configured ACP Discord channel bindings", async () => {
|
it("routes native slash commands through configured ACP Discord channel bindings", async () => {
|
||||||
const guildId = "1459246755253325866";
|
const guildId = "1459246755253325866";
|
||||||
const channelId = "1478836151241412759";
|
const channelId = "1478836151241412759";
|
||||||
const boundSessionKey = "agent:codex:acp:binding:discord:default:feedface";
|
|
||||||
const cfg = {
|
const cfg = {
|
||||||
commands: {
|
commands: {
|
||||||
useAccessGroups: false,
|
useAccessGroups: false,
|
||||||
},
|
},
|
||||||
|
channels: {
|
||||||
|
discord: {
|
||||||
|
guilds: {
|
||||||
|
[guildId]: {
|
||||||
|
channels: {
|
||||||
|
[channelId]: { allow: true, requireMention: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
bindings: [
|
bindings: [
|
||||||
{
|
{
|
||||||
type: "acp",
|
type: "acp",
|
||||||
@ -522,8 +373,7 @@ describe("Discord native plugin command dispatch", () => {
|
|||||||
await expectBoundStatusCommandDispatch({
|
await expectBoundStatusCommandDispatch({
|
||||||
cfg,
|
cfg,
|
||||||
interaction,
|
interaction,
|
||||||
channelId,
|
expectedPattern: /^agent:codex:acp:binding:discord:default:/,
|
||||||
boundSessionKey,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -557,7 +407,7 @@ describe("Discord native plugin command dispatch", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as OpenClawConfig;
|
} as OpenClawConfig;
|
||||||
const command = createStatusCommand(cfg);
|
const command = await createStatusCommand(cfg);
|
||||||
const interaction = createInteraction({
|
const interaction = createInteraction({
|
||||||
channelType: ChannelType.GuildText,
|
channelType: ChannelType.GuildText,
|
||||||
channelId,
|
channelId,
|
||||||
@ -578,13 +428,11 @@ describe("Discord native plugin command dispatch", () => {
|
|||||||
expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(
|
expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(
|
||||||
"agent:qwen:discord:channel:1478836151241412759",
|
"agent:qwen:discord:channel:1478836151241412759",
|
||||||
);
|
);
|
||||||
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1);
|
expect(ensureConfiguredBindingRouteReadyMock).not.toHaveBeenCalled();
|
||||||
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("routes Discord DM native slash commands through configured ACP bindings", async () => {
|
it("routes Discord DM native slash commands through configured ACP bindings", async () => {
|
||||||
const channelId = "dm-1";
|
const channelId = "dm-1";
|
||||||
const boundSessionKey = "agent:codex:acp:binding:discord:default:dmfeedface";
|
|
||||||
const cfg = {
|
const cfg = {
|
||||||
commands: {
|
commands: {
|
||||||
useAccessGroups: false,
|
useAccessGroups: false,
|
||||||
@ -617,15 +465,13 @@ describe("Discord native plugin command dispatch", () => {
|
|||||||
await expectBoundStatusCommandDispatch({
|
await expectBoundStatusCommandDispatch({
|
||||||
cfg,
|
cfg,
|
||||||
interaction,
|
interaction,
|
||||||
channelId,
|
expectedPattern: /^agent:codex:acp:binding:discord:default:/,
|
||||||
boundSessionKey,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows recovery commands through configured ACP bindings even when ensure fails", async () => {
|
it("allows recovery commands through configured ACP bindings even when ensure fails", async () => {
|
||||||
const guildId = "1459246755253325866";
|
const guildId = "1459246755253325866";
|
||||||
const channelId = "1479098716916023408";
|
const channelId = "1479098716916023408";
|
||||||
const boundSessionKey = "agent:codex:acp:binding:discord:default:feedface";
|
|
||||||
const cfg = {
|
const cfg = {
|
||||||
commands: {
|
commands: {
|
||||||
useAccessGroups: false,
|
useAccessGroups: false,
|
||||||
@ -651,14 +497,13 @@ describe("Discord native plugin command dispatch", () => {
|
|||||||
guildId,
|
guildId,
|
||||||
guildName: "Ops",
|
guildName: "Ops",
|
||||||
});
|
});
|
||||||
const command = createNativeCommand(cfg, {
|
const command = await createNativeCommand(cfg, {
|
||||||
name: "new",
|
name: "new",
|
||||||
description: "Start a new session.",
|
description: "Start a new session.",
|
||||||
acceptsArgs: true,
|
acceptsArgs: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
setConfiguredBinding(channelId, boundSessionKey);
|
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({
|
||||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
|
|
||||||
ok: false,
|
ok: false,
|
||||||
error: "acpx exited with code 1",
|
error: "acpx exited with code 1",
|
||||||
});
|
});
|
||||||
@ -671,10 +516,11 @@ describe("Discord native plugin command dispatch", () => {
|
|||||||
const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as {
|
const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as {
|
||||||
ctx?: { SessionKey?: string; CommandTargetSessionKey?: string };
|
ctx?: { SessionKey?: string; CommandTargetSessionKey?: string };
|
||||||
};
|
};
|
||||||
expect(dispatchCall.ctx?.SessionKey).toBe(boundSessionKey);
|
expect(dispatchCall.ctx?.SessionKey).toMatch(/^agent:codex:acp:binding:discord:default:/);
|
||||||
expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(boundSessionKey);
|
expect(dispatchCall.ctx?.CommandTargetSessionKey).toMatch(
|
||||||
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1);
|
/^agent:codex:acp:binding:discord:default:/,
|
||||||
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).not.toHaveBeenCalled();
|
);
|
||||||
|
expect(ensureConfiguredBindingRouteReadyMock).not.toHaveBeenCalled();
|
||||||
expect(interaction.reply).not.toHaveBeenCalledWith(
|
expect(interaction.reply).not.toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
content: "Configured ACP binding is unavailable right now. Please try again.",
|
content: "Configured ACP binding is unavailable right now. Please try again.",
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import {
|
|||||||
baseRuntime,
|
baseRuntime,
|
||||||
getFirstDiscordMessageHandlerParams,
|
getFirstDiscordMessageHandlerParams,
|
||||||
getProviderMonitorTestMocks,
|
getProviderMonitorTestMocks,
|
||||||
mockResolvedDiscordAccountConfig,
|
|
||||||
resetDiscordProviderMonitorMocks,
|
resetDiscordProviderMonitorMocks,
|
||||||
} from "../../../../test/helpers/extensions/discord-provider.test-support.js";
|
} from "../../../../test/helpers/extensions/discord-provider.test-support.js";
|
||||||
|
|
||||||
@ -37,6 +36,21 @@ const {
|
|||||||
voiceRuntimeModuleLoadedMock,
|
voiceRuntimeModuleLoadedMock,
|
||||||
} = getProviderMonitorTestMocks();
|
} = 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 () => {
|
vi.mock("openclaw/plugin-sdk/plugin-runtime", async () => {
|
||||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/plugin-runtime")>(
|
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/plugin-runtime")>(
|
||||||
"openclaw/plugin-sdk/plugin-runtime",
|
"openclaw/plugin-sdk/plugin-runtime",
|
||||||
@ -90,7 +104,18 @@ describe("monitorDiscordProvider", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
resetDiscordProviderMonitorMocks();
|
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 () => {
|
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 () => {
|
it("loads the Discord voice runtime only when voice is enabled", async () => {
|
||||||
resolveDiscordAccountMock.mockReturnValue({
|
resolveDiscordAccountMock.mockReturnValue({
|
||||||
accountId: "default",
|
accountId: "default",
|
||||||
token: "cfg-token",
|
token: "MTIz.abc.def",
|
||||||
config: {
|
config: {
|
||||||
commands: { native: true, nativeSkills: false },
|
commands: { native: true, nativeSkills: false },
|
||||||
voice: { enabled: true },
|
voice: { enabled: true },
|
||||||
@ -356,11 +381,18 @@ describe("monitorDiscordProvider", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("forwards custom eventQueue config from discord config to Carbon Client", async () => {
|
it("forwards custom eventQueue config from discord config to Carbon Client", async () => {
|
||||||
const { monitorDiscordProvider } = await import("./provider.js");
|
resolveDiscordAccountMock.mockReturnValue({
|
||||||
|
accountId: "default",
|
||||||
mockResolvedDiscordAccountConfig({
|
token: "MTIz.abc.def",
|
||||||
eventQueue: { listenerTimeout: 300_000 },
|
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({
|
await monitorDiscordProvider({
|
||||||
config: baseConfig(),
|
config: baseConfig(),
|
||||||
@ -374,12 +406,10 @@ describe("monitorDiscordProvider", () => {
|
|||||||
it("does not reuse eventQueue.listenerTimeout as the queued inbound worker timeout", async () => {
|
it("does not reuse eventQueue.listenerTimeout as the queued inbound worker timeout", async () => {
|
||||||
const { monitorDiscordProvider } = await import("./provider.js");
|
const { monitorDiscordProvider } = await import("./provider.js");
|
||||||
|
|
||||||
mockResolvedDiscordAccountConfig({
|
|
||||||
eventQueue: { listenerTimeout: 50_000 },
|
|
||||||
});
|
|
||||||
|
|
||||||
await monitorDiscordProvider({
|
await monitorDiscordProvider({
|
||||||
config: baseConfig(),
|
config: createConfigWithDiscordAccount({
|
||||||
|
eventQueue: { listenerTimeout: 50_000 },
|
||||||
|
}),
|
||||||
runtime: baseRuntime(),
|
runtime: baseRuntime(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -392,11 +422,18 @@ describe("monitorDiscordProvider", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("forwards inbound worker timeout config to the Discord message handler", async () => {
|
it("forwards inbound worker timeout config to the Discord message handler", async () => {
|
||||||
const { monitorDiscordProvider } = await import("./provider.js");
|
resolveDiscordAccountMock.mockReturnValue({
|
||||||
|
accountId: "default",
|
||||||
mockResolvedDiscordAccountConfig({
|
token: "MTIz.abc.def",
|
||||||
inboundWorker: { runTimeoutMs: 300_000 },
|
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({
|
await monitorDiscordProvider({
|
||||||
config: baseConfig(),
|
config: baseConfig(),
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
export {
|
export {
|
||||||
buildComputedAccountStatusSnapshot,
|
buildComputedAccountStatusSnapshot,
|
||||||
buildTokenChannelStatusSummary,
|
buildTokenChannelStatusSummary,
|
||||||
listDiscordDirectoryGroupsFromConfig,
|
|
||||||
listDiscordDirectoryPeersFromConfig,
|
|
||||||
PAIRING_APPROVED_MESSAGE,
|
PAIRING_APPROVED_MESSAGE,
|
||||||
projectCredentialSnapshotFields,
|
projectCredentialSnapshotFields,
|
||||||
resolveConfiguredFromCredentialStatuses,
|
resolveConfiguredFromCredentialStatuses,
|
||||||
@ -21,8 +19,15 @@ export {
|
|||||||
export { DiscordConfigSchema } from "openclaw/plugin-sdk/discord-core";
|
export { DiscordConfigSchema } from "openclaw/plugin-sdk/discord-core";
|
||||||
export { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
|
export { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
|
||||||
export {
|
export {
|
||||||
|
listDiscordDirectoryGroupsFromConfig,
|
||||||
|
listDiscordDirectoryPeersFromConfig,
|
||||||
|
} from "./directory-config.js";
|
||||||
|
export {
|
||||||
|
createHybridChannelConfigAdapter,
|
||||||
|
createScopedChannelConfigAdapter,
|
||||||
createScopedAccountConfigAccessors,
|
createScopedAccountConfigAccessors,
|
||||||
createScopedChannelConfigBase,
|
createScopedChannelConfigBase,
|
||||||
|
createTopLevelChannelConfigAdapter,
|
||||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
} from "openclaw/plugin-sdk/channel-config-helpers";
|
||||||
export {
|
export {
|
||||||
createAccountActionGate,
|
createAccountActionGate,
|
||||||
@ -35,13 +40,16 @@ export type {
|
|||||||
ChannelMessageActionAdapter,
|
ChannelMessageActionAdapter,
|
||||||
ChannelMessageActionName,
|
ChannelMessageActionName,
|
||||||
} from "openclaw/plugin-sdk/channel-runtime";
|
} from "openclaw/plugin-sdk/channel-runtime";
|
||||||
export { withNormalizedTimestamp } from "../../../src/agents/date-time.js";
|
export {
|
||||||
export { assertMediaNotDataUrl } from "../../../src/agents/sandbox-paths.js";
|
assertMediaNotDataUrl,
|
||||||
export { parseAvailableTags, readReactionParams } from "openclaw/plugin-sdk/discord-core";
|
parseAvailableTags,
|
||||||
export { resolvePollMaxSelections } from "../../../src/polls.js";
|
readReactionParams,
|
||||||
export type { DiscordAccountConfig, DiscordActionConfig } from "../../../src/config/types.js";
|
resolvePollMaxSelections,
|
||||||
|
withNormalizedTimestamp,
|
||||||
|
} from "openclaw/plugin-sdk/discord-core";
|
||||||
|
export type { DiscordAccountConfig, DiscordActionConfig } from "openclaw/plugin-sdk/discord";
|
||||||
export {
|
export {
|
||||||
hasConfiguredSecretInput,
|
hasConfiguredSecretInput,
|
||||||
normalizeResolvedSecretInputString,
|
normalizeResolvedSecretInputString,
|
||||||
normalizeSecretInputString,
|
normalizeSecretInputString,
|
||||||
} from "../../../src/config/types.secrets.js";
|
} from "openclaw/plugin-sdk/config-runtime";
|
||||||
|
|||||||
@ -8,8 +8,7 @@ import {
|
|||||||
type ResolvedDiscordAccount,
|
type ResolvedDiscordAccount,
|
||||||
} from "./accounts.js";
|
} from "./accounts.js";
|
||||||
import {
|
import {
|
||||||
createScopedAccountConfigAccessors,
|
createScopedChannelConfigAdapter,
|
||||||
createScopedChannelConfigBase,
|
|
||||||
buildChannelConfigSchema,
|
buildChannelConfigSchema,
|
||||||
DiscordConfigSchema,
|
DiscordConfigSchema,
|
||||||
getChatChannelMeta,
|
getChatChannelMeta,
|
||||||
@ -27,20 +26,16 @@ export const discordSetupWizard = createDiscordSetupWizardProxy(
|
|||||||
async () => (await loadDiscordChannelRuntime()).discordSetupWizard,
|
async () => (await loadDiscordChannelRuntime()).discordSetupWizard,
|
||||||
);
|
);
|
||||||
|
|
||||||
export const discordConfigAccessors = createScopedAccountConfigAccessors({
|
export const discordConfigAdapter = createScopedChannelConfigAdapter<ResolvedDiscordAccount>({
|
||||||
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>({
|
|
||||||
sectionKey: DISCORD_CHANNEL,
|
sectionKey: DISCORD_CHANNEL,
|
||||||
listAccountIds: listDiscordAccountIds,
|
listAccountIds: listDiscordAccountIds,
|
||||||
resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }),
|
resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }),
|
||||||
inspectAccount: (cfg, accountId) => inspectDiscordAccount({ cfg, accountId }),
|
inspectAccount: (cfg, accountId) => inspectDiscordAccount({ cfg, accountId }),
|
||||||
defaultAccountId: resolveDefaultDiscordAccountId,
|
defaultAccountId: resolveDefaultDiscordAccountId,
|
||||||
clearBaseFields: ["token", "name"],
|
clearBaseFields: ["token", "name"],
|
||||||
|
resolveAllowFrom: (account: ResolvedDiscordAccount) => account.config.dm?.allowFrom,
|
||||||
|
formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }),
|
||||||
|
resolveDefaultTo: (account: ResolvedDiscordAccount) => account.config.defaultTo,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function createDiscordPluginBase(params: {
|
export function createDiscordPluginBase(params: {
|
||||||
@ -75,7 +70,7 @@ export function createDiscordPluginBase(params: {
|
|||||||
reload: { configPrefixes: ["channels.discord"] },
|
reload: { configPrefixes: ["channels.discord"] },
|
||||||
configSchema: buildChannelConfigSchema(DiscordConfigSchema),
|
configSchema: buildChannelConfigSchema(DiscordConfigSchema),
|
||||||
config: {
|
config: {
|
||||||
...discordConfigBase,
|
...discordConfigAdapter,
|
||||||
isConfigured: (account) => Boolean(account.token?.trim()),
|
isConfigured: (account) => Boolean(account.token?.trim()),
|
||||||
describeAccount: (account) => ({
|
describeAccount: (account) => ({
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
@ -84,7 +79,6 @@ export function createDiscordPluginBase(params: {
|
|||||||
configured: Boolean(account.token?.trim()),
|
configured: Boolean(account.token?.trim()),
|
||||||
tokenSource: account.tokenSource,
|
tokenSource: account.tokenSource,
|
||||||
}),
|
}),
|
||||||
...discordConfigAccessors,
|
|
||||||
},
|
},
|
||||||
setup: params.setup,
|
setup: params.setup,
|
||||||
}) as Pick<
|
}) 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,
|
warnMissingProviderGroupPolicyFallbackOnce,
|
||||||
} from "../runtime-api.js";
|
} from "../runtime-api.js";
|
||||||
import { resolveFeishuAccount } from "./accounts.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 { type FeishuPermissionError, resolveFeishuSenderName } from "./bot-sender-name.js";
|
||||||
import { createFeishuClient } from "./client.js";
|
import { createFeishuClient } from "./client.js";
|
||||||
import { buildFeishuConversationId } from "./conversation-id.js";
|
|
||||||
import { finalizeFeishuMessageProcessing, tryRecordMessagePersistent } from "./dedup.js";
|
import { finalizeFeishuMessageProcessing, tryRecordMessagePersistent } from "./dedup.js";
|
||||||
import { maybeCreateDynamicAgent } from "./dynamic-agent.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 { extractMentionTargets, isMentionForwardRequest } from "./mention.js";
|
||||||
import {
|
import {
|
||||||
resolveFeishuGroupConfig,
|
resolveFeishuGroupConfig,
|
||||||
@ -36,13 +43,14 @@ import {
|
|||||||
resolveFeishuAllowlistMatch,
|
resolveFeishuAllowlistMatch,
|
||||||
isFeishuGroupAllowed,
|
isFeishuGroupAllowed,
|
||||||
} from "./policy.js";
|
} from "./policy.js";
|
||||||
import { parsePostContent } from "./post.js";
|
|
||||||
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
|
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
|
||||||
import { getFeishuRuntime } from "./runtime.js";
|
import { getFeishuRuntime } from "./runtime.js";
|
||||||
import { getMessageFeishu, listFeishuThreadMessages, sendMessageFeishu } from "./send.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";
|
import type { DynamicAgentCreationConfig } from "./types.js";
|
||||||
|
|
||||||
|
export { toMessageResourceType } from "./bot-content.js";
|
||||||
|
|
||||||
// Cache permission errors to avoid spamming the user with repeated notifications.
|
// Cache permission errors to avoid spamming the user with repeated notifications.
|
||||||
// Key: appId or "default", Value: timestamp of last notification
|
// Key: appId or "default", Value: timestamp of last notification
|
||||||
const permissionErrorNotifiedAt = new Map<string, number>();
|
const permissionErrorNotifiedAt = new Map<string, number>();
|
||||||
@ -91,546 +99,6 @@ export type FeishuBotAddedEvent = {
|
|||||||
operator_tenant_key?: string;
|
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 ---
|
// --- Broadcast support ---
|
||||||
// Resolve broadcast agent list for a given peer (group) ID.
|
// 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.
|
// 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 { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
|
||||||
import {
|
import { createHybridChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers";
|
||||||
createHybridChannelConfigBase,
|
|
||||||
createScopedAccountConfigAccessors,
|
|
||||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
|
||||||
import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy";
|
import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy";
|
||||||
import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime";
|
import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime";
|
||||||
import type {
|
import type {
|
||||||
@ -130,17 +127,16 @@ function setFeishuNamedAccountEnabled(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const feishuConfigBase = createHybridChannelConfigBase<ResolvedFeishuAccount, ClawdbotConfig>({
|
const feishuConfigAdapter = createHybridChannelConfigAdapter<
|
||||||
|
ResolvedFeishuAccount,
|
||||||
|
ResolvedFeishuAccount,
|
||||||
|
ClawdbotConfig
|
||||||
|
>({
|
||||||
sectionKey: "feishu",
|
sectionKey: "feishu",
|
||||||
listAccountIds: listFeishuAccountIds,
|
listAccountIds: listFeishuAccountIds,
|
||||||
resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }),
|
resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }),
|
||||||
defaultAccountId: resolveDefaultFeishuAccountId,
|
defaultAccountId: resolveDefaultFeishuAccountId,
|
||||||
clearBaseFields: [],
|
clearBaseFields: [],
|
||||||
});
|
|
||||||
|
|
||||||
const feishuConfigAccessors = createScopedAccountConfigAccessors<ResolvedFeishuAccount>({
|
|
||||||
resolveAccount: ({ cfg, accountId }) =>
|
|
||||||
resolveFeishuAccount({ cfg: cfg as ClawdbotConfig, accountId }),
|
|
||||||
resolveAllowFrom: (account) => account.config.allowFrom,
|
resolveAllowFrom: (account) => account.config.allowFrom,
|
||||||
formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }),
|
formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }),
|
||||||
});
|
});
|
||||||
@ -396,7 +392,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|||||||
reload: { configPrefixes: ["channels.feishu"] },
|
reload: { configPrefixes: ["channels.feishu"] },
|
||||||
configSchema: buildChannelConfigSchema(FeishuConfigSchema),
|
configSchema: buildChannelConfigSchema(FeishuConfigSchema),
|
||||||
config: {
|
config: {
|
||||||
...feishuConfigBase,
|
...feishuConfigAdapter,
|
||||||
setAccountEnabled: ({ cfg, accountId, enabled }) => {
|
setAccountEnabled: ({ cfg, accountId, enabled }) => {
|
||||||
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
|
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
|
||||||
if (isDefault) {
|
if (isDefault) {
|
||||||
@ -454,7 +450,6 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|||||||
appId: account.appId,
|
appId: account.appId,
|
||||||
domain: account.domain,
|
domain: account.domain,
|
||||||
}),
|
}),
|
||||||
...feishuConfigAccessors,
|
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
describeMessageTool: describeFeishuMessageTool,
|
describeMessageTool: describeFeishuMessageTool,
|
||||||
|
|||||||
@ -1,8 +1,33 @@
|
|||||||
{
|
{
|
||||||
"id": "firecrawl",
|
"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": {
|
"configSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {}
|
"properties": {
|
||||||
|
"webSearch": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"apiKey": {
|
||||||
|
"type": ["string", "object"]
|
||||||
|
},
|
||||||
|
"baseUrl": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,6 +26,15 @@ type FirecrawlSearchConfig =
|
|||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
|
type PluginEntryConfig =
|
||||||
|
| {
|
||||||
|
webSearch?: {
|
||||||
|
apiKey?: unknown;
|
||||||
|
baseUrl?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
|
||||||
type FirecrawlFetchConfig =
|
type FirecrawlFetchConfig =
|
||||||
| {
|
| {
|
||||||
apiKey?: unknown;
|
apiKey?: unknown;
|
||||||
@ -53,6 +62,11 @@ function resolveFetchConfig(cfg?: OpenClawConfig): WebFetchConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveFirecrawlSearchConfig(cfg?: OpenClawConfig): FirecrawlSearchConfig {
|
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);
|
const search = resolveSearchConfig(cfg);
|
||||||
if (!search || typeof search !== "object") {
|
if (!search || typeof search !== "object") {
|
||||||
return undefined;
|
return undefined;
|
||||||
@ -89,6 +103,10 @@ export function resolveFirecrawlApiKey(cfg?: OpenClawConfig): string | undefined
|
|||||||
const search = resolveFirecrawlSearchConfig(cfg);
|
const search = resolveFirecrawlSearchConfig(cfg);
|
||||||
const fetch = resolveFirecrawlFetchConfig(cfg);
|
const fetch = resolveFirecrawlFetchConfig(cfg);
|
||||||
return (
|
return (
|
||||||
|
normalizeConfiguredSecret(
|
||||||
|
search?.apiKey,
|
||||||
|
"plugins.entries.firecrawl.config.webSearch.apiKey",
|
||||||
|
) ||
|
||||||
normalizeConfiguredSecret(search?.apiKey, "tools.web.search.firecrawl.apiKey") ||
|
normalizeConfiguredSecret(search?.apiKey, "tools.web.search.firecrawl.apiKey") ||
|
||||||
normalizeConfiguredSecret(fetch?.apiKey, "tools.web.fetch.firecrawl.apiKey") ||
|
normalizeConfiguredSecret(fetch?.apiKey, "tools.web.fetch.firecrawl.apiKey") ||
|
||||||
normalizeSecretInput(process.env.FIRECRAWL_API_KEY) ||
|
normalizeSecretInput(process.env.FIRECRAWL_API_KEY) ||
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
import { Type } from "@sinclair/typebox";
|
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 { enablePluginInConfig } from "../../../src/plugins/enable.js";
|
||||||
import type { WebSearchProviderPlugin } from "../../../src/plugins/types.js";
|
import type { WebSearchProviderPlugin } from "../../../src/plugins/types.js";
|
||||||
import { runFirecrawlSearch } from "./firecrawl-client.js";
|
import { runFirecrawlSearch } from "./firecrawl-client.js";
|
||||||
@ -47,10 +51,15 @@ export function createFirecrawlWebSearchProvider(): WebSearchProviderPlugin {
|
|||||||
signupUrl: "https://www.firecrawl.dev/",
|
signupUrl: "https://www.firecrawl.dev/",
|
||||||
docsUrl: "https://docs.openclaw.ai/tools/firecrawl",
|
docsUrl: "https://docs.openclaw.ai/tools/firecrawl",
|
||||||
autoDetectOrder: 60,
|
autoDetectOrder: 60,
|
||||||
credentialPath: "tools.web.search.firecrawl.apiKey",
|
credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey",
|
||||||
inactiveSecretPaths: ["tools.web.search.firecrawl.apiKey"],
|
inactiveSecretPaths: ["plugins.entries.firecrawl.config.webSearch.apiKey"],
|
||||||
getCredentialValue: getScopedCredentialValue,
|
getCredentialValue: getScopedCredentialValue,
|
||||||
setCredentialValue: setScopedCredentialValue,
|
setCredentialValue: setScopedCredentialValue,
|
||||||
|
getConfiguredCredentialValue: (config) =>
|
||||||
|
resolveProviderWebSearchPluginConfig(config, "firecrawl")?.apiKey,
|
||||||
|
setConfiguredCredentialValue: (configTarget, value) => {
|
||||||
|
setProviderWebSearchPluginConfigValue(configTarget, "firecrawl", "apiKey", value);
|
||||||
|
},
|
||||||
applySelectionConfig: (config) => enablePluginInConfig(config, "firecrawl").config,
|
applySelectionConfig: (config) => enablePluginInConfig(config, "firecrawl").config,
|
||||||
createTool: (ctx) => ({
|
createTool: (ctx) => ({
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
GOOGLE_GEMINI_DEFAULT_MODEL,
|
GOOGLE_GEMINI_DEFAULT_MODEL,
|
||||||
applyGoogleGeminiModelDefault,
|
applyGoogleGeminiModelDefault,
|
||||||
} from "openclaw/plugin-sdk/provider-models";
|
} from "openclaw/plugin-sdk/provider-models";
|
||||||
|
import { createGoogleThinkingPayloadWrapper } from "openclaw/plugin-sdk/provider-stream";
|
||||||
import { registerGoogleGeminiCliProvider } from "./gemini-cli-provider.js";
|
import { registerGoogleGeminiCliProvider } from "./gemini-cli-provider.js";
|
||||||
import { googleMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
import { googleMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
||||||
import { isModernGoogleModel, resolveGoogle31ForwardCompatModel } from "./provider-models.js";
|
import { isModernGoogleModel, resolveGoogle31ForwardCompatModel } from "./provider-models.js";
|
||||||
@ -44,6 +45,7 @@ export default definePluginEntry({
|
|||||||
],
|
],
|
||||||
resolveDynamicModel: (ctx) =>
|
resolveDynamicModel: (ctx) =>
|
||||||
resolveGoogle31ForwardCompatModel({ providerId: "google", ctx }),
|
resolveGoogle31ForwardCompatModel({ providerId: "google", ctx }),
|
||||||
|
wrapStreamFn: (ctx) => createGoogleThinkingPayloadWrapper(ctx.streamFn, ctx.thinkingLevel),
|
||||||
isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId),
|
isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId),
|
||||||
});
|
});
|
||||||
registerGoogleGeminiCliProvider(api);
|
registerGoogleGeminiCliProvider(api);
|
||||||
|
|||||||
@ -29,9 +29,34 @@
|
|||||||
"groupHint": "Gemini API key + OAuth"
|
"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": {
|
"configSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {}
|
"properties": {
|
||||||
|
"webSearch": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"apiKey": {
|
||||||
|
"type": ["string", "object"]
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,11 @@ import {
|
|||||||
withTrustedWebSearchEndpoint,
|
withTrustedWebSearchEndpoint,
|
||||||
writeCachedSearchPayload,
|
writeCachedSearchPayload,
|
||||||
} from "../../../src/agents/tools/web-search-provider-common.js";
|
} 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 {
|
import type {
|
||||||
WebSearchProviderPlugin,
|
WebSearchProviderPlugin,
|
||||||
WebSearchProviderToolDefinition,
|
WebSearchProviderToolDefinition,
|
||||||
@ -52,8 +57,15 @@ type GeminiGroundingResponse = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolveGeminiConfig(searchConfig?: SearchConfigRecord): GeminiConfig {
|
function resolveGeminiConfig(
|
||||||
const gemini = searchConfig?.gemini;
|
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)
|
return gemini && typeof gemini === "object" && !Array.isArray(gemini)
|
||||||
? (gemini as GeminiConfig)
|
? (gemini as GeminiConfig)
|
||||||
: {};
|
: {};
|
||||||
@ -61,7 +73,7 @@ function resolveGeminiConfig(searchConfig?: SearchConfigRecord): GeminiConfig {
|
|||||||
|
|
||||||
function resolveGeminiApiKey(gemini?: GeminiConfig): string | undefined {
|
function resolveGeminiApiKey(gemini?: GeminiConfig): string | undefined {
|
||||||
return (
|
return (
|
||||||
readConfiguredSecretString(gemini?.apiKey, "tools.web.search.gemini.apiKey") ??
|
readConfiguredSecretString(gemini?.apiKey, "plugins.entries.google.config.webSearch.apiKey") ??
|
||||||
readProviderEnvValue(["GEMINI_API_KEY"])
|
readProviderEnvValue(["GEMINI_API_KEY"])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -168,6 +180,7 @@ function createGeminiSchema() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createGeminiToolDefinition(
|
function createGeminiToolDefinition(
|
||||||
|
config?: OpenClawConfig,
|
||||||
searchConfig?: SearchConfigRecord,
|
searchConfig?: SearchConfigRecord,
|
||||||
): WebSearchProviderToolDefinition {
|
): WebSearchProviderToolDefinition {
|
||||||
return {
|
return {
|
||||||
@ -194,13 +207,13 @@ function createGeminiToolDefinition(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const geminiConfig = resolveGeminiConfig(searchConfig);
|
const geminiConfig = resolveGeminiConfig(config, searchConfig);
|
||||||
const apiKey = resolveGeminiApiKey(geminiConfig);
|
const apiKey = resolveGeminiApiKey(geminiConfig);
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return {
|
return {
|
||||||
error: "missing_gemini_api_key",
|
error: "missing_gemini_api_key",
|
||||||
message:
|
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",
|
docs: "https://docs.openclaw.ai/tools/web",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -259,8 +272,8 @@ export function createGeminiWebSearchProvider(): WebSearchProviderPlugin {
|
|||||||
signupUrl: "https://aistudio.google.com/apikey",
|
signupUrl: "https://aistudio.google.com/apikey",
|
||||||
docsUrl: "https://docs.openclaw.ai/tools/web",
|
docsUrl: "https://docs.openclaw.ai/tools/web",
|
||||||
autoDetectOrder: 20,
|
autoDetectOrder: 20,
|
||||||
credentialPath: "tools.web.search.gemini.apiKey",
|
credentialPath: "plugins.entries.google.config.webSearch.apiKey",
|
||||||
inactiveSecretPaths: ["tools.web.search.gemini.apiKey"],
|
inactiveSecretPaths: ["plugins.entries.google.config.webSearch.apiKey"],
|
||||||
getCredentialValue: (searchConfig) => {
|
getCredentialValue: (searchConfig) => {
|
||||||
const gemini = searchConfig?.gemini;
|
const gemini = searchConfig?.gemini;
|
||||||
return gemini && typeof gemini === "object" && !Array.isArray(gemini)
|
return gemini && typeof gemini === "object" && !Array.isArray(gemini)
|
||||||
@ -275,8 +288,13 @@ export function createGeminiWebSearchProvider(): WebSearchProviderPlugin {
|
|||||||
}
|
}
|
||||||
(scoped as Record<string, unknown>).apiKey = value;
|
(scoped as Record<string, unknown>).apiKey = value;
|
||||||
},
|
},
|
||||||
|
getConfiguredCredentialValue: (config) =>
|
||||||
|
resolveProviderWebSearchPluginConfig(config, "google")?.apiKey,
|
||||||
|
setConfiguredCredentialValue: (configTarget, value) => {
|
||||||
|
setProviderWebSearchPluginConfigValue(configTarget, "google", "apiKey", value);
|
||||||
|
},
|
||||||
createTool: (ctx) =>
|
createTool: (ctx) =>
|
||||||
createGeminiToolDefinition(ctx.searchConfig as SearchConfigRecord | undefined),
|
createGeminiToolDefinition(ctx.config, ctx.searchConfig as SearchConfigRecord | undefined),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,16 +9,19 @@ export {
|
|||||||
readStringParam,
|
readStringParam,
|
||||||
} from "../../src/agents/tools/common.js";
|
} from "../../src/agents/tools/common.js";
|
||||||
export {
|
export {
|
||||||
|
createScopedChannelConfigAdapter,
|
||||||
createScopedAccountConfigAccessors,
|
createScopedAccountConfigAccessors,
|
||||||
createScopedChannelConfigBase,
|
createScopedChannelConfigBase,
|
||||||
|
createTopLevelChannelConfigAdapter,
|
||||||
|
createHybridChannelConfigAdapter,
|
||||||
createScopedDmSecurityResolver,
|
createScopedDmSecurityResolver,
|
||||||
} from "../../src/plugin-sdk/channel-config-helpers.js";
|
} from "../../src/plugin-sdk/channel-config-helpers.js";
|
||||||
export {
|
export {
|
||||||
buildOpenGroupPolicyConfigureRouteAllowlistWarning,
|
buildOpenGroupPolicyConfigureRouteAllowlistWarning,
|
||||||
collectAllowlistProviderGroupPolicyWarnings,
|
collectAllowlistProviderGroupPolicyWarnings,
|
||||||
resolveMentionGatingWithBypass,
|
} from "../../src/plugin-sdk/channel-policy.js";
|
||||||
} from "../../src/channels/channel-policy.js";
|
export { resolveMentionGatingWithBypass } from "../../src/channels/mention-gating.js";
|
||||||
export { formatNormalizedAllowFromEntries } from "../../src/channels/allow-from.js";
|
export { formatNormalizedAllowFromEntries } from "../../src/plugin-sdk/allow-from.js";
|
||||||
export { buildComputedAccountStatusSnapshot } from "../../src/plugin-sdk/status-helpers.js";
|
export { buildComputedAccountStatusSnapshot } from "../../src/plugin-sdk/status-helpers.js";
|
||||||
export {
|
export {
|
||||||
createAccountStatusSink,
|
createAccountStatusSink,
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
|
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
|
||||||
import {
|
import {
|
||||||
createScopedAccountConfigAccessors,
|
createScopedChannelConfigAdapter,
|
||||||
createScopedChannelConfigBase,
|
|
||||||
createScopedDmSecurityResolver,
|
createScopedDmSecurityResolver,
|
||||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
} from "openclaw/plugin-sdk/channel-config-helpers";
|
||||||
import {
|
import {
|
||||||
@ -61,18 +60,7 @@ const formatAllowFromEntry = (entry: string) =>
|
|||||||
.replace(/^users\//i, "")
|
.replace(/^users\//i, "")
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
|
|
||||||
const googleChatConfigAccessors = createScopedAccountConfigAccessors({
|
const googleChatConfigAdapter = createScopedChannelConfigAdapter<ResolvedGoogleChatAccount>({
|
||||||
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>({
|
|
||||||
sectionKey: "googlechat",
|
sectionKey: "googlechat",
|
||||||
listAccountIds: listGoogleChatAccountIds,
|
listAccountIds: listGoogleChatAccountIds,
|
||||||
resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg, accountId }),
|
resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg, accountId }),
|
||||||
@ -87,6 +75,13 @@ const googleChatConfigBase = createScopedChannelConfigBase<ResolvedGoogleChatAcc
|
|||||||
"botUser",
|
"botUser",
|
||||||
"name",
|
"name",
|
||||||
],
|
],
|
||||||
|
resolveAllowFrom: (account: ResolvedGoogleChatAccount) => account.config.dm?.allowFrom,
|
||||||
|
formatAllowFrom: (allowFrom) =>
|
||||||
|
formatNormalizedAllowFromEntries({
|
||||||
|
allowFrom,
|
||||||
|
normalizeEntry: formatAllowFromEntry,
|
||||||
|
}),
|
||||||
|
resolveDefaultTo: (account: ResolvedGoogleChatAccount) => account.config.defaultTo,
|
||||||
});
|
});
|
||||||
|
|
||||||
const resolveGoogleChatDmPolicy = createScopedDmSecurityResolver<ResolvedGoogleChatAccount>({
|
const resolveGoogleChatDmPolicy = createScopedDmSecurityResolver<ResolvedGoogleChatAccount>({
|
||||||
@ -146,7 +141,7 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
|
|||||||
reload: { configPrefixes: ["channels.googlechat"] },
|
reload: { configPrefixes: ["channels.googlechat"] },
|
||||||
configSchema: buildChannelConfigSchema(GoogleChatConfigSchema),
|
configSchema: buildChannelConfigSchema(GoogleChatConfigSchema),
|
||||||
config: {
|
config: {
|
||||||
...googleChatConfigBase,
|
...googleChatConfigAdapter,
|
||||||
isConfigured: (account) => account.credentialSource !== "none",
|
isConfigured: (account) => account.credentialSource !== "none",
|
||||||
describeAccount: (account) => ({
|
describeAccount: (account) => ({
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
@ -155,7 +150,6 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
|
|||||||
configured: account.credentialSource !== "none",
|
configured: account.credentialSource !== "none",
|
||||||
credentialSource: account.credentialSource,
|
credentialSource: account.credentialSource,
|
||||||
}),
|
}),
|
||||||
...googleChatConfigAccessors,
|
|
||||||
},
|
},
|
||||||
security: {
|
security: {
|
||||||
resolveDmPolicy: resolveGoogleChatDmPolicy,
|
resolveDmPolicy: resolveGoogleChatDmPolicy,
|
||||||
|
|||||||
@ -1,3 +1,29 @@
|
|||||||
export * from "./src/monitor.js";
|
export type { IMessageAccountConfig } from "../../src/config/types.imessage.js";
|
||||||
export * from "./src/probe.js";
|
export type { ChannelPlugin } from "../../src/channels/plugins/types.plugin.js";
|
||||||
export * from "./src/send.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,
|
resolveAccountEntry,
|
||||||
type OpenClawConfig,
|
type OpenClawConfig,
|
||||||
} from "openclaw/plugin-sdk/account-resolution";
|
} from "openclaw/plugin-sdk/account-resolution";
|
||||||
import type { IMessageAccountConfig } from "openclaw/plugin-sdk/imessage";
|
import type { IMessageAccountConfig } from "../runtime-api.js";
|
||||||
|
|
||||||
export type ResolvedIMessageAccount = {
|
export type ResolvedIMessageAccount = {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
|
|||||||
@ -1,8 +1,5 @@
|
|||||||
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
|
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
|
||||||
import {
|
import { PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes } from "../runtime-api.js";
|
||||||
PAIRING_APPROVED_MESSAGE,
|
|
||||||
resolveChannelMediaMaxBytes,
|
|
||||||
} from "openclaw/plugin-sdk/imessage";
|
|
||||||
import type { ResolvedIMessageAccount } from "./accounts.js";
|
import type { ResolvedIMessageAccount } from "./accounts.js";
|
||||||
import { monitorIMessageProvider } from "./monitor.js";
|
import { monitorIMessageProvider } from "./monitor.js";
|
||||||
import { probeIMessage } from "./probe.js";
|
import { probeIMessage } from "./probe.js";
|
||||||
@ -55,7 +52,7 @@ export async function startIMessageGatewayAccount(
|
|||||||
ctx: Parameters<
|
ctx: Parameters<
|
||||||
NonNullable<
|
NonNullable<
|
||||||
NonNullable<
|
NonNullable<
|
||||||
import("openclaw/plugin-sdk/imessage").ChannelPlugin<ResolvedIMessageAccount>["gateway"]
|
import("../runtime-api.js").ChannelPlugin<ResolvedIMessageAccount>["gateway"]
|
||||||
>["startAccount"]
|
>["startAccount"]
|
||||||
>
|
>
|
||||||
>[0],
|
>[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 { type ResolvedIMessageAccount } from "./accounts.js";
|
||||||
import { imessageSetupAdapter } from "./setup-core.js";
|
import { imessageSetupAdapter } from "./setup-core.js";
|
||||||
import { createIMessagePluginBase, imessageSetupWizard } from "./shared.js";
|
import { createIMessagePluginBase, imessageSetupWizard } from "./shared.js";
|
||||||
|
|||||||
@ -1,17 +1,16 @@
|
|||||||
import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit";
|
import { buildAccountScopedAllowlistConfigEditor } from "openclaw/plugin-sdk/allowlist-config-edit";
|
||||||
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
|
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
|
||||||
import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/core";
|
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 {
|
import {
|
||||||
collectStatusIssuesFromLastError,
|
collectStatusIssuesFromLastError,
|
||||||
DEFAULT_ACCOUNT_ID,
|
DEFAULT_ACCOUNT_ID,
|
||||||
formatTrimmedAllowFromEntries,
|
formatTrimmedAllowFromEntries,
|
||||||
looksLikeIMessageTargetId,
|
|
||||||
normalizeIMessageMessagingTarget,
|
normalizeIMessageMessagingTarget,
|
||||||
type ChannelPlugin,
|
type ChannelPlugin,
|
||||||
} from "openclaw/plugin-sdk/imessage";
|
} from "../runtime-api.js";
|
||||||
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 { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js";
|
import { resolveIMessageAccount, type ResolvedIMessageAccount } from "./accounts.js";
|
||||||
import {
|
import {
|
||||||
resolveIMessageGroupRequireMention,
|
resolveIMessageGroupRequireMention,
|
||||||
@ -25,7 +24,12 @@ import {
|
|||||||
imessageResolveDmPolicy,
|
imessageResolveDmPolicy,
|
||||||
imessageSetupWizard,
|
imessageSetupWizard,
|
||||||
} from "./shared.js";
|
} from "./shared.js";
|
||||||
import { normalizeIMessageHandle, parseIMessageTarget } from "./targets.js";
|
import {
|
||||||
|
inferIMessageTargetChatType,
|
||||||
|
looksLikeIMessageExplicitTargetId,
|
||||||
|
normalizeIMessageHandle,
|
||||||
|
parseIMessageTarget,
|
||||||
|
} from "./targets.js";
|
||||||
|
|
||||||
const loadIMessageChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js"));
|
const loadIMessageChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js"));
|
||||||
|
|
||||||
@ -139,10 +143,26 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
|
|||||||
},
|
},
|
||||||
messaging: {
|
messaging: {
|
||||||
normalizeTarget: normalizeIMessageMessagingTarget,
|
normalizeTarget: normalizeIMessageMessagingTarget,
|
||||||
|
inferTargetChatType: ({ to }) => inferIMessageTargetChatType(to),
|
||||||
resolveOutboundSessionRoute: (params) => resolveIMessageOutboundSessionRoute(params),
|
resolveOutboundSessionRoute: (params) => resolveIMessageOutboundSessionRoute(params),
|
||||||
targetResolver: {
|
targetResolver: {
|
||||||
looksLikeId: looksLikeIMessageTargetId,
|
looksLikeId: looksLikeIMessageExplicitTargetId,
|
||||||
hint: "<handle|chat_id:ID>",
|
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: {
|
outbound: {
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
collectAllowlistProviderRestrictSendersWarnings,
|
collectAllowlistProviderRestrictSendersWarnings,
|
||||||
createScopedAccountConfigAccessors,
|
createScopedChannelConfigAdapter,
|
||||||
createScopedChannelConfigBase,
|
|
||||||
createScopedDmSecurityResolver,
|
createScopedDmSecurityResolver,
|
||||||
|
formatTrimmedAllowFromEntries,
|
||||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
} from "openclaw/plugin-sdk/channel-config-helpers";
|
||||||
import { createChannelPluginBase } from "openclaw/plugin-sdk/core";
|
import { createChannelPluginBase } from "openclaw/plugin-sdk/core";
|
||||||
import {
|
import {
|
||||||
@ -10,7 +10,7 @@ import {
|
|||||||
getChatChannelMeta,
|
getChatChannelMeta,
|
||||||
IMessageConfigSchema,
|
IMessageConfigSchema,
|
||||||
type ChannelPlugin,
|
type ChannelPlugin,
|
||||||
} from "openclaw/plugin-sdk/imessage-core";
|
} from "../runtime-api.js";
|
||||||
import {
|
import {
|
||||||
listIMessageAccountIds,
|
listIMessageAccountIds,
|
||||||
resolveDefaultIMessageAccountId,
|
resolveDefaultIMessageAccountId,
|
||||||
@ -29,19 +29,15 @@ export const imessageSetupWizard = createIMessageSetupWizardProxy(
|
|||||||
async () => (await loadIMessageChannelRuntime()).imessageSetupWizard,
|
async () => (await loadIMessageChannelRuntime()).imessageSetupWizard,
|
||||||
);
|
);
|
||||||
|
|
||||||
export const imessageConfigAccessors = createScopedAccountConfigAccessors({
|
export const imessageConfigAdapter = createScopedChannelConfigAdapter<ResolvedIMessageAccount>({
|
||||||
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>({
|
|
||||||
sectionKey: IMESSAGE_CHANNEL,
|
sectionKey: IMESSAGE_CHANNEL,
|
||||||
listAccountIds: listIMessageAccountIds,
|
listAccountIds: listIMessageAccountIds,
|
||||||
resolveAccount: (cfg, accountId) => resolveIMessageAccount({ cfg, accountId }),
|
resolveAccount: (cfg, accountId) => resolveIMessageAccount({ cfg, accountId }),
|
||||||
defaultAccountId: resolveDefaultIMessageAccountId,
|
defaultAccountId: resolveDefaultIMessageAccountId,
|
||||||
clearBaseFields: ["cliPath", "dbPath", "service", "region", "name"],
|
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>({
|
export const imessageResolveDmPolicy = createScopedDmSecurityResolver<ResolvedIMessageAccount>({
|
||||||
@ -97,7 +93,7 @@ export function createIMessagePluginBase(params: {
|
|||||||
reload: { configPrefixes: ["channels.imessage"] },
|
reload: { configPrefixes: ["channels.imessage"] },
|
||||||
configSchema: buildChannelConfigSchema(IMessageConfigSchema),
|
configSchema: buildChannelConfigSchema(IMessageConfigSchema),
|
||||||
config: {
|
config: {
|
||||||
...imessageConfigBase,
|
...imessageConfigAdapter,
|
||||||
isConfigured: (account) => account.configured,
|
isConfigured: (account) => account.configured,
|
||||||
describeAccount: (account) => ({
|
describeAccount: (account) => ({
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
@ -105,7 +101,6 @@ export function createIMessagePluginBase(params: {
|
|||||||
enabled: account.enabled,
|
enabled: account.enabled,
|
||||||
configured: account.configured,
|
configured: account.configured,
|
||||||
}),
|
}),
|
||||||
...imessageConfigAccessors,
|
|
||||||
},
|
},
|
||||||
security: {
|
security: {
|
||||||
resolveDmPolicy: imessageResolveDmPolicy,
|
resolveDmPolicy: imessageResolveDmPolicy,
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
formatIMessageChatTarget,
|
formatIMessageChatTarget,
|
||||||
|
inferIMessageTargetChatType,
|
||||||
isAllowedIMessageSender,
|
isAllowedIMessageSender,
|
||||||
|
looksLikeIMessageExplicitTargetId,
|
||||||
normalizeIMessageHandle,
|
normalizeIMessageHandle,
|
||||||
parseIMessageTarget,
|
parseIMessageTarget,
|
||||||
} from "./targets.js";
|
} from "./targets.js";
|
||||||
@ -83,6 +85,18 @@ describe("imessage targets", () => {
|
|||||||
expect(formatIMessageChatTarget(42)).toBe("chat_id:42");
|
expect(formatIMessageChatTarget(42)).toBe("chat_id:42");
|
||||||
expect(formatIMessageChatTarget(undefined)).toBe("");
|
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", () => {
|
describe("createIMessageRpcClient", () => {
|
||||||
|
|||||||
@ -107,6 +107,34 @@ export function parseIMessageTarget(raw: string): IMessageTarget {
|
|||||||
return { kind: "handle", to: trimmed, service: "auto" };
|
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 {
|
export function parseIMessageAllowTarget(raw: string): IMessageAllowTarget {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
|
|||||||
@ -1,13 +1,22 @@
|
|||||||
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
|
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
|
||||||
import {
|
import {
|
||||||
createScopedAccountConfigAccessors,
|
createScopedChannelConfigAdapter,
|
||||||
createScopedChannelConfigBase,
|
|
||||||
createScopedDmSecurityResolver,
|
createScopedDmSecurityResolver,
|
||||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
} from "openclaw/plugin-sdk/channel-config-helpers";
|
||||||
import {
|
import {
|
||||||
buildOpenGroupPolicyWarning,
|
buildOpenGroupPolicyWarning,
|
||||||
collectAllowlistProviderGroupPolicyWarnings,
|
collectAllowlistProviderGroupPolicyWarnings,
|
||||||
} from "openclaw/plugin-sdk/channel-policy";
|
} 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 { runStoppablePassiveMonitor } from "../../shared/passive-monitor.js";
|
||||||
import {
|
import {
|
||||||
listIrcAccountIds,
|
listIrcAccountIds,
|
||||||
@ -25,16 +34,6 @@ import {
|
|||||||
} from "./normalize.js";
|
} from "./normalize.js";
|
||||||
import { resolveIrcGroupMatch, resolveIrcRequireMention } from "./policy.js";
|
import { resolveIrcGroupMatch, resolveIrcRequireMention } from "./policy.js";
|
||||||
import { probeIrc } from "./probe.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 { getIrcRuntime } from "./runtime.js";
|
||||||
import { sendMessageIrc } from "./send.js";
|
import { sendMessageIrc } from "./send.js";
|
||||||
import { ircSetupAdapter } from "./setup-core.js";
|
import { ircSetupAdapter } from "./setup-core.js";
|
||||||
@ -51,18 +50,11 @@ function normalizePairingTarget(raw: string): string {
|
|||||||
return normalized.split(/[!@]/, 1)[0]?.trim() ?? "";
|
return normalized.split(/[!@]/, 1)[0]?.trim() ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const ircConfigAccessors = createScopedAccountConfigAccessors({
|
const ircConfigAdapter = createScopedChannelConfigAdapter<
|
||||||
resolveAccount: ({ cfg, accountId }) => resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }),
|
ResolvedIrcAccount,
|
||||||
resolveAllowFrom: (account: ResolvedIrcAccount) => account.config.allowFrom,
|
ResolvedIrcAccount,
|
||||||
formatAllowFrom: (allowFrom) =>
|
CoreConfig
|
||||||
formatNormalizedAllowFromEntries({
|
>({
|
||||||
allowFrom,
|
|
||||||
normalizeEntry: normalizeIrcAllowEntry,
|
|
||||||
}),
|
|
||||||
resolveDefaultTo: (account: ResolvedIrcAccount) => account.config.defaultTo,
|
|
||||||
});
|
|
||||||
|
|
||||||
const ircConfigBase = createScopedChannelConfigBase<ResolvedIrcAccount, CoreConfig>({
|
|
||||||
sectionKey: "irc",
|
sectionKey: "irc",
|
||||||
listAccountIds: listIrcAccountIds,
|
listAccountIds: listIrcAccountIds,
|
||||||
resolveAccount: (cfg, accountId) => resolveIrcAccount({ cfg, accountId }),
|
resolveAccount: (cfg, accountId) => resolveIrcAccount({ cfg, accountId }),
|
||||||
@ -79,6 +71,13 @@ const ircConfigBase = createScopedChannelConfigBase<ResolvedIrcAccount, CoreConf
|
|||||||
"passwordFile",
|
"passwordFile",
|
||||||
"channels",
|
"channels",
|
||||||
],
|
],
|
||||||
|
resolveAllowFrom: (account: ResolvedIrcAccount) => account.config.allowFrom,
|
||||||
|
formatAllowFrom: (allowFrom) =>
|
||||||
|
formatNormalizedAllowFromEntries({
|
||||||
|
allowFrom,
|
||||||
|
normalizeEntry: normalizeIrcAllowEntry,
|
||||||
|
}),
|
||||||
|
resolveDefaultTo: (account: ResolvedIrcAccount) => account.config.defaultTo,
|
||||||
});
|
});
|
||||||
|
|
||||||
const resolveIrcDmPolicy = createScopedDmSecurityResolver<ResolvedIrcAccount>({
|
const resolveIrcDmPolicy = createScopedDmSecurityResolver<ResolvedIrcAccount>({
|
||||||
@ -116,7 +115,7 @@ export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = {
|
|||||||
reload: { configPrefixes: ["channels.irc"] },
|
reload: { configPrefixes: ["channels.irc"] },
|
||||||
configSchema: buildChannelConfigSchema(IrcConfigSchema),
|
configSchema: buildChannelConfigSchema(IrcConfigSchema),
|
||||||
config: {
|
config: {
|
||||||
...ircConfigBase,
|
...ircConfigAdapter,
|
||||||
isConfigured: (account) => account.configured,
|
isConfigured: (account) => account.configured,
|
||||||
describeAccount: (account) => ({
|
describeAccount: (account) => ({
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
@ -129,7 +128,6 @@ export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = {
|
|||||||
nick: account.nick,
|
nick: account.nick,
|
||||||
passwordSource: account.passwordSource,
|
passwordSource: account.passwordSource,
|
||||||
}),
|
}),
|
||||||
...ircConfigAccessors,
|
|
||||||
},
|
},
|
||||||
security: {
|
security: {
|
||||||
resolveDmPolicy: resolveIrcDmPolicy,
|
resolveDmPolicy: resolveIrcDmPolicy,
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import { z } from "zod";
|
|
||||||
import { requireChannelOpenAllowFrom } from "../../shared/config-schema-helpers.js";
|
|
||||||
import {
|
import {
|
||||||
BlockStreamingCoalesceSchema,
|
BlockStreamingCoalesceSchema,
|
||||||
DmConfigSchema,
|
DmConfigSchema,
|
||||||
@ -9,7 +7,9 @@ import {
|
|||||||
ReplyRuntimeConfigSchemaShape,
|
ReplyRuntimeConfigSchemaShape,
|
||||||
ToolPolicySchema,
|
ToolPolicySchema,
|
||||||
requireOpenAllowFrom,
|
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
|
const IrcGroupSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@ -2,10 +2,9 @@ import {
|
|||||||
buildChannelConfigSchema,
|
buildChannelConfigSchema,
|
||||||
LineConfigSchema,
|
LineConfigSchema,
|
||||||
type ChannelPlugin,
|
type ChannelPlugin,
|
||||||
type OpenClawConfig,
|
|
||||||
type ResolvedLineAccount,
|
type ResolvedLineAccount,
|
||||||
} from "../api.js";
|
} from "../api.js";
|
||||||
import { listLineAccountIds, resolveDefaultLineAccountId, resolveLineAccount } from "../api.js";
|
import { lineConfigAdapter } from "./config-adapter.js";
|
||||||
import { lineSetupAdapter } from "./setup-core.js";
|
import { lineSetupAdapter } from "./setup-core.js";
|
||||||
import { lineSetupWizard } from "./setup-surface.js";
|
import { lineSetupWizard } from "./setup-surface.js";
|
||||||
|
|
||||||
@ -20,8 +19,6 @@ const meta = {
|
|||||||
systemImage: "message.fill",
|
systemImage: "message.fill",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const normalizeLineAllowFrom = (entry: string) => entry.replace(/^line:(?:user:)?/i, "");
|
|
||||||
|
|
||||||
export const lineSetupPlugin: ChannelPlugin<ResolvedLineAccount> = {
|
export const lineSetupPlugin: ChannelPlugin<ResolvedLineAccount> = {
|
||||||
id: "line",
|
id: "line",
|
||||||
meta: {
|
meta: {
|
||||||
@ -39,10 +36,7 @@ export const lineSetupPlugin: ChannelPlugin<ResolvedLineAccount> = {
|
|||||||
reload: { configPrefixes: ["channels.line"] },
|
reload: { configPrefixes: ["channels.line"] },
|
||||||
configSchema: buildChannelConfigSchema(LineConfigSchema),
|
configSchema: buildChannelConfigSchema(LineConfigSchema),
|
||||||
config: {
|
config: {
|
||||||
listAccountIds: (cfg: OpenClawConfig) => listLineAccountIds(cfg),
|
...lineConfigAdapter,
|
||||||
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) =>
|
|
||||||
resolveLineAccount({ cfg, accountId: accountId ?? undefined }),
|
|
||||||
defaultAccountId: (cfg: OpenClawConfig) => resolveDefaultLineAccountId(cfg),
|
|
||||||
isConfigured: (account) =>
|
isConfigured: (account) =>
|
||||||
Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()),
|
Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()),
|
||||||
describeAccount: (account) => ({
|
describeAccount: (account) => ({
|
||||||
@ -52,13 +46,6 @@ export const lineSetupPlugin: ChannelPlugin<ResolvedLineAccount> = {
|
|||||||
configured: Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()),
|
configured: Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()),
|
||||||
tokenSource: account.tokenSource ?? undefined,
|
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,
|
setupWizard: lineSetupWizard,
|
||||||
setup: lineSetupAdapter,
|
setup: lineSetupAdapter,
|
||||||
|
|||||||
@ -1,8 +1,4 @@
|
|||||||
import {
|
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
|
||||||
createScopedAccountConfigAccessors,
|
|
||||||
createScopedChannelConfigBase,
|
|
||||||
createScopedDmSecurityResolver,
|
|
||||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
|
||||||
import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy";
|
import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy";
|
||||||
import {
|
import {
|
||||||
buildChannelConfigSchema,
|
buildChannelConfigSchema,
|
||||||
@ -14,11 +10,11 @@ import {
|
|||||||
processLineMessage,
|
processLineMessage,
|
||||||
type ChannelPlugin,
|
type ChannelPlugin,
|
||||||
type ChannelStatusIssue,
|
type ChannelStatusIssue,
|
||||||
type OpenClawConfig,
|
|
||||||
type LineConfig,
|
type LineConfig,
|
||||||
type LineChannelData,
|
type LineChannelData,
|
||||||
type ResolvedLineAccount,
|
type ResolvedLineAccount,
|
||||||
} from "../api.js";
|
} from "../api.js";
|
||||||
|
import { lineConfigAdapter } from "./config-adapter.js";
|
||||||
import { resolveLineGroupRequireMention } from "./group-policy.js";
|
import { resolveLineGroupRequireMention } from "./group-policy.js";
|
||||||
import { getLineRuntime } from "./runtime.js";
|
import { getLineRuntime } from "./runtime.js";
|
||||||
import { lineSetupAdapter } from "./setup-core.js";
|
import { lineSetupAdapter } from "./setup-core.js";
|
||||||
@ -36,26 +32,6 @@ const meta = {
|
|||||||
systemImage: "message.fill",
|
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>({
|
const resolveLineDmPolicy = createScopedDmSecurityResolver<ResolvedLineAccount>({
|
||||||
channelKey: "line",
|
channelKey: "line",
|
||||||
resolvePolicy: (account) => account.config.dmPolicy,
|
resolvePolicy: (account) => account.config.dmPolicy,
|
||||||
@ -100,7 +76,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
|||||||
configSchema: buildChannelConfigSchema(LineConfigSchema),
|
configSchema: buildChannelConfigSchema(LineConfigSchema),
|
||||||
setupWizard: lineSetupWizard,
|
setupWizard: lineSetupWizard,
|
||||||
config: {
|
config: {
|
||||||
...lineConfigBase,
|
...lineConfigAdapter,
|
||||||
isConfigured: (account) =>
|
isConfigured: (account) =>
|
||||||
Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()),
|
Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()),
|
||||||
describeAccount: (account) => ({
|
describeAccount: (account) => ({
|
||||||
@ -110,7 +86,6 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
|
|||||||
configured: Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()),
|
configured: Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()),
|
||||||
tokenSource: account.tokenSource ?? undefined,
|
tokenSource: account.tokenSource ?? undefined,
|
||||||
}),
|
}),
|
||||||
...lineConfigAccessors,
|
|
||||||
},
|
},
|
||||||
security: {
|
security: {
|
||||||
resolveDmPolicy: resolveLineDmPolicy,
|
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 {
|
import {
|
||||||
createScopedAccountConfigAccessors,
|
createScopedChannelConfigAdapter,
|
||||||
createScopedChannelConfigBase,
|
|
||||||
createScopedDmSecurityResolver,
|
createScopedDmSecurityResolver,
|
||||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
} from "openclaw/plugin-sdk/channel-config-helpers";
|
||||||
import {
|
import {
|
||||||
@ -69,17 +68,16 @@ function normalizeMatrixMessagingTarget(raw: string): string | undefined {
|
|||||||
return stripped || undefined;
|
return stripped || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const matrixConfigAccessors = createScopedAccountConfigAccessors({
|
const matrixConfigAdapter = createScopedChannelConfigAdapter<
|
||||||
resolveAccount: ({ cfg, accountId }) =>
|
ResolvedMatrixAccount,
|
||||||
resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId }),
|
ReturnType<typeof resolveMatrixAccountConfig>,
|
||||||
resolveAllowFrom: (account) => account.dm?.allowFrom,
|
CoreConfig
|
||||||
formatAllowFrom: (allowFrom) => normalizeMatrixAllowList(allowFrom),
|
>({
|
||||||
});
|
|
||||||
|
|
||||||
const matrixConfigBase = createScopedChannelConfigBase<ResolvedMatrixAccount, CoreConfig>({
|
|
||||||
sectionKey: "matrix",
|
sectionKey: "matrix",
|
||||||
listAccountIds: listMatrixAccountIds,
|
listAccountIds: listMatrixAccountIds,
|
||||||
resolveAccount: (cfg, accountId) => resolveMatrixAccount({ cfg, accountId }),
|
resolveAccount: (cfg, accountId) => resolveMatrixAccount({ cfg, accountId }),
|
||||||
|
resolveAccessorAccount: ({ cfg, accountId }) =>
|
||||||
|
resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId }),
|
||||||
defaultAccountId: resolveDefaultMatrixAccountId,
|
defaultAccountId: resolveDefaultMatrixAccountId,
|
||||||
clearBaseFields: [
|
clearBaseFields: [
|
||||||
"name",
|
"name",
|
||||||
@ -90,6 +88,8 @@ const matrixConfigBase = createScopedChannelConfigBase<ResolvedMatrixAccount, Co
|
|||||||
"deviceName",
|
"deviceName",
|
||||||
"initialSyncLimit",
|
"initialSyncLimit",
|
||||||
],
|
],
|
||||||
|
resolveAllowFrom: (account) => account.dm?.allowFrom,
|
||||||
|
formatAllowFrom: (allowFrom) => normalizeMatrixAllowList(allowFrom),
|
||||||
});
|
});
|
||||||
|
|
||||||
const resolveMatrixDmPolicy = createScopedDmSecurityResolver<ResolvedMatrixAccount>({
|
const resolveMatrixDmPolicy = createScopedDmSecurityResolver<ResolvedMatrixAccount>({
|
||||||
@ -122,7 +122,7 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
|||||||
reload: { configPrefixes: ["channels.matrix"] },
|
reload: { configPrefixes: ["channels.matrix"] },
|
||||||
configSchema: buildChannelConfigSchema(MatrixConfigSchema),
|
configSchema: buildChannelConfigSchema(MatrixConfigSchema),
|
||||||
config: {
|
config: {
|
||||||
...matrixConfigBase,
|
...matrixConfigAdapter,
|
||||||
isConfigured: (account) => account.configured,
|
isConfigured: (account) => account.configured,
|
||||||
describeAccount: (account) => ({
|
describeAccount: (account) => ({
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
@ -131,7 +131,6 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
|||||||
configured: account.configured,
|
configured: account.configured,
|
||||||
baseUrl: account.homeserver,
|
baseUrl: account.homeserver,
|
||||||
}),
|
}),
|
||||||
...matrixConfigAccessors,
|
|
||||||
},
|
},
|
||||||
security: {
|
security: {
|
||||||
resolveDmPolicy: resolveMatrixDmPolicy,
|
resolveDmPolicy: resolveMatrixDmPolicy,
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
|
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
|
||||||
import {
|
import {
|
||||||
createScopedAccountConfigAccessors,
|
createScopedChannelConfigAdapter,
|
||||||
createScopedChannelConfigBase,
|
|
||||||
createScopedDmSecurityResolver,
|
createScopedDmSecurityResolver,
|
||||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
} from "openclaw/plugin-sdk/channel-config-helpers";
|
||||||
import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy";
|
import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy";
|
||||||
@ -248,8 +247,12 @@ function formatAllowEntry(entry: string): string {
|
|||||||
return trimmed.replace(/^(mattermost|user):/i, "").toLowerCase();
|
return trimmed.replace(/^(mattermost|user):/i, "").toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
const mattermostConfigAccessors = createScopedAccountConfigAccessors({
|
const mattermostConfigAdapter = createScopedChannelConfigAdapter<ResolvedMattermostAccount>({
|
||||||
resolveAccount: ({ cfg, accountId }) => resolveMattermostAccount({ cfg, accountId }),
|
sectionKey: "mattermost",
|
||||||
|
listAccountIds: listMattermostAccountIds,
|
||||||
|
resolveAccount: (cfg, accountId) => resolveMattermostAccount({ cfg, accountId }),
|
||||||
|
defaultAccountId: resolveDefaultMattermostAccountId,
|
||||||
|
clearBaseFields: ["botToken", "baseUrl", "name"],
|
||||||
resolveAllowFrom: (account: ResolvedMattermostAccount) => account.config.allowFrom,
|
resolveAllowFrom: (account: ResolvedMattermostAccount) => account.config.allowFrom,
|
||||||
formatAllowFrom: (allowFrom) =>
|
formatAllowFrom: (allowFrom) =>
|
||||||
formatNormalizedAllowFromEntries({
|
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>({
|
const resolveMattermostDmPolicy = createScopedDmSecurityResolver<ResolvedMattermostAccount>({
|
||||||
channelKey: "mattermost",
|
channelKey: "mattermost",
|
||||||
resolvePolicy: (account) => account.config.dmPolicy,
|
resolvePolicy: (account) => account.config.dmPolicy,
|
||||||
@ -311,7 +306,7 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
|
|||||||
reload: { configPrefixes: ["channels.mattermost"] },
|
reload: { configPrefixes: ["channels.mattermost"] },
|
||||||
configSchema: buildChannelConfigSchema(MattermostConfigSchema),
|
configSchema: buildChannelConfigSchema(MattermostConfigSchema),
|
||||||
config: {
|
config: {
|
||||||
...mattermostConfigBase,
|
...mattermostConfigAdapter,
|
||||||
isConfigured: (account) => Boolean(account.botToken && account.baseUrl),
|
isConfigured: (account) => Boolean(account.botToken && account.baseUrl),
|
||||||
describeAccount: (account) => ({
|
describeAccount: (account) => ({
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
@ -321,7 +316,6 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
|
|||||||
botTokenSource: account.botTokenSource,
|
botTokenSource: account.botTokenSource,
|
||||||
baseUrl: account.baseUrl,
|
baseUrl: account.baseUrl,
|
||||||
}),
|
}),
|
||||||
...mattermostConfigAccessors,
|
|
||||||
},
|
},
|
||||||
security: {
|
security: {
|
||||||
resolveDmPolicy: resolveMattermostDmPolicy,
|
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,
|
type MattermostUser,
|
||||||
} from "./client.js";
|
} from "./client.js";
|
||||||
import {
|
import {
|
||||||
buildButtonProps,
|
|
||||||
computeInteractionCallbackUrl,
|
computeInteractionCallbackUrl,
|
||||||
createMattermostInteractionHandler,
|
createMattermostInteractionHandler,
|
||||||
resolveInteractionCallbackPath,
|
resolveInteractionCallbackPath,
|
||||||
@ -75,6 +74,7 @@ import {
|
|||||||
resolveThreadSessionKeys,
|
resolveThreadSessionKeys,
|
||||||
} from "./monitor-helpers.js";
|
} from "./monitor-helpers.js";
|
||||||
import { resolveOncharPrefixes, stripOncharPrefix } from "./monitor-onchar.js";
|
import { resolveOncharPrefixes, stripOncharPrefix } from "./monitor-onchar.js";
|
||||||
|
import { createMattermostMonitorResources, type MattermostMediaInfo } from "./monitor-resources.js";
|
||||||
import { registerMattermostMonitorSlashCommands } from "./monitor-slash.js";
|
import { registerMattermostMonitorSlashCommands } from "./monitor-slash.js";
|
||||||
import {
|
import {
|
||||||
createMattermostConnectOnce,
|
createMattermostConnectOnce,
|
||||||
@ -117,8 +117,6 @@ type MattermostReaction = {
|
|||||||
};
|
};
|
||||||
const RECENT_MATTERMOST_MESSAGE_TTL_MS = 5 * 60_000;
|
const RECENT_MATTERMOST_MESSAGE_TTL_MS = 5 * 60_000;
|
||||||
const RECENT_MATTERMOST_MESSAGE_MAX = 2000;
|
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 {
|
function isLoopbackHost(hostname: string): boolean {
|
||||||
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
||||||
@ -215,12 +213,6 @@ export function resolveMattermostThreadSessionContext(params: {
|
|||||||
parentSessionKey: threadKeys.parentSessionKey,
|
parentSessionKey: threadKeys.parentSessionKey,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
type MattermostMediaInfo = {
|
|
||||||
path: string;
|
|
||||||
contentType?: string;
|
|
||||||
kind: MediaKind;
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildMattermostAttachmentPlaceholder(mediaList: MattermostMediaInfo[]): string {
|
function buildMattermostAttachmentPlaceholder(mediaList: MattermostMediaInfo[]): string {
|
||||||
if (mediaList.length === 0) {
|
if (mediaList.length === 0) {
|
||||||
return "";
|
return "";
|
||||||
@ -286,6 +278,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
baseUrl,
|
baseUrl,
|
||||||
botUserId,
|
botUserId,
|
||||||
});
|
});
|
||||||
|
const slashEnabled = getSlashCommandState(account.accountId) != null;
|
||||||
|
|
||||||
// ─── Interactive buttons registration ──────────────────────────────────────
|
// ─── Interactive buttons registration ──────────────────────────────────────
|
||||||
// Derive a stable HMAC secret from the bot token so CLI and gateway share it.
|
// 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),
|
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 logger = core.logging.getChildLogger({ module: "mattermost" });
|
||||||
const logVerboseMessage = (message: string) => {
|
const logVerboseMessage = (message: string) => {
|
||||||
if (!core.logging.shouldLogVerbose()) {
|
if (!core.logging.shouldLogVerbose()) {
|
||||||
@ -570,123 +561,25 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
|||||||
log: (message) => logVerboseMessage(message),
|
log: (message) => logVerboseMessage(message),
|
||||||
});
|
});
|
||||||
|
|
||||||
const resolveMattermostMedia = async (
|
const {
|
||||||
fileIds?: string[] | null,
|
resolveMattermostMedia,
|
||||||
): Promise<MattermostMediaInfo[]> => {
|
sendTypingIndicator,
|
||||||
const ids = (fileIds ?? []).map((id) => id?.trim()).filter(Boolean);
|
resolveChannelInfo,
|
||||||
if (ids.length === 0) {
|
resolveUserInfo,
|
||||||
return [];
|
updateModelPickerPost,
|
||||||
}
|
} = createMattermostMonitorResources({
|
||||||
const out: MattermostMediaInfo[] = [];
|
accountId: account.accountId,
|
||||||
for (const fileId of ids) {
|
callbackUrl,
|
||||||
try {
|
client,
|
||||||
const fetched = await core.channel.media.fetchRemoteMedia({
|
logger: {
|
||||||
url: `${client.apiBaseUrl}/files/${fileId}`,
|
debug: (message) => logger.debug?.(String(message)),
|
||||||
requestInit: {
|
},
|
||||||
headers: {
|
mediaMaxBytes,
|
||||||
Authorization: `Bearer ${client.token}`,
|
fetchRemoteMedia: (params) => core.channel.media.fetchRemoteMedia(params),
|
||||||
},
|
saveMediaBuffer: (buffer, contentType, direction, maxBytes) =>
|
||||||
},
|
core.channel.media.saveMediaBuffer(Buffer.from(buffer), contentType, direction, maxBytes),
|
||||||
filePathHint: fileId,
|
mediaKindFromMime: (contentType) => core.media.mediaKindFromMime(contentType) as MediaKind,
|
||||||
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 runModelPickerCommand = async (params: {
|
const runModelPickerCommand = async (params: {
|
||||||
commandText: string;
|
commandText: string;
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { definePluginEntry } from "openclaw/plugin-sdk/core";
|
import { definePluginEntry } from "openclaw/plugin-sdk/core";
|
||||||
import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog";
|
|
||||||
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth";
|
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth";
|
||||||
|
import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog";
|
||||||
import {
|
import {
|
||||||
createMoonshotThinkingWrapper,
|
createMoonshotThinkingWrapper,
|
||||||
resolveMoonshotThinkingType,
|
resolveMoonshotThinkingType,
|
||||||
|
|||||||
@ -32,9 +32,40 @@
|
|||||||
"cliDescription": "Moonshot API key"
|
"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": {
|
"configSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"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,
|
withTrustedWebSearchEndpoint,
|
||||||
writeCachedSearchPayload,
|
writeCachedSearchPayload,
|
||||||
} from "../../../src/agents/tools/web-search-provider-common.js";
|
} 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 {
|
import type {
|
||||||
WebSearchProviderPlugin,
|
WebSearchProviderPlugin,
|
||||||
WebSearchProviderToolDefinition,
|
WebSearchProviderToolDefinition,
|
||||||
@ -61,14 +66,18 @@ type KimiSearchResponse = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolveKimiConfig(searchConfig?: SearchConfigRecord): KimiConfig {
|
function resolveKimiConfig(config?: OpenClawConfig, searchConfig?: SearchConfigRecord): KimiConfig {
|
||||||
const kimi = searchConfig?.kimi;
|
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) : {};
|
return kimi && typeof kimi === "object" && !Array.isArray(kimi) ? (kimi as KimiConfig) : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveKimiApiKey(kimi?: KimiConfig): string | undefined {
|
function resolveKimiApiKey(kimi?: KimiConfig): string | undefined {
|
||||||
return (
|
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"])
|
readProviderEnvValue(["KIMI_API_KEY", "MOONSHOT_API_KEY"])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -237,6 +246,7 @@ function createKimiSchema() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createKimiToolDefinition(
|
function createKimiToolDefinition(
|
||||||
|
config?: OpenClawConfig,
|
||||||
searchConfig?: SearchConfigRecord,
|
searchConfig?: SearchConfigRecord,
|
||||||
): WebSearchProviderToolDefinition {
|
): WebSearchProviderToolDefinition {
|
||||||
return {
|
return {
|
||||||
@ -263,13 +273,13 @@ function createKimiToolDefinition(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const kimiConfig = resolveKimiConfig(searchConfig);
|
const kimiConfig = resolveKimiConfig(config, searchConfig);
|
||||||
const apiKey = resolveKimiApiKey(kimiConfig);
|
const apiKey = resolveKimiApiKey(kimiConfig);
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return {
|
return {
|
||||||
error: "missing_kimi_api_key",
|
error: "missing_kimi_api_key",
|
||||||
message:
|
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",
|
docs: "https://docs.openclaw.ai/tools/web",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -331,8 +341,8 @@ export function createKimiWebSearchProvider(): WebSearchProviderPlugin {
|
|||||||
signupUrl: "https://platform.moonshot.cn/",
|
signupUrl: "https://platform.moonshot.cn/",
|
||||||
docsUrl: "https://docs.openclaw.ai/tools/web",
|
docsUrl: "https://docs.openclaw.ai/tools/web",
|
||||||
autoDetectOrder: 40,
|
autoDetectOrder: 40,
|
||||||
credentialPath: "tools.web.search.kimi.apiKey",
|
credentialPath: "plugins.entries.moonshot.config.webSearch.apiKey",
|
||||||
inactiveSecretPaths: ["tools.web.search.kimi.apiKey"],
|
inactiveSecretPaths: ["plugins.entries.moonshot.config.webSearch.apiKey"],
|
||||||
getCredentialValue: (searchConfig) => {
|
getCredentialValue: (searchConfig) => {
|
||||||
const kimi = searchConfig?.kimi;
|
const kimi = searchConfig?.kimi;
|
||||||
return kimi && typeof kimi === "object" && !Array.isArray(kimi)
|
return kimi && typeof kimi === "object" && !Array.isArray(kimi)
|
||||||
@ -347,8 +357,13 @@ export function createKimiWebSearchProvider(): WebSearchProviderPlugin {
|
|||||||
}
|
}
|
||||||
(scoped as Record<string, unknown>).apiKey = value;
|
(scoped as Record<string, unknown>).apiKey = value;
|
||||||
},
|
},
|
||||||
|
getConfiguredCredentialValue: (config) =>
|
||||||
|
resolveProviderWebSearchPluginConfig(config, "moonshot")?.apiKey,
|
||||||
|
setConfiguredCredentialValue: (configTarget, value) => {
|
||||||
|
setProviderWebSearchPluginConfigValue(configTarget, "moonshot", "apiKey", value);
|
||||||
|
},
|
||||||
createTool: (ctx) =>
|
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 { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
|
||||||
import {
|
import { createTopLevelChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers";
|
||||||
createScopedAccountConfigAccessors,
|
|
||||||
createTopLevelChannelConfigBase,
|
|
||||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
|
||||||
import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy";
|
import { collectAllowlistProviderRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy";
|
||||||
import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime";
|
import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-runtime";
|
||||||
import type {
|
import type {
|
||||||
@ -73,20 +70,20 @@ const resolveMSTeamsChannelConfig = (cfg: OpenClawConfig) => ({
|
|||||||
defaultTo: cfg.channels?.msteams?.defaultTo,
|
defaultTo: cfg.channels?.msteams?.defaultTo,
|
||||||
});
|
});
|
||||||
|
|
||||||
const msteamsConfigBase = createTopLevelChannelConfigBase<ResolvedMSTeamsAccount>({
|
const msteamsConfigAdapter = createTopLevelChannelConfigAdapter<
|
||||||
|
ResolvedMSTeamsAccount,
|
||||||
|
{
|
||||||
|
allowFrom?: Array<string | number>;
|
||||||
|
defaultTo?: string;
|
||||||
|
}
|
||||||
|
>({
|
||||||
sectionKey: "msteams",
|
sectionKey: "msteams",
|
||||||
resolveAccount: (cfg) => ({
|
resolveAccount: (cfg) => ({
|
||||||
accountId: DEFAULT_ACCOUNT_ID,
|
accountId: DEFAULT_ACCOUNT_ID,
|
||||||
enabled: cfg.channels?.msteams?.enabled !== false,
|
enabled: cfg.channels?.msteams?.enabled !== false,
|
||||||
configured: Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)),
|
configured: Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)),
|
||||||
}),
|
}),
|
||||||
});
|
resolveAccessorAccount: ({ cfg }) => resolveMSTeamsChannelConfig(cfg),
|
||||||
|
|
||||||
const msteamsConfigAccessors = createScopedAccountConfigAccessors<{
|
|
||||||
allowFrom?: Array<string | number>;
|
|
||||||
defaultTo?: string;
|
|
||||||
}>({
|
|
||||||
resolveAccount: ({ cfg }) => resolveMSTeamsChannelConfig(cfg),
|
|
||||||
resolveAllowFrom: (account) => account.allowFrom,
|
resolveAllowFrom: (account) => account.allowFrom,
|
||||||
formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }),
|
formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }),
|
||||||
resolveDefaultTo: (account) => account.defaultTo,
|
resolveDefaultTo: (account) => account.defaultTo,
|
||||||
@ -157,14 +154,13 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
|||||||
reload: { configPrefixes: ["channels.msteams"] },
|
reload: { configPrefixes: ["channels.msteams"] },
|
||||||
configSchema: buildChannelConfigSchema(MSTeamsConfigSchema),
|
configSchema: buildChannelConfigSchema(MSTeamsConfigSchema),
|
||||||
config: {
|
config: {
|
||||||
...msteamsConfigBase,
|
...msteamsConfigAdapter,
|
||||||
isConfigured: (_account, cfg) => Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)),
|
isConfigured: (_account, cfg) => Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)),
|
||||||
describeAccount: (account) => ({
|
describeAccount: (account) => ({
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
enabled: account.enabled,
|
enabled: account.enabled,
|
||||||
configured: account.configured,
|
configured: account.configured,
|
||||||
}),
|
}),
|
||||||
...msteamsConfigAccessors,
|
|
||||||
},
|
},
|
||||||
security: {
|
security: {
|
||||||
collectWarnings: ({ cfg }) => {
|
collectWarnings: ({ cfg }) => {
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
|
import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
|
||||||
import {
|
import {
|
||||||
createScopedAccountConfigAccessors,
|
createScopedChannelConfigAdapter,
|
||||||
createScopedChannelConfigBase,
|
|
||||||
createScopedDmSecurityResolver,
|
createScopedDmSecurityResolver,
|
||||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
} from "openclaw/plugin-sdk/channel-config-helpers";
|
||||||
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
|
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
|
||||||
@ -51,19 +50,8 @@ const meta = {
|
|||||||
quickstartAllowFrom: true,
|
quickstartAllowFrom: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const nextcloudTalkConfigAccessors =
|
const nextcloudTalkConfigAdapter = createScopedChannelConfigAdapter<
|
||||||
createScopedAccountConfigAccessors<ResolvedNextcloudTalkAccount>({
|
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<
|
|
||||||
ResolvedNextcloudTalkAccount,
|
ResolvedNextcloudTalkAccount,
|
||||||
CoreConfig
|
CoreConfig
|
||||||
>({
|
>({
|
||||||
@ -72,6 +60,12 @@ const nextcloudTalkConfigBase = createScopedChannelConfigBase<
|
|||||||
resolveAccount: (cfg, accountId) => resolveNextcloudTalkAccount({ cfg, accountId }),
|
resolveAccount: (cfg, accountId) => resolveNextcloudTalkAccount({ cfg, accountId }),
|
||||||
defaultAccountId: resolveDefaultNextcloudTalkAccountId,
|
defaultAccountId: resolveDefaultNextcloudTalkAccountId,
|
||||||
clearBaseFields: ["botSecret", "botSecretFile", "baseUrl", "name"],
|
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>({
|
const resolveNextcloudTalkDmPolicy = createScopedDmSecurityResolver<ResolvedNextcloudTalkAccount>({
|
||||||
@ -105,7 +99,7 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
|
|||||||
reload: { configPrefixes: ["channels.nextcloud-talk"] },
|
reload: { configPrefixes: ["channels.nextcloud-talk"] },
|
||||||
configSchema: buildChannelConfigSchema(NextcloudTalkConfigSchema),
|
configSchema: buildChannelConfigSchema(NextcloudTalkConfigSchema),
|
||||||
config: {
|
config: {
|
||||||
...nextcloudTalkConfigBase,
|
...nextcloudTalkConfigAdapter,
|
||||||
isConfigured: (account) => Boolean(account.secret?.trim() && account.baseUrl?.trim()),
|
isConfigured: (account) => Boolean(account.secret?.trim() && account.baseUrl?.trim()),
|
||||||
describeAccount: (account) => ({
|
describeAccount: (account) => ({
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
@ -115,7 +109,6 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
|
|||||||
secretSource: account.secretSource,
|
secretSource: account.secretSource,
|
||||||
baseUrl: account.baseUrl ? "[set]" : "[missing]",
|
baseUrl: account.baseUrl ? "[set]" : "[missing]",
|
||||||
}),
|
}),
|
||||||
...nextcloudTalkConfigAccessors,
|
|
||||||
},
|
},
|
||||||
security: {
|
security: {
|
||||||
resolveDmPolicy: resolveNextcloudTalkDmPolicy,
|
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 {
|
import {
|
||||||
buildChannelConfigSchema,
|
buildChannelConfigSchema,
|
||||||
collectStatusIssuesFromLastError,
|
collectStatusIssuesFromLastError,
|
||||||
createDefaultChannelRuntimeState,
|
createDefaultChannelRuntimeState,
|
||||||
DEFAULT_ACCOUNT_ID,
|
DEFAULT_ACCOUNT_ID,
|
||||||
formatPairingApproveHint,
|
formatPairingApproveHint,
|
||||||
mapAllowFromEntries,
|
|
||||||
type ChannelPlugin,
|
type ChannelPlugin,
|
||||||
} from "openclaw/plugin-sdk/nostr";
|
} from "openclaw/plugin-sdk/nostr";
|
||||||
import {
|
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> = {
|
export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
|
||||||
id: "nostr",
|
id: "nostr",
|
||||||
meta: {
|
meta: {
|
||||||
@ -70,9 +105,7 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
|
|||||||
setupWizard: nostrSetupWizard,
|
setupWizard: nostrSetupWizard,
|
||||||
|
|
||||||
config: {
|
config: {
|
||||||
listAccountIds: (cfg) => listNostrAccountIds(cfg),
|
...nostrConfigAdapter,
|
||||||
resolveAccount: (cfg, accountId) => resolveNostrAccount({ cfg, accountId }),
|
|
||||||
defaultAccountId: (cfg) => resolveDefaultNostrAccountId(cfg),
|
|
||||||
isConfigured: (account) => account.configured,
|
isConfigured: (account) => account.configured,
|
||||||
describeAccount: (account) => ({
|
describeAccount: (account) => ({
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
@ -81,23 +114,6 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
|
|||||||
configured: account.configured,
|
configured: account.configured,
|
||||||
publicKey: account.publicKey,
|
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: {
|
pairing: {
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import {
|
|||||||
normalizeProviderId,
|
normalizeProviderId,
|
||||||
type ProviderPlugin,
|
type ProviderPlugin,
|
||||||
} from "openclaw/plugin-sdk/provider-models";
|
} from "openclaw/plugin-sdk/provider-models";
|
||||||
|
import { createOpenAIAttributionHeadersWrapper } from "openclaw/plugin-sdk/provider-stream";
|
||||||
import { fetchCodexUsage } from "openclaw/plugin-sdk/provider-usage";
|
import { fetchCodexUsage } from "openclaw/plugin-sdk/provider-usage";
|
||||||
import { buildOpenAICodexProvider } from "./openai-codex-catalog.js";
|
import { buildOpenAICodexProvider } from "./openai-codex-catalog.js";
|
||||||
import {
|
import {
|
||||||
@ -248,6 +249,7 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin {
|
|||||||
transport: "auto",
|
transport: "auto",
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
wrapStreamFn: (ctx) => createOpenAIAttributionHeadersWrapper(ctx.streamFn),
|
||||||
normalizeResolvedModel: (ctx) => {
|
normalizeResolvedModel: (ctx) => {
|
||||||
if (normalizeProviderId(ctx.provider) !== PROVIDER_ID) {
|
if (normalizeProviderId(ctx.provider) !== PROVIDER_ID) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@ -11,6 +11,10 @@ import {
|
|||||||
OPENAI_DEFAULT_MODEL,
|
OPENAI_DEFAULT_MODEL,
|
||||||
type ProviderPlugin,
|
type ProviderPlugin,
|
||||||
} from "openclaw/plugin-sdk/provider-models";
|
} from "openclaw/plugin-sdk/provider-models";
|
||||||
|
import {
|
||||||
|
createOpenAIAttributionHeadersWrapper,
|
||||||
|
createOpenAIDefaultTransportWrapper,
|
||||||
|
} from "openclaw/plugin-sdk/provider-stream";
|
||||||
import {
|
import {
|
||||||
cloneFirstTemplateModel,
|
cloneFirstTemplateModel,
|
||||||
findCatalogTemplate,
|
findCatalogTemplate,
|
||||||
@ -169,6 +173,8 @@ export function buildOpenAIProvider(): ProviderPlugin {
|
|||||||
capabilities: {
|
capabilities: {
|
||||||
providerFamily: "openai",
|
providerFamily: "openai",
|
||||||
},
|
},
|
||||||
|
wrapStreamFn: (ctx) =>
|
||||||
|
createOpenAIAttributionHeadersWrapper(createOpenAIDefaultTransportWrapper(ctx.streamFn)),
|
||||||
supportsXHighThinking: ({ modelId }) => matchesExactOrPrefix(modelId, OPENAI_XHIGH_MODEL_IDS),
|
supportsXHighThinking: ({ modelId }) => matchesExactOrPrefix(modelId, OPENAI_XHIGH_MODEL_IDS),
|
||||||
isModernModelRef: ({ modelId }) => matchesExactOrPrefix(modelId, OPENAI_MODERN_MODEL_IDS),
|
isModernModelRef: ({ modelId }) => matchesExactOrPrefix(modelId, OPENAI_MODERN_MODEL_IDS),
|
||||||
buildMissingAuthMessage: (ctx) => {
|
buildMissingAuthMessage: (ctx) => {
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import {
|
|||||||
type ProviderRuntimeModel,
|
type ProviderRuntimeModel,
|
||||||
} from "openclaw/plugin-sdk/core";
|
} from "openclaw/plugin-sdk/core";
|
||||||
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth";
|
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 {
|
import {
|
||||||
getOpenRouterModelCapabilities,
|
getOpenRouterModelCapabilities,
|
||||||
loadOpenRouterModelCapabilities,
|
loadOpenRouterModelCapabilities,
|
||||||
@ -73,6 +73,10 @@ function isOpenRouterCacheTtlModel(modelId: string): boolean {
|
|||||||
return OPENROUTER_CACHE_TTL_MODEL_PREFIXES.some((prefix) => modelId.startsWith(prefix));
|
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({
|
export default definePluginEntry({
|
||||||
id: "openrouter",
|
id: "openrouter",
|
||||||
name: "OpenRouter Provider",
|
name: "OpenRouter Provider",
|
||||||
@ -129,6 +133,8 @@ export default definePluginEntry({
|
|||||||
geminiThoughtSignatureSanitization: true,
|
geminiThoughtSignatureSanitization: true,
|
||||||
geminiThoughtSignatureModelHints: ["gemini"],
|
geminiThoughtSignatureModelHints: ["gemini"],
|
||||||
},
|
},
|
||||||
|
normalizeResolvedModel: ({ modelId, model }) =>
|
||||||
|
isXaiOpenRouterModel(modelId) ? applyXaiModelCompat(model) : undefined,
|
||||||
isModernModelRef: () => true,
|
isModernModelRef: () => true,
|
||||||
wrapStreamFn: (ctx) => {
|
wrapStreamFn: (ctx) => {
|
||||||
let streamFn = ctx.streamFn;
|
let streamFn = ctx.streamFn;
|
||||||
|
|||||||
@ -1,8 +1,40 @@
|
|||||||
{
|
{
|
||||||
"id": "perplexity",
|
"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": {
|
"configSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"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,
|
withTrustedWebSearchEndpoint,
|
||||||
writeCachedSearchPayload,
|
writeCachedSearchPayload,
|
||||||
} from "../../../src/agents/tools/web-search-provider-common.js";
|
} 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 {
|
import type {
|
||||||
WebSearchCredentialResolutionSource,
|
WebSearchCredentialResolutionSource,
|
||||||
WebSearchProviderPlugin,
|
WebSearchProviderPlugin,
|
||||||
@ -71,8 +76,15 @@ type PerplexitySearchApiResponse = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolvePerplexityConfig(searchConfig?: SearchConfigRecord): PerplexityConfig {
|
function resolvePerplexityConfig(
|
||||||
const perplexity = searchConfig?.perplexity;
|
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)
|
return perplexity && typeof perplexity === "object" && !Array.isArray(perplexity)
|
||||||
? (perplexity as PerplexityConfig)
|
? (perplexity as PerplexityConfig)
|
||||||
: {};
|
: {};
|
||||||
@ -98,7 +110,7 @@ function resolvePerplexityApiKey(perplexity?: PerplexityConfig): {
|
|||||||
} {
|
} {
|
||||||
const fromConfig = readConfiguredSecretString(
|
const fromConfig = readConfiguredSecretString(
|
||||||
perplexity?.apiKey,
|
perplexity?.apiKey,
|
||||||
"tools.web.search.perplexity.apiKey",
|
"plugins.entries.perplexity.config.webSearch.apiKey",
|
||||||
);
|
);
|
||||||
if (fromConfig) {
|
if (fromConfig) {
|
||||||
return { apiKey: fromConfig, source: "config" };
|
return { apiKey: fromConfig, source: "config" };
|
||||||
@ -313,16 +325,16 @@ async function runPerplexitySearch(params: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveRuntimeTransport(params: {
|
function resolveRuntimeTransport(params: {
|
||||||
|
config?: OpenClawConfig;
|
||||||
searchConfig?: Record<string, unknown>;
|
searchConfig?: Record<string, unknown>;
|
||||||
resolvedKey?: string;
|
resolvedKey?: string;
|
||||||
keySource: WebSearchCredentialResolutionSource;
|
keySource: WebSearchCredentialResolutionSource;
|
||||||
fallbackEnvVar?: string;
|
fallbackEnvVar?: string;
|
||||||
}): PerplexityTransport | undefined {
|
}): PerplexityTransport | undefined {
|
||||||
const perplexity = params.searchConfig?.perplexity;
|
const scoped = resolvePerplexityConfig(
|
||||||
const scoped =
|
params.config,
|
||||||
perplexity && typeof perplexity === "object" && !Array.isArray(perplexity)
|
params.searchConfig as SearchConfigRecord | undefined,
|
||||||
? (perplexity as { baseUrl?: string; model?: string })
|
);
|
||||||
: undefined;
|
|
||||||
const configuredBaseUrl = typeof scoped?.baseUrl === "string" ? scoped.baseUrl.trim() : "";
|
const configuredBaseUrl = typeof scoped?.baseUrl === "string" ? scoped.baseUrl.trim() : "";
|
||||||
const configuredModel = typeof scoped?.model === "string" ? scoped.model.trim() : "";
|
const configuredModel = typeof scoped?.model === "string" ? scoped.model.trim() : "";
|
||||||
const baseUrl = (() => {
|
const baseUrl = (() => {
|
||||||
@ -404,10 +416,11 @@ function createPerplexitySchema(transport?: PerplexityTransport) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createPerplexityToolDefinition(
|
function createPerplexityToolDefinition(
|
||||||
|
config?: OpenClawConfig,
|
||||||
searchConfig?: SearchConfigRecord,
|
searchConfig?: SearchConfigRecord,
|
||||||
runtimeTransport?: PerplexityTransport,
|
runtimeTransport?: PerplexityTransport,
|
||||||
): WebSearchProviderToolDefinition {
|
): WebSearchProviderToolDefinition {
|
||||||
const perplexityConfig = resolvePerplexityConfig(searchConfig);
|
const perplexityConfig = resolvePerplexityConfig(config, searchConfig);
|
||||||
const schemaTransport =
|
const schemaTransport =
|
||||||
runtimeTransport ??
|
runtimeTransport ??
|
||||||
(perplexityConfig.baseUrl || perplexityConfig.model ? "chat_completions" : undefined);
|
(perplexityConfig.baseUrl || perplexityConfig.model ? "chat_completions" : undefined);
|
||||||
@ -424,7 +437,7 @@ function createPerplexityToolDefinition(
|
|||||||
return {
|
return {
|
||||||
error: "missing_perplexity_api_key",
|
error: "missing_perplexity_api_key",
|
||||||
message:
|
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",
|
docs: "https://docs.openclaw.ai/tools/web",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -656,8 +669,8 @@ export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin {
|
|||||||
signupUrl: "https://www.perplexity.ai/settings/api",
|
signupUrl: "https://www.perplexity.ai/settings/api",
|
||||||
docsUrl: "https://docs.openclaw.ai/perplexity",
|
docsUrl: "https://docs.openclaw.ai/perplexity",
|
||||||
autoDetectOrder: 50,
|
autoDetectOrder: 50,
|
||||||
credentialPath: "tools.web.search.perplexity.apiKey",
|
credentialPath: "plugins.entries.perplexity.config.webSearch.apiKey",
|
||||||
inactiveSecretPaths: ["tools.web.search.perplexity.apiKey"],
|
inactiveSecretPaths: ["plugins.entries.perplexity.config.webSearch.apiKey"],
|
||||||
getCredentialValue: (searchConfig) => {
|
getCredentialValue: (searchConfig) => {
|
||||||
const perplexity = searchConfig?.perplexity;
|
const perplexity = searchConfig?.perplexity;
|
||||||
return perplexity && typeof perplexity === "object" && !Array.isArray(perplexity)
|
return perplexity && typeof perplexity === "object" && !Array.isArray(perplexity)
|
||||||
@ -672,8 +685,14 @@ export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin {
|
|||||||
}
|
}
|
||||||
(scoped as Record<string, unknown>).apiKey = value;
|
(scoped as Record<string, unknown>).apiKey = value;
|
||||||
},
|
},
|
||||||
|
getConfiguredCredentialValue: (config) =>
|
||||||
|
resolveProviderWebSearchPluginConfig(config, "perplexity")?.apiKey,
|
||||||
|
setConfiguredCredentialValue: (configTarget, value) => {
|
||||||
|
setProviderWebSearchPluginConfigValue(configTarget, "perplexity", "apiKey", value);
|
||||||
|
},
|
||||||
resolveRuntimeMetadata: (ctx) => ({
|
resolveRuntimeMetadata: (ctx) => ({
|
||||||
perplexityTransport: resolveRuntimeTransport({
|
perplexityTransport: resolveRuntimeTransport({
|
||||||
|
config: ctx.config,
|
||||||
searchConfig: ctx.searchConfig,
|
searchConfig: ctx.searchConfig,
|
||||||
resolvedKey: ctx.resolvedCredential?.value,
|
resolvedKey: ctx.resolvedCredential?.value,
|
||||||
keySource: ctx.resolvedCredential?.source ?? "missing",
|
keySource: ctx.resolvedCredential?.source ?? "missing",
|
||||||
@ -682,6 +701,7 @@ export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin {
|
|||||||
}),
|
}),
|
||||||
createTool: (ctx) =>
|
createTool: (ctx) =>
|
||||||
createPerplexityToolDefinition(
|
createPerplexityToolDefinition(
|
||||||
|
ctx.config,
|
||||||
ctx.searchConfig as SearchConfigRecord | undefined,
|
ctx.searchConfig as SearchConfigRecord | undefined,
|
||||||
ctx.runtimeMetadata?.perplexityTransport as PerplexityTransport | undefined,
|
ctx.runtimeMetadata?.perplexityTransport as PerplexityTransport | undefined,
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,2 +1 @@
|
|||||||
export * from "openclaw/plugin-sdk/signal";
|
export * from "./src/index.js";
|
||||||
export * from "openclaw/plugin-sdk/signal-core";
|
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import {
|
|||||||
PAIRING_APPROVED_MESSAGE,
|
PAIRING_APPROVED_MESSAGE,
|
||||||
resolveChannelMediaMaxBytes,
|
resolveChannelMediaMaxBytes,
|
||||||
type ChannelPlugin,
|
type ChannelPlugin,
|
||||||
} from "../runtime-api.js";
|
} from "openclaw/plugin-sdk/signal";
|
||||||
import { resolveSignalAccount, type ResolvedSignalAccount } from "./accounts.js";
|
import { resolveSignalAccount, type ResolvedSignalAccount } from "./accounts.js";
|
||||||
import { markdownToSignalTextChunks } from "./format.js";
|
import { markdownToSignalTextChunks } from "./format.js";
|
||||||
import {
|
import {
|
||||||
@ -31,8 +31,8 @@ import { getSignalRuntime } from "./runtime.js";
|
|||||||
import { signalSetupAdapter } from "./setup-core.js";
|
import { signalSetupAdapter } from "./setup-core.js";
|
||||||
import {
|
import {
|
||||||
collectSignalSecurityWarnings,
|
collectSignalSecurityWarnings,
|
||||||
|
signalConfigAdapter,
|
||||||
createSignalPluginBase,
|
createSignalPluginBase,
|
||||||
signalConfigAccessors,
|
|
||||||
signalResolveDmPolicy,
|
signalResolveDmPolicy,
|
||||||
signalSetupWizard,
|
signalSetupWizard,
|
||||||
} from "./shared.js";
|
} from "./shared.js";
|
||||||
@ -290,7 +290,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
|
|||||||
applyConfigEdit: buildAccountScopedAllowlistConfigEditor({
|
applyConfigEdit: buildAccountScopedAllowlistConfigEditor({
|
||||||
channelId: "signal",
|
channelId: "signal",
|
||||||
normalize: ({ cfg, accountId, values }) =>
|
normalize: ({ cfg, accountId, values }) =>
|
||||||
signalConfigAccessors.formatAllowFrom!({ cfg, accountId, allowFrom: values }),
|
signalConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }),
|
||||||
resolvePaths: (scope) => ({
|
resolvePaths: (scope) => ({
|
||||||
readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]],
|
readPaths: [[scope === "dm" ? "allowFrom" : "groupAllowFrom"]],
|
||||||
writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"],
|
writePath: [scope === "dm" ? "allowFrom" : "groupAllowFrom"],
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
collectAllowlistProviderRestrictSendersWarnings,
|
collectAllowlistProviderRestrictSendersWarnings,
|
||||||
createScopedAccountConfigAccessors,
|
createScopedChannelConfigAdapter,
|
||||||
createScopedChannelConfigBase,
|
|
||||||
createScopedDmSecurityResolver,
|
createScopedDmSecurityResolver,
|
||||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
} from "openclaw/plugin-sdk/channel-config-helpers";
|
||||||
import { createChannelPluginBase } from "openclaw/plugin-sdk/core";
|
import { createChannelPluginBase } from "openclaw/plugin-sdk/core";
|
||||||
@ -11,7 +10,7 @@ import {
|
|||||||
normalizeE164,
|
normalizeE164,
|
||||||
SignalConfigSchema,
|
SignalConfigSchema,
|
||||||
type ChannelPlugin,
|
type ChannelPlugin,
|
||||||
} from "../runtime-api.js";
|
} from "openclaw/plugin-sdk/signal";
|
||||||
import {
|
import {
|
||||||
listSignalAccountIds,
|
listSignalAccountIds,
|
||||||
resolveDefaultSignalAccountId,
|
resolveDefaultSignalAccountId,
|
||||||
@ -30,8 +29,12 @@ export const signalSetupWizard = createSignalSetupWizardProxy(
|
|||||||
async () => (await loadSignalChannelRuntime()).signalSetupWizard,
|
async () => (await loadSignalChannelRuntime()).signalSetupWizard,
|
||||||
);
|
);
|
||||||
|
|
||||||
export const signalConfigAccessors = createScopedAccountConfigAccessors({
|
export const signalConfigAdapter = createScopedChannelConfigAdapter<ResolvedSignalAccount>({
|
||||||
resolveAccount: ({ cfg, accountId }) => resolveSignalAccount({ cfg, accountId }),
|
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,
|
resolveAllowFrom: (account: ResolvedSignalAccount) => account.config.allowFrom,
|
||||||
formatAllowFrom: (allowFrom) =>
|
formatAllowFrom: (allowFrom) =>
|
||||||
allowFrom
|
allowFrom
|
||||||
@ -42,14 +45,6 @@ export const signalConfigAccessors = createScopedAccountConfigAccessors({
|
|||||||
resolveDefaultTo: (account: ResolvedSignalAccount) => account.config.defaultTo,
|
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>({
|
export const signalResolveDmPolicy = createScopedDmSecurityResolver<ResolvedSignalAccount>({
|
||||||
channelKey: SIGNAL_CHANNEL,
|
channelKey: SIGNAL_CHANNEL,
|
||||||
resolvePolicy: (account) => account.config.dmPolicy,
|
resolvePolicy: (account) => account.config.dmPolicy,
|
||||||
@ -107,7 +102,7 @@ export function createSignalPluginBase(params: {
|
|||||||
reload: { configPrefixes: ["channels.signal"] },
|
reload: { configPrefixes: ["channels.signal"] },
|
||||||
configSchema: buildChannelConfigSchema(SignalConfigSchema),
|
configSchema: buildChannelConfigSchema(SignalConfigSchema),
|
||||||
config: {
|
config: {
|
||||||
...signalConfigBase,
|
...signalConfigAdapter,
|
||||||
isConfigured: (account) => account.configured,
|
isConfigured: (account) => account.configured,
|
||||||
describeAccount: (account) => ({
|
describeAccount: (account) => ({
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
@ -116,7 +111,6 @@ export function createSignalPluginBase(params: {
|
|||||||
configured: account.configured,
|
configured: account.configured,
|
||||||
baseUrl: account.baseUrl,
|
baseUrl: account.baseUrl,
|
||||||
}),
|
}),
|
||||||
...signalConfigAccessors,
|
|
||||||
},
|
},
|
||||||
security: {
|
security: {
|
||||||
resolveDmPolicy: signalResolveDmPolicy,
|
resolveDmPolicy: signalResolveDmPolicy,
|
||||||
|
|||||||
@ -7,13 +7,13 @@ import {
|
|||||||
hasConfiguredSecretInput,
|
hasConfiguredSecretInput,
|
||||||
normalizeSecretInputString,
|
normalizeSecretInputString,
|
||||||
} from "openclaw/plugin-sdk/config-runtime";
|
} from "openclaw/plugin-sdk/config-runtime";
|
||||||
import type { SlackAccountConfig } from "openclaw/plugin-sdk/slack";
|
|
||||||
import type { SlackAccountSurfaceFields } from "./account-surface-fields.js";
|
import type { SlackAccountSurfaceFields } from "./account-surface-fields.js";
|
||||||
import {
|
import {
|
||||||
mergeSlackAccountConfig,
|
mergeSlackAccountConfig,
|
||||||
resolveDefaultSlackAccountId,
|
resolveDefaultSlackAccountId,
|
||||||
type SlackTokenSource,
|
type SlackTokenSource,
|
||||||
} from "./accounts.js";
|
} from "./accounts.js";
|
||||||
|
import type { SlackAccountConfig } from "./runtime-api.js";
|
||||||
|
|
||||||
export type SlackCredentialStatus = "available" | "configured_unavailable" | "missing";
|
export type SlackCredentialStatus = "available" | "configured_unavailable" | "missing";
|
||||||
|
|
||||||
|
|||||||
@ -6,8 +6,8 @@ import {
|
|||||||
resolveAccountEntry,
|
resolveAccountEntry,
|
||||||
type OpenClawConfig,
|
type OpenClawConfig,
|
||||||
} from "openclaw/plugin-sdk/account-resolution";
|
} from "openclaw/plugin-sdk/account-resolution";
|
||||||
import type { SlackAccountConfig } from "openclaw/plugin-sdk/slack";
|
|
||||||
import type { SlackAccountSurfaceFields } from "./account-surface-fields.js";
|
import type { SlackAccountSurfaceFields } from "./account-surface-fields.js";
|
||||||
|
import type { SlackAccountConfig } from "./runtime-api.js";
|
||||||
import { resolveSlackAppToken, resolveSlackBotToken, resolveSlackUserToken } from "./token.js";
|
import { resolveSlackAppToken, resolveSlackBotToken, resolveSlackUserToken } from "./token.js";
|
||||||
|
|
||||||
export type SlackTokenSource = "env" | "config" | "none";
|
export type SlackTokenSource = "env" | "config" | "none";
|
||||||
|
|||||||
@ -1,14 +1,4 @@
|
|||||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
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 { resolveSlackAccount } from "./accounts.js";
|
||||||
import {
|
import {
|
||||||
deleteSlackMessage,
|
deleteSlackMessage,
|
||||||
@ -27,6 +17,16 @@ import {
|
|||||||
unpinSlackMessage,
|
unpinSlackMessage,
|
||||||
} from "./actions.js";
|
} from "./actions.js";
|
||||||
import { parseSlackBlocksInput } from "./blocks-input.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 { recordSlackThreadParticipation } from "./sent-thread-cache.js";
|
||||||
import { parseSlackTarget, resolveSlackChannelId } from "./targets.js";
|
import { parseSlackTarget, resolveSlackChannelId } from "./targets.js";
|
||||||
|
|
||||||
|
|||||||
@ -4,11 +4,11 @@ import {
|
|||||||
type ChannelMessageActionAdapter,
|
type ChannelMessageActionAdapter,
|
||||||
type ChannelMessageToolDiscovery,
|
type ChannelMessageToolDiscovery,
|
||||||
} from "openclaw/plugin-sdk/channel-runtime";
|
} from "openclaw/plugin-sdk/channel-runtime";
|
||||||
import { isSlackInteractiveRepliesEnabled } from "openclaw/plugin-sdk/slack";
|
|
||||||
import type { SlackActionContext } from "./action-runtime.js";
|
import type { SlackActionContext } from "./action-runtime.js";
|
||||||
import { handleSlackAction } from "./action-runtime.js";
|
import { handleSlackAction } from "./action-runtime.js";
|
||||||
import { handleSlackMessageAction } from "./message-action-dispatch.js";
|
import { handleSlackMessageAction } from "./message-action-dispatch.js";
|
||||||
import { extractSlackToolSend, listSlackMessageActions } from "./message-actions.js";
|
import { extractSlackToolSend, listSlackMessageActions } from "./message-actions.js";
|
||||||
|
import { isSlackInteractiveRepliesEnabled } from "./runtime-api.js";
|
||||||
import { resolveSlackChannelId } from "./targets.js";
|
import { resolveSlackChannelId } from "./targets.js";
|
||||||
|
|
||||||
type SlackActionInvoke = (
|
type SlackActionInvoke = (
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { type ChannelPlugin } from "openclaw/plugin-sdk/slack";
|
|
||||||
import { type ResolvedSlackAccount } from "./accounts.js";
|
import { type ResolvedSlackAccount } from "./accounts.js";
|
||||||
|
import { type ChannelPlugin } from "./runtime-api.js";
|
||||||
import { slackSetupAdapter } from "./setup-core.js";
|
import { slackSetupAdapter } from "./setup-core.js";
|
||||||
import { slackSetupWizard } from "./setup-surface.js";
|
import { slackSetupWizard } from "./setup-surface.js";
|
||||||
import { createSlackPluginBase } from "./shared.js";
|
import { createSlackPluginBase } from "./shared.js";
|
||||||
|
|||||||
@ -10,20 +10,6 @@ import {
|
|||||||
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
|
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
|
||||||
import { buildOutboundBaseSessionKey, normalizeOutboundThreadId } from "openclaw/plugin-sdk/core";
|
import { buildOutboundBaseSessionKey, normalizeOutboundThreadId } from "openclaw/plugin-sdk/core";
|
||||||
import { resolveThreadSessionKeys, type RoutePeer } from "openclaw/plugin-sdk/routing";
|
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 { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js";
|
||||||
import {
|
import {
|
||||||
listEnabledSlackAccounts,
|
listEnabledSlackAccounts,
|
||||||
@ -31,6 +17,7 @@ import {
|
|||||||
resolveSlackReplyToMode,
|
resolveSlackReplyToMode,
|
||||||
type ResolvedSlackAccount,
|
type ResolvedSlackAccount,
|
||||||
} from "./accounts.js";
|
} from "./accounts.js";
|
||||||
|
import type { SlackActionContext } from "./action-runtime.js";
|
||||||
import { parseSlackBlocksInput } from "./blocks-input.js";
|
import { parseSlackBlocksInput } from "./blocks-input.js";
|
||||||
import { createSlackActions } from "./channel-actions.js";
|
import { createSlackActions } from "./channel-actions.js";
|
||||||
import { createSlackWebClient } from "./client.js";
|
import { createSlackWebClient } from "./client.js";
|
||||||
@ -39,6 +26,17 @@ import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js";
|
|||||||
import { normalizeAllowListLower } from "./monitor/allow-list.js";
|
import { normalizeAllowListLower } from "./monitor/allow-list.js";
|
||||||
import type { SlackProbe } from "./probe.js";
|
import type { SlackProbe } from "./probe.js";
|
||||||
import { resolveSlackUserAllowlist } from "./resolve-users.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 { getSlackRuntime } from "./runtime.js";
|
||||||
import { fetchSlackScopes } from "./scopes.js";
|
import { fetchSlackScopes } from "./scopes.js";
|
||||||
import { slackSetupAdapter } from "./setup-core.js";
|
import { slackSetupAdapter } from "./setup-core.js";
|
||||||
@ -46,7 +44,7 @@ import { slackSetupWizard } from "./setup-surface.js";
|
|||||||
import {
|
import {
|
||||||
createSlackPluginBase,
|
createSlackPluginBase,
|
||||||
isSlackPluginAccountConfigured,
|
isSlackPluginAccountConfigured,
|
||||||
slackConfigAccessors,
|
slackConfigAdapter,
|
||||||
SLACK_CHANNEL,
|
SLACK_CHANNEL,
|
||||||
} from "./shared.js";
|
} from "./shared.js";
|
||||||
import { parseSlackTarget } from "./targets.js";
|
import { parseSlackTarget } from "./targets.js";
|
||||||
@ -354,7 +352,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
|||||||
applyConfigEdit: buildAccountScopedAllowlistConfigEditor({
|
applyConfigEdit: buildAccountScopedAllowlistConfigEditor({
|
||||||
channelId: "slack",
|
channelId: "slack",
|
||||||
normalize: ({ cfg, accountId, values }) =>
|
normalize: ({ cfg, accountId, values }) =>
|
||||||
slackConfigAccessors.formatAllowFrom!({ cfg, accountId, allowFrom: values }),
|
slackConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }),
|
||||||
resolvePaths: resolveLegacyDmAllowlistConfigPaths,
|
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,
|
normalizeInteractiveReply,
|
||||||
type ChannelMessageActionContext,
|
type ChannelMessageActionContext,
|
||||||
} from "openclaw/plugin-sdk/channel-runtime";
|
} from "openclaw/plugin-sdk/channel-runtime";
|
||||||
import { readNumberParam, readStringParam } from "openclaw/plugin-sdk/slack-core";
|
|
||||||
import { parseSlackBlocksInput } from "./blocks-input.js";
|
import { parseSlackBlocksInput } from "./blocks-input.js";
|
||||||
import { buildSlackInteractiveBlocks } from "./blocks-render.js";
|
import { buildSlackInteractiveBlocks } from "./blocks-render.js";
|
||||||
|
import { readNumberParam, readStringParam } from "./runtime-api.js";
|
||||||
|
|
||||||
type SlackActionInvoke = (
|
type SlackActionInvoke = (
|
||||||
action: Record<string, unknown>,
|
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 { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
|
||||||
import {
|
import { createScopedChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers";
|
||||||
createScopedAccountConfigAccessors,
|
|
||||||
createScopedChannelConfigBase,
|
|
||||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
|
||||||
import { createChannelPluginBase } from "openclaw/plugin-sdk/core";
|
import { createChannelPluginBase } from "openclaw/plugin-sdk/core";
|
||||||
import {
|
import {
|
||||||
formatDocsLink,
|
formatDocsLink,
|
||||||
hasConfiguredSecretInput,
|
hasConfiguredSecretInput,
|
||||||
patchChannelConfigForAccount,
|
patchChannelConfigForAccount,
|
||||||
} from "openclaw/plugin-sdk/setup";
|
} 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 { inspectSlackAccount } from "./account-inspect.js";
|
||||||
import {
|
import {
|
||||||
listSlackAccountIds,
|
listSlackAccountIds,
|
||||||
@ -24,6 +14,13 @@ import {
|
|||||||
type ResolvedSlackAccount,
|
type ResolvedSlackAccount,
|
||||||
} from "./accounts.js";
|
} from "./accounts.js";
|
||||||
import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.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;
|
export const SLACK_CHANNEL = "slack" as const;
|
||||||
|
|
||||||
@ -145,20 +142,16 @@ export function isSlackSetupAccountConfigured(account: ResolvedSlackAccount): bo
|
|||||||
return hasConfiguredBotToken && hasConfiguredAppToken;
|
return hasConfiguredBotToken && hasConfiguredAppToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const slackConfigAccessors = createScopedAccountConfigAccessors({
|
export const slackConfigAdapter = createScopedChannelConfigAdapter<ResolvedSlackAccount>({
|
||||||
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({
|
|
||||||
sectionKey: SLACK_CHANNEL,
|
sectionKey: SLACK_CHANNEL,
|
||||||
listAccountIds: listSlackAccountIds,
|
listAccountIds: listSlackAccountIds,
|
||||||
resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }),
|
resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }),
|
||||||
inspectAccount: (cfg, accountId) => inspectSlackAccount({ cfg, accountId }),
|
inspectAccount: (cfg, accountId) => inspectSlackAccount({ cfg, accountId }),
|
||||||
defaultAccountId: resolveDefaultSlackAccountId,
|
defaultAccountId: resolveDefaultSlackAccountId,
|
||||||
clearBaseFields: ["botToken", "appToken", "name"],
|
clearBaseFields: ["botToken", "appToken", "name"],
|
||||||
|
resolveAllowFrom: (account: ResolvedSlackAccount) => account.dm?.allowFrom,
|
||||||
|
formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }),
|
||||||
|
resolveDefaultTo: (account: ResolvedSlackAccount) => account.config.defaultTo,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function createSlackPluginBase(params: {
|
export function createSlackPluginBase(params: {
|
||||||
@ -208,7 +201,7 @@ export function createSlackPluginBase(params: {
|
|||||||
reload: { configPrefixes: ["channels.slack"] },
|
reload: { configPrefixes: ["channels.slack"] },
|
||||||
configSchema: buildChannelConfigSchema(SlackConfigSchema),
|
configSchema: buildChannelConfigSchema(SlackConfigSchema),
|
||||||
config: {
|
config: {
|
||||||
...slackConfigBase,
|
...slackConfigAdapter,
|
||||||
isConfigured: (account) => isSlackPluginAccountConfigured(account),
|
isConfigured: (account) => isSlackPluginAccountConfigured(account),
|
||||||
describeAccount: (account) => ({
|
describeAccount: (account) => ({
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
@ -218,7 +211,6 @@ export function createSlackPluginBase(params: {
|
|||||||
botTokenSource: account.botTokenSource,
|
botTokenSource: account.botTokenSource,
|
||||||
appTokenSource: account.appTokenSource,
|
appTokenSource: account.appTokenSource,
|
||||||
}),
|
}),
|
||||||
...slackConfigAccessors,
|
|
||||||
},
|
},
|
||||||
setup: params.setup,
|
setup: params.setup,
|
||||||
}) as Pick<
|
}) as Pick<
|
||||||
|
|||||||
@ -57,6 +57,16 @@ describe("createSynologyChatPlugin", () => {
|
|||||||
const plugin = createSynologyChatPlugin();
|
const plugin = createSynologyChatPlugin();
|
||||||
expect(plugin.config.defaultAccountId?.({})).toBe("default");
|
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", () => {
|
describe("security", () => {
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createHybridChannelConfigBase,
|
createHybridChannelConfigAdapter,
|
||||||
createScopedDmSecurityResolver,
|
createScopedDmSecurityResolver,
|
||||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
} from "openclaw/plugin-sdk/channel-config-helpers";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@ -32,7 +32,7 @@ const resolveSynologyChatDmPolicy = createScopedDmSecurityResolver<ResolvedSynol
|
|||||||
normalizeEntry: (raw) => raw.toLowerCase().trim(),
|
normalizeEntry: (raw) => raw.toLowerCase().trim(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const synologyChatConfigBase = createHybridChannelConfigBase<ResolvedSynologyChatAccount>({
|
const synologyChatConfigAdapter = createHybridChannelConfigAdapter<ResolvedSynologyChatAccount>({
|
||||||
sectionKey: CHANNEL_ID,
|
sectionKey: CHANNEL_ID,
|
||||||
listAccountIds: (cfg: any) => listAccountIds(cfg),
|
listAccountIds: (cfg: any) => listAccountIds(cfg),
|
||||||
resolveAccount: (cfg: any, accountId?: string | null) => resolveAccount(cfg, accountId),
|
resolveAccount: (cfg: any, accountId?: string | null) => resolveAccount(cfg, accountId),
|
||||||
@ -48,6 +48,9 @@ const synologyChatConfigBase = createHybridChannelConfigBase<ResolvedSynologyCha
|
|||||||
"botName",
|
"botName",
|
||||||
"allowInsecureSsl",
|
"allowInsecureSsl",
|
||||||
],
|
],
|
||||||
|
resolveAllowFrom: (account) => account.allowedUserIds,
|
||||||
|
formatAllowFrom: (allowFrom) =>
|
||||||
|
allowFrom.map((entry) => String(entry).trim().toLowerCase()).filter(Boolean),
|
||||||
});
|
});
|
||||||
|
|
||||||
function waitUntilAbort(signal?: AbortSignal, onAbort?: () => void): Promise<void> {
|
function waitUntilAbort(signal?: AbortSignal, onAbort?: () => void): Promise<void> {
|
||||||
@ -100,7 +103,7 @@ export function createSynologyChatPlugin() {
|
|||||||
setupWizard: synologyChatSetupWizard,
|
setupWizard: synologyChatSetupWizard,
|
||||||
|
|
||||||
config: {
|
config: {
|
||||||
...synologyChatConfigBase,
|
...synologyChatConfigAdapter,
|
||||||
},
|
},
|
||||||
|
|
||||||
pairing: {
|
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 { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime";
|
||||||
import { shouldDebounceTextInbound } from "openclaw/plugin-sdk/channel-runtime";
|
import { shouldDebounceTextInbound } from "openclaw/plugin-sdk/channel-runtime";
|
||||||
import { resolveChannelConfigWrites } 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 { writeConfigFile } from "openclaw/plugin-sdk/config-runtime";
|
||||||
import {
|
import {
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
resolveSessionStoreEntry,
|
resolveSessionStoreEntry,
|
||||||
resolveStorePath,
|
|
||||||
updateSessionStore,
|
updateSessionStore,
|
||||||
} from "openclaw/plugin-sdk/config-runtime";
|
} from "openclaw/plugin-sdk/config-runtime";
|
||||||
import type { DmPolicy } from "openclaw/plugin-sdk/config-runtime";
|
import type { DmPolicy } from "openclaw/plugin-sdk/config-runtime";
|
||||||
@ -18,13 +16,11 @@ import type {
|
|||||||
TelegramTopicConfig,
|
TelegramTopicConfig,
|
||||||
} from "openclaw/plugin-sdk/config-runtime";
|
} from "openclaw/plugin-sdk/config-runtime";
|
||||||
import { applyModelOverrideToSessionEntry } from "openclaw/plugin-sdk/config-runtime";
|
import { applyModelOverrideToSessionEntry } from "openclaw/plugin-sdk/config-runtime";
|
||||||
import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime";
|
|
||||||
import {
|
import {
|
||||||
buildPluginBindingResolvedText,
|
buildPluginBindingResolvedText,
|
||||||
parsePluginBindingApprovalCustomId,
|
parsePluginBindingApprovalCustomId,
|
||||||
resolvePluginConversationBindingApproval,
|
resolvePluginConversationBindingApproval,
|
||||||
} from "openclaw/plugin-sdk/conversation-runtime";
|
} from "openclaw/plugin-sdk/conversation-runtime";
|
||||||
import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime";
|
|
||||||
import { dispatchPluginInteractiveHandler } from "openclaw/plugin-sdk/plugin-runtime";
|
import { dispatchPluginInteractiveHandler } from "openclaw/plugin-sdk/plugin-runtime";
|
||||||
import {
|
import {
|
||||||
createInboundDebouncer,
|
createInboundDebouncer,
|
||||||
@ -36,7 +32,6 @@ import {
|
|||||||
formatModelsAvailableHeader,
|
formatModelsAvailableHeader,
|
||||||
} from "openclaw/plugin-sdk/reply-runtime";
|
} from "openclaw/plugin-sdk/reply-runtime";
|
||||||
import { resolveStoredModelOverride } 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 { buildCommandsMessagePaginated } from "openclaw/plugin-sdk/reply-runtime";
|
||||||
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
|
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||||
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
|
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
|
||||||
@ -47,6 +42,7 @@ import {
|
|||||||
normalizeDmAllowFromWithStore,
|
normalizeDmAllowFromWithStore,
|
||||||
type NormalizedAllowFrom,
|
type NormalizedAllowFrom,
|
||||||
} from "./bot-access.js";
|
} from "./bot-access.js";
|
||||||
|
import { defaultTelegramBotDeps } from "./bot-deps.js";
|
||||||
import {
|
import {
|
||||||
APPROVE_CALLBACK_DATA_RE,
|
APPROVE_CALLBACK_DATA_RE,
|
||||||
hasInboundMedia,
|
hasInboundMedia,
|
||||||
@ -97,7 +93,6 @@ import {
|
|||||||
type ProviderInfo,
|
type ProviderInfo,
|
||||||
} from "./model-buttons.js";
|
} from "./model-buttons.js";
|
||||||
import { buildInlineKeyboard } from "./send.js";
|
import { buildInlineKeyboard } from "./send.js";
|
||||||
import { wasSentByBot } from "./sent-message-cache.js";
|
|
||||||
|
|
||||||
export const registerTelegramHandlers = ({
|
export const registerTelegramHandlers = ({
|
||||||
cfg,
|
cfg,
|
||||||
@ -115,6 +110,7 @@ export const registerTelegramHandlers = ({
|
|||||||
shouldSkipUpdate,
|
shouldSkipUpdate,
|
||||||
processMessage,
|
processMessage,
|
||||||
logger,
|
logger,
|
||||||
|
telegramDeps = defaultTelegramBotDeps,
|
||||||
}: RegisterTelegramHandlerParams) => {
|
}: RegisterTelegramHandlerParams) => {
|
||||||
const DEFAULT_TEXT_FRAGMENT_MAX_GAP_MS = 1500;
|
const DEFAULT_TEXT_FRAGMENT_MAX_GAP_MS = 1500;
|
||||||
const TELEGRAM_TEXT_FRAGMENT_START_THRESHOLD_CHARS = 4000;
|
const TELEGRAM_TEXT_FRAGMENT_START_THRESHOLD_CHARS = 4000;
|
||||||
@ -315,7 +311,9 @@ export const registerTelegramHandlers = ({
|
|||||||
? resolveThreadSessionKeys({ baseSessionKey, threadId: `${params.chatId}:${dmThreadId}` })
|
? resolveThreadSessionKeys({ baseSessionKey, threadId: `${params.chatId}:${dmThreadId}` })
|
||||||
: null;
|
: null;
|
||||||
const sessionKey = threadKeys?.sessionKey ?? baseSessionKey;
|
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 store = loadSessionStore(storePath);
|
||||||
const entry = resolveSessionStoreEntry({ store, sessionKey }).existing;
|
const entry = resolveSessionStoreEntry({ store, sessionKey }).existing;
|
||||||
const storedOverride = resolveStoredModelOverride({
|
const storedOverride = resolveStoredModelOverride({
|
||||||
@ -444,7 +442,7 @@ export const registerTelegramHandlers = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const loadStoreAllowFrom = async () =>
|
const loadStoreAllowFrom = async () =>
|
||||||
readChannelAllowFromStore("telegram", process.env, accountId).catch(() => []);
|
telegramDeps.readChannelAllowFromStore("telegram", process.env, accountId).catch(() => []);
|
||||||
|
|
||||||
const resolveReplyMediaForMessage = async (
|
const resolveReplyMediaForMessage = async (
|
||||||
ctx: TelegramContext,
|
ctx: TelegramContext,
|
||||||
@ -760,7 +758,7 @@ export const registerTelegramHandlers = ({
|
|||||||
if (user?.is_bot) {
|
if (user?.is_bot) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (reactionMode === "own" && !wasSentByBot(chatId, messageId)) {
|
if (reactionMode === "own" && !telegramDeps.wasSentByBot(chatId, messageId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const eventAuthContext = await resolveTelegramEventAuthorizationContext({
|
const eventAuthContext = await resolveTelegramEventAuthorizationContext({
|
||||||
@ -835,7 +833,7 @@ export const registerTelegramHandlers = ({
|
|||||||
const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId });
|
const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId });
|
||||||
// Fresh config for bindings lookup; other routing inputs are payload-derived.
|
// Fresh config for bindings lookup; other routing inputs are payload-derived.
|
||||||
const route = resolveAgentRoute({
|
const route = resolveAgentRoute({
|
||||||
cfg: loadConfig(),
|
cfg: telegramDeps.loadConfig(),
|
||||||
channel: "telegram",
|
channel: "telegram",
|
||||||
accountId,
|
accountId,
|
||||||
peer: { kind: isGroup ? "group" : "direct", id: peerId },
|
peer: { kind: isGroup ? "group" : "direct", id: peerId },
|
||||||
@ -847,7 +845,7 @@ export const registerTelegramHandlers = ({
|
|||||||
for (const r of addedReactions) {
|
for (const r of addedReactions) {
|
||||||
const emoji = r.emoji;
|
const emoji = r.emoji;
|
||||||
const text = `Telegram reaction added: ${emoji} by ${senderLabel} on msg ${messageId}`;
|
const text = `Telegram reaction added: ${emoji} by ${senderLabel} on msg ${messageId}`;
|
||||||
enqueueSystemEvent(text, {
|
telegramDeps.enqueueSystemEvent(text, {
|
||||||
sessionKey,
|
sessionKey,
|
||||||
contextKey: `telegram:reaction:add:${chatId}:${messageId}:${user?.id ?? "anon"}:${emoji}`,
|
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 agentId = paginationMatch[2]?.trim() || resolveDefaultAgentId(cfg);
|
||||||
const skillCommands = listSkillCommandsForAgents({
|
const skillCommands = telegramDeps.listSkillCommandsForAgents({
|
||||||
cfg,
|
cfg,
|
||||||
agentIds: [agentId],
|
agentIds: [agentId],
|
||||||
});
|
});
|
||||||
@ -1460,7 +1458,7 @@ export const registerTelegramHandlers = ({
|
|||||||
// Directly set model override in session
|
// Directly set model override in session
|
||||||
try {
|
try {
|
||||||
// Get session store path
|
// Get session store path
|
||||||
const storePath = resolveStorePath(cfg.session?.store, {
|
const storePath = telegramDeps.resolveStorePath(cfg.session?.store, {
|
||||||
agentId: sessionState.agentId,
|
agentId: sessionState.agentId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1540,7 +1538,7 @@ export const registerTelegramHandlers = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if old chat ID has config and migrate it
|
// Check if old chat ID has config and migrate it
|
||||||
const currentConfig = loadConfig();
|
const currentConfig = telegramDeps.loadConfig();
|
||||||
const migration = migrateTelegramGroupConfig({
|
const migration = migrateTelegramGroupConfig({
|
||||||
cfg: currentConfig,
|
cfg: currentConfig,
|
||||||
accountId,
|
accountId,
|
||||||
|
|||||||
@ -24,10 +24,10 @@ import type {
|
|||||||
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
|
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
|
||||||
import { resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime";
|
import { resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime";
|
||||||
import { clearHistoryEntriesIfEnabled } 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 type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||||
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||||
import type { RuntimeEnv } 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 { TelegramMessageContext } from "./bot-message-context.js";
|
||||||
import type { TelegramBotOptions } from "./bot.js";
|
import type { TelegramBotOptions } from "./bot.js";
|
||||||
import { deliverReplies } from "./bot/delivery.js";
|
import { deliverReplies } from "./bot/delivery.js";
|
||||||
@ -110,6 +110,7 @@ type DispatchTelegramMessageParams = {
|
|||||||
streamMode: TelegramStreamMode;
|
streamMode: TelegramStreamMode;
|
||||||
textLimit: number;
|
textLimit: number;
|
||||||
telegramCfg: TelegramAccountConfig;
|
telegramCfg: TelegramAccountConfig;
|
||||||
|
telegramDeps?: TelegramBotDeps;
|
||||||
opts: Pick<TelegramBotOptions, "token">;
|
opts: Pick<TelegramBotOptions, "token">;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -147,6 +148,7 @@ export const dispatchTelegramMessage = async ({
|
|||||||
streamMode,
|
streamMode,
|
||||||
textLimit,
|
textLimit,
|
||||||
telegramCfg,
|
telegramCfg,
|
||||||
|
telegramDeps = defaultTelegramBotDeps,
|
||||||
opts,
|
opts,
|
||||||
}: DispatchTelegramMessageParams) => {
|
}: DispatchTelegramMessageParams) => {
|
||||||
const {
|
const {
|
||||||
@ -535,7 +537,7 @@ export const dispatchTelegramMessage = async ({
|
|||||||
|
|
||||||
let dispatchError: unknown;
|
let dispatchError: unknown;
|
||||||
try {
|
try {
|
||||||
({ queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({
|
({ queuedFinal } = await telegramDeps.dispatchReplyWithBufferedBlockDispatcher({
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
cfg,
|
cfg,
|
||||||
dispatcherOptions: {
|
dispatcherOptions: {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import type { ReplyToMode } from "openclaw/plugin-sdk/config-runtime";
|
|||||||
import type { TelegramAccountConfig } from "openclaw/plugin-sdk/config-runtime";
|
import type { TelegramAccountConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||||
import { danger } from "openclaw/plugin-sdk/runtime-env";
|
import { danger } from "openclaw/plugin-sdk/runtime-env";
|
||||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||||
|
import type { TelegramBotDeps } from "./bot-deps.js";
|
||||||
import {
|
import {
|
||||||
buildTelegramMessageContext,
|
buildTelegramMessageContext,
|
||||||
type BuildTelegramMessageContextParams,
|
type BuildTelegramMessageContextParams,
|
||||||
@ -21,6 +22,7 @@ type TelegramMessageProcessorDeps = Omit<
|
|||||||
replyToMode: ReplyToMode;
|
replyToMode: ReplyToMode;
|
||||||
streamMode: TelegramStreamMode;
|
streamMode: TelegramStreamMode;
|
||||||
textLimit: number;
|
textLimit: number;
|
||||||
|
telegramDeps: TelegramBotDeps;
|
||||||
opts: Pick<TelegramBotOptions, "token">;
|
opts: Pick<TelegramBotOptions, "token">;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -45,6 +47,7 @@ export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDep
|
|||||||
replyToMode,
|
replyToMode,
|
||||||
streamMode,
|
streamMode,
|
||||||
textLimit,
|
textLimit,
|
||||||
|
telegramDeps,
|
||||||
opts,
|
opts,
|
||||||
} = deps;
|
} = deps;
|
||||||
|
|
||||||
@ -89,6 +92,7 @@ export const createTelegramMessageProcessor = (deps: TelegramMessageProcessorDep
|
|||||||
streamMode,
|
streamMode,
|
||||||
textLimit,
|
textLimit,
|
||||||
telegramCfg,
|
telegramCfg,
|
||||||
|
telegramDeps,
|
||||||
opts,
|
opts,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -56,6 +56,7 @@ export function createNativeCommandTestParams(
|
|||||||
params.resolveTelegramGroupConfig ??
|
params.resolveTelegramGroupConfig ??
|
||||||
((_chatId, _messageThreadId) => ({ groupConfig: undefined, topicConfig: undefined })),
|
((_chatId, _messageThreadId) => ({ groupConfig: undefined, topicConfig: undefined })),
|
||||||
shouldSkipUpdate: params.shouldSkipUpdate ?? (() => false),
|
shouldSkipUpdate: params.shouldSkipUpdate ?? (() => false),
|
||||||
|
telegramDeps: params.telegramDeps,
|
||||||
opts: params.opts ?? { token: "token" },
|
opts: params.opts ?? { token: "token" },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/telegram";
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/telegram";
|
||||||
import { expect, vi } from "vitest";
|
import { expect, vi } from "vitest";
|
||||||
|
import type { TelegramBotDeps } from "./bot-deps.js";
|
||||||
import {
|
import {
|
||||||
createNativeCommandTestParams as createBaseNativeCommandTestParams,
|
createNativeCommandTestParams as createBaseNativeCommandTestParams,
|
||||||
createTelegramPrivateCommandContext,
|
createTelegramPrivateCommandContext,
|
||||||
@ -78,10 +79,23 @@ export function createNativeCommandTestParams(
|
|||||||
cfg: OpenClawConfig,
|
cfg: OpenClawConfig,
|
||||||
params: Partial<RegisterTelegramNativeCommandsParams> = {},
|
params: Partial<RegisterTelegramNativeCommandsParams> = {},
|
||||||
): 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({
|
return createBaseNativeCommandTestParams({
|
||||||
cfg,
|
cfg,
|
||||||
runtime: params.runtime ?? ({} as RuntimeEnv),
|
runtime: params.runtime ?? ({} as RuntimeEnv),
|
||||||
nativeSkillsEnabled: true,
|
nativeSkillsEnabled: true,
|
||||||
|
telegramDeps,
|
||||||
...params,
|
...params,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
pluginCommandMocks,
|
pluginCommandMocks,
|
||||||
resetPluginCommandMocks,
|
resetPluginCommandMocks,
|
||||||
} from "../../../test/helpers/extensions/telegram-plugin-command.js";
|
} from "../../../test/helpers/extensions/telegram-plugin-command.js";
|
||||||
|
import type { TelegramBotDeps } from "./bot-deps.js";
|
||||||
const skillCommandMocks = vi.hoisted(() => ({
|
const skillCommandMocks = vi.hoisted(() => ({
|
||||||
listSkillCommandsForAgents: vi.fn(() => []),
|
listSkillCommandsForAgents: vi.fn(() => []),
|
||||||
}));
|
}));
|
||||||
@ -31,11 +32,33 @@ vi.mock("./bot/delivery.js", () => ({
|
|||||||
import { registerTelegramNativeCommands } from "./bot-native-commands.js";
|
import { registerTelegramNativeCommands } from "./bot-native-commands.js";
|
||||||
import {
|
import {
|
||||||
createCommandBot,
|
createCommandBot,
|
||||||
createNativeCommandTestParams,
|
createNativeCommandTestParams as createNativeCommandTestParamsBase,
|
||||||
createPrivateCommandContext,
|
createPrivateCommandContext,
|
||||||
waitForRegisteredCommands,
|
waitForRegisteredCommands,
|
||||||
} from "./bot-native-commands.menu-test-support.js";
|
} 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", () => {
|
describe("registerTelegramNativeCommands", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
skillCommandMocks.listSkillCommandsForAgents.mockClear();
|
skillCommandMocks.listSkillCommandsForAgents.mockClear();
|
||||||
|
|||||||
@ -37,8 +37,6 @@ import {
|
|||||||
resolveCommandArgMenu,
|
resolveCommandArgMenu,
|
||||||
} from "openclaw/plugin-sdk/reply-runtime";
|
} from "openclaw/plugin-sdk/reply-runtime";
|
||||||
import { finalizeInboundContext } 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 { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||||
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
|
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
|
||||||
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
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 type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||||
import { withTelegramApiErrorLogging } from "./api-logging.js";
|
import { withTelegramApiErrorLogging } from "./api-logging.js";
|
||||||
import { isSenderAllowed, normalizeDmAllowFromWithStore } from "./bot-access.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 type { TelegramMediaRef } from "./bot-message-context.js";
|
||||||
import {
|
import {
|
||||||
buildCappedTelegramMenuCommands,
|
buildCappedTelegramMenuCommands,
|
||||||
@ -101,6 +100,7 @@ export type RegisterTelegramHandlerParams = {
|
|||||||
telegramTransport?: TelegramTransport;
|
telegramTransport?: TelegramTransport;
|
||||||
runtime: RuntimeEnv;
|
runtime: RuntimeEnv;
|
||||||
telegramCfg: TelegramAccountConfig;
|
telegramCfg: TelegramAccountConfig;
|
||||||
|
telegramDeps?: TelegramBotDeps;
|
||||||
allowFrom?: Array<string | number>;
|
allowFrom?: Array<string | number>;
|
||||||
groupAllowFrom?: Array<string | number>;
|
groupAllowFrom?: Array<string | number>;
|
||||||
resolveGroupPolicy: (chatId: string | number) => ChannelGroupPolicy;
|
resolveGroupPolicy: (chatId: string | number) => ChannelGroupPolicy;
|
||||||
@ -142,6 +142,7 @@ export type RegisterTelegramNativeCommandsParams = {
|
|||||||
messageThreadId?: number,
|
messageThreadId?: number,
|
||||||
) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig };
|
) => { groupConfig?: TelegramGroupConfig; topicConfig?: TelegramTopicConfig };
|
||||||
shouldSkipUpdate: (ctx: TelegramUpdateKeyContext) => boolean;
|
shouldSkipUpdate: (ctx: TelegramUpdateKeyContext) => boolean;
|
||||||
|
telegramDeps?: TelegramBotDeps;
|
||||||
opts: { token: string };
|
opts: { token: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -364,6 +365,7 @@ export const registerTelegramNativeCommands = ({
|
|||||||
resolveGroupPolicy,
|
resolveGroupPolicy,
|
||||||
resolveTelegramGroupConfig,
|
resolveTelegramGroupConfig,
|
||||||
shouldSkipUpdate,
|
shouldSkipUpdate,
|
||||||
|
telegramDeps = defaultTelegramBotDeps,
|
||||||
opts,
|
opts,
|
||||||
}: RegisterTelegramNativeCommandsParams) => {
|
}: RegisterTelegramNativeCommandsParams) => {
|
||||||
const silentErrorReplies = telegramCfg.silentErrorReplies === true;
|
const silentErrorReplies = telegramCfg.silentErrorReplies === true;
|
||||||
@ -378,7 +380,10 @@ export const registerTelegramNativeCommands = ({
|
|||||||
}
|
}
|
||||||
const skillCommands =
|
const skillCommands =
|
||||||
nativeEnabled && nativeSkillsEnabled && boundRoute
|
nativeEnabled && nativeSkillsEnabled && boundRoute
|
||||||
? listSkillCommandsForAgents({ cfg, agentIds: [boundRoute.agentId] })
|
? telegramDeps.listSkillCommandsForAgents({
|
||||||
|
cfg,
|
||||||
|
agentIds: [boundRoute.agentId],
|
||||||
|
})
|
||||||
: [];
|
: [];
|
||||||
const nativeCommands = nativeEnabled
|
const nativeCommands = nativeEnabled
|
||||||
? listNativeCommandSpecsForConfig(cfg, {
|
? listNativeCommandSpecsForConfig(cfg, {
|
||||||
@ -756,7 +761,7 @@ export const registerTelegramNativeCommands = ({
|
|||||||
accountId: route.accountId,
|
accountId: route.accountId,
|
||||||
});
|
});
|
||||||
|
|
||||||
await dispatchReplyWithBufferedBlockDispatcher({
|
await telegramDeps.dispatchReplyWithBufferedBlockDispatcher({
|
||||||
ctx: ctxPayload,
|
ctx: ctxPayload,
|
||||||
cfg,
|
cfg,
|
||||||
dispatcherOptions: {
|
dispatcherOptions: {
|
||||||
|
|||||||
@ -7,6 +7,21 @@ import { beforeEach, vi } from "vitest";
|
|||||||
|
|
||||||
type AnyMock = MockFn<(...args: unknown[]) => unknown>;
|
type AnyMock = MockFn<(...args: unknown[]) => unknown>;
|
||||||
type AnyAsyncMock = MockFn<(...args: unknown[]) => Promise<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(() => ({
|
const { sessionStorePath } = vi.hoisted(() => ({
|
||||||
sessionStorePath: `/tmp/openclaw-telegram-${process.pid}-${process.env.VITEST_POOL_ID ?? "0"}.json`,
|
sessionStorePath: `/tmp/openclaw-telegram-${process.pid}-${process.env.VITEST_POOL_ID ?? "0"}.json`,
|
||||||
@ -20,18 +35,21 @@ export function getLoadWebMediaMock(): AnyMock {
|
|||||||
return loadWebMedia;
|
return loadWebMedia;
|
||||||
}
|
}
|
||||||
|
|
||||||
vi.mock("openclaw/plugin-sdk/web-media", () => ({
|
vi.doMock("openclaw/plugin-sdk/web-media", () => ({
|
||||||
loadWebMedia,
|
loadWebMedia,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { loadConfig } = vi.hoisted((): { loadConfig: AnyMock } => ({
|
const { loadConfig } = vi.hoisted((): { loadConfig: AnyMock } => ({
|
||||||
loadConfig: vi.fn(() => ({})),
|
loadConfig: vi.fn(() => ({})),
|
||||||
}));
|
}));
|
||||||
|
const { resolveStorePathMock } = vi.hoisted((): { resolveStorePathMock: AnyMock } => ({
|
||||||
|
resolveStorePathMock: vi.fn((storePath?: string) => storePath ?? sessionStorePath),
|
||||||
|
}));
|
||||||
|
|
||||||
export function getLoadConfigMock(): AnyMock {
|
export function getLoadConfigMock(): AnyMock {
|
||||||
return loadConfig;
|
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")>();
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...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")>();
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
resolveStorePath: vi.fn((storePath) => storePath ?? sessionStorePath),
|
resolveStorePath: resolveStorePathMock,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -68,7 +86,7 @@ export function getUpsertChannelPairingRequestMock(): AnyAsyncMock {
|
|||||||
return upsertChannelPairingRequest;
|
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")>();
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
@ -89,23 +107,36 @@ const skillCommandsHoisted = vi.hoisted(() => ({
|
|||||||
configOverride?: OpenClawConfig,
|
configOverride?: OpenClawConfig,
|
||||||
) => Promise<ReplyPayload | ReplyPayload[] | undefined>
|
) => 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 listSkillCommandsForAgents = skillCommandsHoisted.listSkillCommandsForAgents;
|
||||||
export const replySpy = skillCommandsHoisted.replySpy;
|
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")>();
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
listSkillCommandsForAgents: skillCommandsHoisted.listSkillCommandsForAgents,
|
listSkillCommandsForAgents: skillCommandsHoisted.listSkillCommandsForAgents,
|
||||||
getReplyFromConfig: skillCommandsHoisted.replySpy,
|
getReplyFromConfig: skillCommandsHoisted.replySpy,
|
||||||
__replySpy: skillCommandsHoisted.replySpy,
|
__replySpy: skillCommandsHoisted.replySpy,
|
||||||
dispatchReplyWithBufferedBlockDispatcher: vi.fn(
|
dispatchReplyWithBufferedBlockDispatcher:
|
||||||
async ({ ctx, replyOptions }: { ctx: MsgContext; replyOptions?: GetReplyOptions }) => {
|
skillCommandsHoisted.dispatchReplyWithBufferedBlockDispatcher,
|
||||||
await skillCommandsHoisted.replySpy(ctx, replyOptions);
|
|
||||||
return { queuedFinal: false };
|
|
||||||
},
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -114,7 +145,7 @@ const systemEventsHoisted = vi.hoisted(() => ({
|
|||||||
}));
|
}));
|
||||||
export const enqueueSystemEventSpy: AnyMock = systemEventsHoisted.enqueueSystemEventSpy;
|
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")>();
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
@ -127,7 +158,7 @@ const sentMessageCacheHoisted = vi.hoisted(() => ({
|
|||||||
}));
|
}));
|
||||||
export const wasSentByBot = sentMessageCacheHoisted.wasSentByBot;
|
export const wasSentByBot = sentMessageCacheHoisted.wasSentByBot;
|
||||||
|
|
||||||
vi.mock("./sent-message-cache.js", () => ({
|
vi.doMock("./sent-message-cache.js", () => ({
|
||||||
wasSentByBot: sentMessageCacheHoisted.wasSentByBot,
|
wasSentByBot: sentMessageCacheHoisted.wasSentByBot,
|
||||||
recordSentMessage: vi.fn(),
|
recordSentMessage: vi.fn(),
|
||||||
clearSentMessageCache: vi.fn(),
|
clearSentMessageCache: vi.fn(),
|
||||||
@ -181,7 +212,19 @@ export const {
|
|||||||
getFileSpy,
|
getFileSpy,
|
||||||
} = grammySpies;
|
} = 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 {
|
Bot: class {
|
||||||
api = {
|
api = {
|
||||||
config: { use: grammySpies.useSpy },
|
config: { use: grammySpies.useSpy },
|
||||||
@ -210,32 +253,23 @@ vi.mock("grammy", () => ({
|
|||||||
grammySpies.botCtorSpy(token, options);
|
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) => {
|
sequentialize: (keyFn: (ctx: unknown) => string) => {
|
||||||
sequentializeKey = keyFn;
|
sequentializeKey = keyFn;
|
||||||
return runnerHoisted.sequentializeSpy();
|
return runnerHoisted.sequentializeSpy();
|
||||||
},
|
},
|
||||||
}));
|
|
||||||
|
|
||||||
export const throttlerSpy: AnyMock = runnerHoisted.throttlerSpy;
|
|
||||||
|
|
||||||
vi.mock("@grammyjs/transformer-throttler", () => ({
|
|
||||||
apiThrottler: () => runnerHoisted.throttlerSpy(),
|
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) => {
|
export const getOnHandler = (event: string) => {
|
||||||
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
|
const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1];
|
||||||
@ -310,6 +344,8 @@ beforeEach(() => {
|
|||||||
resetInboundDedupe();
|
resetInboundDedupe();
|
||||||
loadConfig.mockReset();
|
loadConfig.mockReset();
|
||||||
loadConfig.mockReturnValue(DEFAULT_TELEGRAM_TEST_CONFIG);
|
loadConfig.mockReturnValue(DEFAULT_TELEGRAM_TEST_CONFIG);
|
||||||
|
resolveStorePathMock.mockReset();
|
||||||
|
resolveStorePathMock.mockImplementation((storePath?: string) => storePath ?? sessionStorePath);
|
||||||
loadWebMedia.mockReset();
|
loadWebMedia.mockReset();
|
||||||
readChannelAllowFromStore.mockReset();
|
readChannelAllowFromStore.mockReset();
|
||||||
readChannelAllowFromStore.mockResolvedValue([]);
|
readChannelAllowFromStore.mockResolvedValue([]);
|
||||||
@ -324,6 +360,22 @@ beforeEach(() => {
|
|||||||
await opts?.onReplyStart?.();
|
await opts?.onReplyStart?.();
|
||||||
return undefined;
|
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.mockReset();
|
||||||
sendAnimationSpy.mockResolvedValue({ message_id: 78 });
|
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 { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js";
|
||||||
import { withEnvAsync } from "../../../test/helpers/extensions/env.js";
|
import { withEnvAsync } from "../../../test/helpers/extensions/env.js";
|
||||||
import { useFrozenTime, useRealTime } from "../../../test/helpers/extensions/frozen-time.js";
|
import { useFrozenTime, useRealTime } from "../../../test/helpers/extensions/frozen-time.js";
|
||||||
import {
|
const {
|
||||||
answerCallbackQuerySpy,
|
answerCallbackQuerySpy,
|
||||||
botCtorSpy,
|
botCtorSpy,
|
||||||
commandSpy,
|
commandSpy,
|
||||||
@ -26,13 +26,25 @@ import {
|
|||||||
sequentializeSpy,
|
sequentializeSpy,
|
||||||
setMessageReactionSpy,
|
setMessageReactionSpy,
|
||||||
setMyCommandsSpy,
|
setMyCommandsSpy,
|
||||||
|
telegramBotDepsForTest,
|
||||||
|
telegramBotRuntimeForTest,
|
||||||
throttlerSpy,
|
throttlerSpy,
|
||||||
useSpy,
|
useSpy,
|
||||||
} from "./bot.create-telegram-bot.test-harness.js";
|
} = await import("./bot.create-telegram-bot.test-harness.js");
|
||||||
import { resolveTelegramFetch } from "./fetch.js";
|
import { resolveTelegramFetch } from "./fetch.js";
|
||||||
|
|
||||||
// Import after the harness registers `vi.mock(...)` for grammY and Telegram internals.
|
// 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 loadConfig = getLoadConfigMock();
|
||||||
const loadWebMedia = getLoadWebMediaMock();
|
const loadWebMedia = getLoadWebMediaMock();
|
||||||
|
|||||||
@ -1,8 +1,18 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
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";
|
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) {
|
function createWrappedTelegramClientFetch(proxyFetch: typeof fetch) {
|
||||||
const shutdown = new AbortController();
|
const shutdown = new AbortController();
|
||||||
botCtorSpy.mockClear();
|
botCtorSpy.mockClear();
|
||||||
|
|||||||
@ -56,12 +56,7 @@ const apiStub: ApiStub = {
|
|||||||
setMyCommands: vi.fn(async () => undefined),
|
setMyCommands: vi.fn(async () => undefined),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
export const telegramBotRuntimeForTest = {
|
||||||
resetInboundDedupe();
|
|
||||||
resetSaveMediaBufferMock();
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock("grammy", () => ({
|
|
||||||
Bot: class {
|
Bot: class {
|
||||||
api = apiStub;
|
api = apiStub;
|
||||||
use = middlewareUseSpy;
|
use = middlewareUseSpy;
|
||||||
@ -71,20 +66,51 @@ vi.mock("grammy", () => ({
|
|||||||
catch = vi.fn();
|
catch = vi.fn();
|
||||||
constructor(public token: string) {}
|
constructor(public token: string) {}
|
||||||
},
|
},
|
||||||
InputFile: class {},
|
|
||||||
webhookCallback: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@grammyjs/runner", () => ({
|
|
||||||
sequentialize: () => vi.fn(),
|
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");
|
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")>();
|
const actual = await importOriginal<typeof import("undici")>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...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 actual = await importOriginal<typeof import("openclaw/plugin-sdk/media-runtime")>();
|
||||||
const mockModule = Object.create(null) as Record<string, unknown>;
|
const mockModule = Object.create(null) as Record<string, unknown>;
|
||||||
Object.defineProperties(mockModule, Object.getOwnPropertyDescriptors(actual));
|
Object.defineProperties(mockModule, Object.getOwnPropertyDescriptors(actual));
|
||||||
@ -105,7 +131,7 @@ vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => {
|
|||||||
return mockModule;
|
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")>();
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...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")>();
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...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[]),
|
readChannelAllowFromStore: vi.fn(async () => [] as string[]),
|
||||||
upsertChannelPairingRequest: vi.fn(async () => ({
|
upsertChannelPairingRequest: vi.fn(async () => ({
|
||||||
code: "PAIRCODE",
|
code: "PAIRCODE",
|
||||||
@ -131,10 +157,11 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({
|
|||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("openclaw/plugin-sdk/reply-runtime", () => {
|
vi.doMock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
|
||||||
const replySpy = vi.fn(async (_ctx, opts) => {
|
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
|
||||||
await opts?.onReplyStart?.();
|
return {
|
||||||
return undefined;
|
...actual,
|
||||||
});
|
getReplyFromConfig: mediaHarnessReplySpy,
|
||||||
return { getReplyFromConfig: replySpy, __replySpy: replySpy };
|
__replySpy: mediaHarnessReplySpy,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import * as ssrf from "openclaw/plugin-sdk/infra-runtime";
|
import * as ssrf from "openclaw/plugin-sdk/infra-runtime";
|
||||||
import { afterEach, beforeAll, beforeEach, expect, vi, type Mock } from "vitest";
|
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>;
|
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 createTelegramBotRef: typeof import("./bot.js").createTelegramBot;
|
||||||
let replySpyRef: ReturnType<typeof vi.fn>;
|
let replySpyRef: ReturnType<typeof vi.fn>;
|
||||||
|
let onSpyRef: Mock;
|
||||||
|
let sendChatActionSpyRef: Mock;
|
||||||
|
|
||||||
export async function createBotHandler(): Promise<{
|
export async function createBotHandler(): Promise<{
|
||||||
handler: (ctx: Record<string, unknown>) => Promise<void>;
|
handler: (ctx: Record<string, unknown>) => Promise<void>;
|
||||||
@ -39,9 +40,9 @@ export async function createBotHandlerWithOptions(options: {
|
|||||||
replySpy: ReturnType<typeof vi.fn>;
|
replySpy: ReturnType<typeof vi.fn>;
|
||||||
runtimeError: ReturnType<typeof vi.fn>;
|
runtimeError: ReturnType<typeof vi.fn>;
|
||||||
}> {
|
}> {
|
||||||
onSpy.mockClear();
|
onSpyRef.mockClear();
|
||||||
replySpyRef.mockClear();
|
replySpyRef.mockClear();
|
||||||
sendChatActionSpy.mockClear();
|
sendChatActionSpyRef.mockClear();
|
||||||
|
|
||||||
const runtimeError = options.runtimeError ?? vi.fn();
|
const runtimeError = options.runtimeError ?? vi.fn();
|
||||||
const runtimeLog = options.runtimeLog ?? 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>,
|
ctx: Record<string, unknown>,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
expect(handler).toBeDefined();
|
expect(handler).toBeDefined();
|
||||||
@ -102,7 +103,16 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
beforeAll(async () => {
|
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");
|
const replyModule = await import("openclaw/plugin-sdk/reply-runtime");
|
||||||
replySpyRef = (replyModule as unknown as { __replySpy: ReturnType<typeof vi.fn> }).__replySpy;
|
replySpyRef = (replyModule as unknown as { __replySpy: ReturnType<typeof vi.fn> }).__replySpy;
|
||||||
}, TELEGRAM_BOT_IMPORT_TIMEOUT_MS);
|
}, 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,
|
registerPluginInteractiveHandler,
|
||||||
} from "../../../src/plugins/interactive.js";
|
} from "../../../src/plugins/interactive.js";
|
||||||
import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js";
|
import { escapeRegExp, formatEnvelopeTimestamp } from "../../../test/helpers/envelope-timestamp.js";
|
||||||
import {
|
const {
|
||||||
answerCallbackQuerySpy,
|
answerCallbackQuerySpy,
|
||||||
commandSpy,
|
commandSpy,
|
||||||
editMessageReplyMarkupSpy,
|
editMessageReplyMarkupSpy,
|
||||||
@ -22,8 +22,10 @@ import {
|
|||||||
replySpy,
|
replySpy,
|
||||||
sendMessageSpy,
|
sendMessageSpy,
|
||||||
setMyCommandsSpy,
|
setMyCommandsSpy,
|
||||||
|
telegramBotDepsForTest,
|
||||||
|
telegramBotRuntimeForTest,
|
||||||
wasSentByBot,
|
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.
|
// Import after the harness registers `vi.mock(...)` for grammY and Telegram internals.
|
||||||
const { listNativeCommandSpecs, listNativeCommandSpecsForConfig } =
|
const { listNativeCommandSpecs, listNativeCommandSpecsForConfig } =
|
||||||
@ -31,7 +33,14 @@ const { listNativeCommandSpecs, listNativeCommandSpecsForConfig } =
|
|||||||
const { loadSessionStore } = await import("../../../src/config/sessions.js");
|
const { loadSessionStore } = await import("../../../src/config/sessions.js");
|
||||||
const { normalizeTelegramCommandName } =
|
const { normalizeTelegramCommandName } =
|
||||||
await import("../../../src/config/telegram-custom-commands.js");
|
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 loadConfig = getLoadConfigMock();
|
||||||
const readChannelAllowFromStore = getReadChannelAllowFromStoreMock();
|
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