mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-11 08:21:38 +08:00
Compare commits
156 Commits
316
...
feat/plugi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1e6efb0fb | ||
|
|
476ea0d097 | ||
|
|
4be2c52041 | ||
|
|
de6997a203 | ||
|
|
ee63fdb056 | ||
|
|
93e716e775 | ||
|
|
756597e6ad | ||
|
|
328b7bee75 | ||
|
|
78022740fc | ||
|
|
4c0f51df81 | ||
|
|
b57922552e | ||
|
|
58ee283658 | ||
|
|
299ed8cb39 | ||
|
|
2a13508379 | ||
|
|
067496b129 | ||
|
|
3e0ddaf5bc | ||
|
|
d44af743db | ||
|
|
f8a0f9ffd3 | ||
|
|
d007559c38 | ||
|
|
84db697cd6 | ||
|
|
2a5fbf0fd6 | ||
|
|
7db148706a | ||
|
|
f56a9f3b3b | ||
|
|
1ff586cda1 | ||
|
|
314512ae14 | ||
|
|
2247089381 | ||
|
|
92409aa4d6 | ||
|
|
52fb51db77 | ||
|
|
3f86972e46 | ||
|
|
5942726d25 | ||
|
|
ae976a90a5 | ||
|
|
4481c41368 | ||
|
|
aa983566c4 | ||
|
|
6f8f2a012b | ||
|
|
f7f467b042 | ||
|
|
a715b83e67 | ||
|
|
6c5064b437 | ||
|
|
30e43550bb | ||
|
|
4265a59892 | ||
|
|
d9af49a7af | ||
|
|
58d6c16d12 | ||
|
|
6068497409 | ||
|
|
c8c0aeda76 | ||
|
|
489a62e788 | ||
|
|
63443acc2b | ||
|
|
0805add3a4 | ||
|
|
a18167a2cb | ||
|
|
f5ec0e429f | ||
|
|
1fbf863f53 | ||
|
|
e286ba2bab | ||
|
|
ee5113b1ae | ||
|
|
6a465611d8 | ||
|
|
6286ef55da | ||
|
|
9224afca3d | ||
|
|
cc1881a838 | ||
|
|
0273062dfd | ||
|
|
b361667f98 | ||
|
|
24afd52fcd | ||
|
|
1d4fcb6a01 | ||
|
|
724dd5ca3d | ||
|
|
c7554d3072 | ||
|
|
0bbacca828 | ||
|
|
37de88181b | ||
|
|
2d2fe2bf47 | ||
|
|
5f17362667 | ||
|
|
4578351488 | ||
|
|
811efa2db0 | ||
|
|
35a9eeb857 | ||
|
|
4e27e22663 | ||
|
|
ffba320a2c | ||
|
|
0464435777 | ||
|
|
fa5ea4529a | ||
|
|
e57b6be85f | ||
|
|
516e9054de | ||
|
|
5e204df0bf | ||
|
|
88d3b73c6d | ||
|
|
4b71a94450 | ||
|
|
c9dfc35dfd | ||
|
|
9215ff0615 | ||
|
|
7c25af83e4 | ||
|
|
03aea06321 | ||
|
|
a301e2ef87 | ||
|
|
961d8eb095 | ||
|
|
5e9ae0bfd4 | ||
|
|
4948760c65 | ||
|
|
1c66a050c2 | ||
|
|
f007082e06 | ||
|
|
eecb36eff4 | ||
|
|
1420b3bad7 | ||
|
|
1c470c2736 | ||
|
|
d305a80acd | ||
|
|
bc23db501b | ||
|
|
6115a9498c | ||
|
|
8e8f8d0745 | ||
|
|
d8458a1481 | ||
|
|
fcec417d7d | ||
|
|
d2ca915a7f | ||
|
|
88ab29f492 | ||
|
|
534b0c663e | ||
|
|
f66c9b829e | ||
|
|
4f5f1fa724 | ||
|
|
b8af2c65e5 | ||
|
|
4ca1ae8046 | ||
|
|
c7875f193b | ||
|
|
e3f410efb5 | ||
|
|
3fb6e3e91f | ||
|
|
17c0026c04 | ||
|
|
9289f967df | ||
|
|
e76a16dfa5 | ||
|
|
e697fa5e75 | ||
|
|
2156bf0210 | ||
|
|
0ad2da060e | ||
|
|
cc62fd38f6 | ||
|
|
a8302e8eab | ||
|
|
323ad51eb8 | ||
|
|
8be2dea382 | ||
|
|
b27fd7cc49 | ||
|
|
0c95e3f073 | ||
|
|
e5d2181403 | ||
|
|
45a6f769bb | ||
|
|
6eca4e0136 | ||
|
|
24a4ed1013 | ||
|
|
eea069bdc3 | ||
|
|
e063f67ac0 | ||
|
|
26b7260bf4 | ||
|
|
e9cbdc7439 | ||
|
|
d5c6e7af0f | ||
|
|
1f660bf930 | ||
|
|
5c3dc40794 | ||
|
|
28b8e019f7 | ||
|
|
df18f4c517 | ||
|
|
5eb3341db1 | ||
|
|
5e365a8ec4 | ||
|
|
045010a2a5 | ||
|
|
72b8025107 | ||
|
|
4c5c361db7 | ||
|
|
956e746da1 | ||
|
|
7d691a3ce3 | ||
|
|
5c6dca78d9 | ||
|
|
53f8c2047a | ||
|
|
d20e3d5691 | ||
|
|
a89cb679a2 | ||
|
|
13bc70397a | ||
|
|
5c4551458f | ||
|
|
181bd6327f | ||
|
|
42ffe86fc7 | ||
|
|
03a43fe231 | ||
|
|
856592cf00 | ||
|
|
ab96520bba | ||
|
|
c52df32878 | ||
|
|
7fb58afb41 | ||
|
|
7be2d361de | ||
|
|
abc3f27ba9 | ||
|
|
0ba93afda9 | ||
|
|
b7b53b29e8 | ||
|
|
d9e59f7329 |
@@ -85,6 +85,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Discord/voice: make READY auto-join fire-and-forget while keeping the shorter initial voice-connect timeout separate from the longer playback-start wait. (#60345) Thanks @geekhuashan.
|
||||
- Agents/skills: add inherited `agents.defaults.skills` allowlists, make per-agent `agents.list[].skills` replace defaults instead of merging, and scope embedded, session, sandbox, and cron skill snapshots through the effective runtime agent. (#59992) Thanks @gumadeiras.
|
||||
- Matrix/Telegram exec approvals: recover stored same-channel account bindings even when session reply state drifted to another channel, so foreign-channel approvals route to the bound account instead of fanning out or being rejected as ambiguous. (#60417) thanks @gumadeiras.
|
||||
- Plugins/runtime: honor explicit capability allowlists during fallback speech, media-understanding, and image-generation provider loading so bundled capability plugins do not bypass restrictive `plugins.allow` config. (#52262) Thanks @PerfectPan.
|
||||
|
||||
## 2026.4.2
|
||||
|
||||
@@ -194,6 +195,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Telegram/local Bot API: preserve media MIME types for absolute-path downloads so local audio files still trigger transcription and other MIME-based handling. (#54603) Thanks @jzakirov
|
||||
- Channels/WhatsApp: pass inbound message timestamp to model context so the AI can see when WhatsApp messages were sent. (#58590) Thanks @Maninae
|
||||
- QQBot/voice: lazy-load `silk-wasm` in `audio-convert.ts` so qqbot still starts when the optional voice dependency is missing, while voice encode/decode degrades gracefully instead of crashing at module load time. (#58829) Thanks @WideLee.
|
||||
- WhatsApp/groups: fix bot waking up on self-number quoted replies in groups with `selfChatMode` enabled. (#60148) Thanks @lurebat
|
||||
|
||||
## 2026.3.31
|
||||
|
||||
|
||||
@@ -12279,7 +12279,7 @@
|
||||
"advanced"
|
||||
],
|
||||
"label": "Internal Hooks Enabled",
|
||||
"help": "Enables processing for internal hook handlers and configured entries in the internal hook runtime. Keep disabled unless internal hook handlers are intentionally configured.",
|
||||
"help": "Enables processing for internal hooks and configured entries in the internal hook runtime. Keep disabled unless internal hooks are intentionally configured.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
@@ -12345,72 +12345,6 @@
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.handlers",
|
||||
"kind": "core",
|
||||
"type": "array",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Internal Hook Handlers",
|
||||
"help": "List of internal event handlers mapping event names to modules and optional exports. Keep handler definitions explicit so event-to-code routing is auditable.",
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.handlers.*",
|
||||
"kind": "core",
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.handlers.*.event",
|
||||
"kind": "core",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Internal Hook Event",
|
||||
"help": "Internal event name that triggers this handler module when emitted by the runtime. Use stable event naming conventions to avoid accidental overlap across handlers.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.handlers.*.export",
|
||||
"kind": "core",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Internal Hook Export",
|
||||
"help": "Optional named export for the internal hook handler function when module default export is not used. Set this when one module ships multiple handler entrypoints.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.handlers.*.module",
|
||||
"kind": "core",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Internal Hook Module",
|
||||
"help": "Safe relative module path for the internal hook handler implementation loaded at runtime. Keep module files in reviewed directories and avoid dynamic path composition.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.installs",
|
||||
"kind": "core",
|
||||
|
||||
@@ -12278,7 +12278,7 @@
|
||||
"advanced"
|
||||
],
|
||||
"label": "Internal Hooks Enabled",
|
||||
"help": "Enables processing for internal hook handlers and configured entries in the internal hook runtime. Keep disabled unless internal hook handlers are intentionally configured.",
|
||||
"help": "Enables processing for internal hooks and configured entries in the internal hook runtime. Keep disabled unless internal hooks are intentionally configured.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
@@ -12344,72 +12344,6 @@
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.handlers",
|
||||
"kind": "core",
|
||||
"type": "array",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Internal Hook Handlers",
|
||||
"help": "List of internal event handlers mapping event names to modules and optional exports. Keep handler definitions explicit so event-to-code routing is auditable.",
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.handlers.*",
|
||||
"kind": "core",
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.handlers.*.event",
|
||||
"kind": "core",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Internal Hook Event",
|
||||
"help": "Internal event name that triggers this handler module when emitted by the runtime. Use stable event naming conventions to avoid accidental overlap across handlers.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.handlers.*.export",
|
||||
"kind": "core",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Internal Hook Export",
|
||||
"help": "Optional named export for the internal hook handler function when module default export is not used. Set this when one module ships multiple handler entrypoints.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.handlers.*.module",
|
||||
"kind": "core",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Internal Hook Module",
|
||||
"help": "Safe relative module path for the internal hook handler implementation loaded at runtime. Keep module files in reviewed directories and avoid dynamic path composition.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "hooks.internal.installs",
|
||||
"kind": "core",
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -654,7 +654,7 @@ Matrix can act as an exec approval client for a Matrix account.
|
||||
- `channels.matrix.execApprovals.agentFilter`
|
||||
- `channels.matrix.execApprovals.sessionFilter`
|
||||
|
||||
Matrix becomes an exec approval client when `enabled` is true and at least one approver can be resolved. Approvers must be Matrix user IDs such as `@owner:example.org`.
|
||||
Approvers must be Matrix user IDs such as `@owner:example.org`. Matrix auto-enables native exec approvals when `enabled` is unset or `"auto"` and at least one approver can be resolved, either from `execApprovals.approvers` or from `channels.matrix.dm.allowFrom`. Set `enabled: false` to disable Matrix as a native approval client explicitly. Approval requests otherwise fall back to other configured approval routes or the exec approval fallback policy.
|
||||
|
||||
Delivery rules:
|
||||
|
||||
|
||||
@@ -392,7 +392,7 @@ Notes:
|
||||
## Manifest and scope checklist
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Slack app manifest example">
|
||||
<Accordion title="Slack app manifest example" defaultOpen>
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@ Related:
|
||||
|
||||
- Multi-agent routing: [Multi-Agent Routing](/concepts/multi-agent)
|
||||
- Agent workspace: [Agent workspace](/concepts/agent-workspace)
|
||||
- Skill visibility config: [Skills config](/tools/skills-config)
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -31,6 +32,11 @@ openclaw agents delete work
|
||||
|
||||
Use routing bindings to pin inbound channel traffic to a specific agent.
|
||||
|
||||
If you also want different visible skills per agent, configure
|
||||
`agents.defaults.skills` and `agents.list[].skills` in `openclaw.json`. See
|
||||
[Skills config](/tools/skills-config) and
|
||||
[Configuration Reference](/gateway/configuration-reference#agentsdefaultsskills).
|
||||
|
||||
List bindings:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -111,7 +111,8 @@ See [Memory](/concepts/memory) for the workflow and automatic memory flush.
|
||||
|
||||
- `skills/` (optional)
|
||||
- Workspace-specific skills.
|
||||
- Overrides managed/bundled skills when names collide.
|
||||
- Highest-precedence skill location for that workspace.
|
||||
- Overrides project agent skills, personal agent skills, managed skills, bundled skills, and `skills.load.extraDirs` when names collide.
|
||||
|
||||
- `canvas/` (optional)
|
||||
- Canvas UI files for node displays (for example `canvas/index.html`).
|
||||
|
||||
@@ -55,11 +55,14 @@ guidance for how _you_ want them used.
|
||||
|
||||
## Skills
|
||||
|
||||
OpenClaw loads skills from three locations (workspace wins on name conflict):
|
||||
OpenClaw loads skills from these locations (highest precedence first):
|
||||
|
||||
- Bundled (shipped with the install)
|
||||
- Managed/local: `~/.openclaw/skills`
|
||||
- Workspace: `<workspace>/skills`
|
||||
- Project agent skills: `<workspace>/.agents/skills`
|
||||
- Personal agent skills: `~/.agents/skills`
|
||||
- Managed/local: `~/.openclaw/skills`
|
||||
- Bundled (shipped with the install)
|
||||
- Extra skill folders: `skills.load.extraDirs`
|
||||
|
||||
Skills can be gated by config/env (see `skills` in [Gateway configuration](/gateway/configuration)).
|
||||
|
||||
|
||||
@@ -27,8 +27,12 @@ Main agent credentials are **not** shared automatically. Never reuse `agentDir`
|
||||
across agents (it causes auth/session collisions). If you want to share creds,
|
||||
copy `auth-profiles.json` into the other agent's `agentDir`.
|
||||
|
||||
Skills are per-agent via each workspace’s `skills/` folder, with shared skills
|
||||
available from `~/.openclaw/skills`. See [Skills: per-agent vs shared](/tools/skills#per-agent-vs-shared-skills).
|
||||
Skills are loaded from each agent workspace plus shared roots such as
|
||||
`~/.openclaw/skills`, then filtered by the effective agent skill allowlist when
|
||||
configured. Use `agents.defaults.skills` for a shared baseline and
|
||||
`agents.list[].skills` for per-agent replacement. See
|
||||
[Skills: per-agent vs shared](/tools/skills#per-agent-vs-shared-skills) and
|
||||
[Skills: agent skill allowlists](/tools/skills#agent-skill-allowlists).
|
||||
|
||||
The Gateway can host **one agent** (default) or **many agents** side-by-side.
|
||||
|
||||
|
||||
@@ -110,6 +110,10 @@ prompt instructs the model to use `read` to load the SKILL.md at the listed
|
||||
location (workspace, managed, or bundled). If no skills are eligible, the
|
||||
Skills section is omitted.
|
||||
|
||||
Eligibility includes skill metadata gates, runtime environment/config checks,
|
||||
and the effective agent skill allowlist when `agents.defaults.skills` or
|
||||
`agents.list[].skills` is configured.
|
||||
|
||||
```
|
||||
<available_skills>
|
||||
<skill>
|
||||
|
||||
@@ -250,6 +250,7 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
|
||||
"anthropic/claude-sonnet-4-6": { alias: "sonnet" },
|
||||
"openai/gpt-5.2": { alias: "gpt" },
|
||||
},
|
||||
skills: ["github", "weather"], // inherited by agents that omit list[].skills
|
||||
thinkingDefault: "low",
|
||||
verboseDefault: "off",
|
||||
elevatedDefault: "on",
|
||||
@@ -308,12 +309,14 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
|
||||
{
|
||||
id: "main",
|
||||
default: true,
|
||||
// inherits defaults.skills -> github, weather
|
||||
thinkingDefault: "high", // per-agent thinking override
|
||||
reasoningDefault: "on", // per-agent reasoning visibility
|
||||
fastModeDefault: false, // per-agent fast mode
|
||||
},
|
||||
{
|
||||
id: "quick",
|
||||
skills: [], // no skills for this agent
|
||||
fastModeDefault: true, // this agent always runs fast
|
||||
thinkingDefault: "off",
|
||||
},
|
||||
@@ -462,6 +465,27 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
|
||||
|
||||
## Common patterns
|
||||
|
||||
### Shared skill baseline with one override
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: "~/.openclaw/workspace",
|
||||
skills: ["github", "weather"],
|
||||
},
|
||||
list: [
|
||||
{ id: "main", default: true },
|
||||
{ id: "docs", workspace: "~/.openclaw/workspace-docs", skills: ["docs-search"] },
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- `agents.defaults.skills` is the shared baseline.
|
||||
- `agents.list[].skills` replaces that baseline for one agent.
|
||||
- Use `skills: []` when an agent should see no skills.
|
||||
|
||||
### Multi-platform setup
|
||||
|
||||
```json5
|
||||
|
||||
@@ -818,6 +818,30 @@ Optional repository root shown in the system prompt's Runtime line. If unset, Op
|
||||
}
|
||||
```
|
||||
|
||||
### `agents.defaults.skills`
|
||||
|
||||
Optional default skill allowlist for agents that do not set
|
||||
`agents.list[].skills`.
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: { skills: ["github", "weather"] },
|
||||
list: [
|
||||
{ id: "writer" }, // inherits github, weather
|
||||
{ id: "docs", skills: ["docs-search"] }, // replaces defaults
|
||||
{ id: "locked-down", skills: [] }, // no skills
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- Omit `agents.defaults.skills` for unrestricted skills by default.
|
||||
- Omit `agents.list[].skills` to inherit the defaults.
|
||||
- Set `agents.list[].skills: []` for no skills.
|
||||
- A non-empty `agents.list[].skills` list is the final set for that agent; it
|
||||
does not merge with defaults.
|
||||
|
||||
### `agents.defaults.skipBootstrap`
|
||||
|
||||
Disables automatic creation of workspace bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md`).
|
||||
@@ -1425,6 +1449,7 @@ scripts/sandbox-browser-setup.sh # optional browser image
|
||||
reasoningDefault: "on", // per-agent reasoning visibility override
|
||||
fastModeDefault: false, // per-agent fast mode override
|
||||
params: { cacheRetention: "none" }, // overrides matching defaults.models params by key
|
||||
skills: ["docs-search"], // replaces agents.defaults.skills when set
|
||||
identity: {
|
||||
name: "Samantha",
|
||||
theme: "helpful sloth",
|
||||
@@ -1459,6 +1484,7 @@ scripts/sandbox-browser-setup.sh # optional browser image
|
||||
- `default`: when multiple are set, first wins (warning logged). If none set, first list entry is default.
|
||||
- `model`: string form overrides `primary` only; object form `{ primary, fallbacks }` overrides both (`[]` disables global fallbacks). Cron jobs that only override `primary` still inherit default fallbacks unless you set `fallbacks: []`.
|
||||
- `params`: per-agent stream params merged over the selected model entry in `agents.defaults.models`. Use this for agent-specific overrides like `cacheRetention`, `temperature`, or `maxTokens` without duplicating the whole model catalog.
|
||||
- `skills`: optional per-agent skill allowlist. If omitted, the agent inherits `agents.defaults.skills` when set; an explicit list replaces defaults instead of merging, and `[]` means no skills.
|
||||
- `thinkingDefault`: optional per-agent default thinking level (`off | minimal | low | medium | high | xhigh | adaptive`). Overrides `agents.defaults.thinkingDefault` for this agent when no per-message or session override is set.
|
||||
- `reasoningDefault`: optional per-agent default reasoning visibility (`on | off | stream`). Applies when no per-message or session reasoning override is set.
|
||||
- `fastModeDefault`: optional per-agent default for fast mode (`true | false`). Applies when no per-message or session fast-mode override is set.
|
||||
|
||||
@@ -175,6 +175,33 @@ When validation fails:
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Restrict skills per agent">
|
||||
Use `agents.defaults.skills` for a shared baseline, then override specific
|
||||
agents with `agents.list[].skills`:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
skills: ["github", "weather"],
|
||||
},
|
||||
list: [
|
||||
{ id: "writer" }, // inherits github, weather
|
||||
{ id: "docs", skills: ["docs-search"] }, // replaces defaults
|
||||
{ id: "locked-down", skills: [] }, // no skills
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- Omit `agents.defaults.skills` for unrestricted skills by default.
|
||||
- Omit `agents.list[].skills` to inherit the defaults.
|
||||
- Set `agents.list[].skills: []` for no skills.
|
||||
- See [Skills](/tools/skills), [Skills config](/tools/skills-config), and
|
||||
the [Configuration Reference](/gateway/configuration-reference#agentsdefaultsskills).
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Tune gateway channel health monitoring">
|
||||
Control how aggressively the gateway restarts channels that look stale:
|
||||
|
||||
|
||||
@@ -946,11 +946,11 @@ for usage/billing and raise limits as needed.
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="How do I customize skills without keeping the repo dirty?">
|
||||
Use managed overrides instead of editing the repo copy. Put your changes in `~/.openclaw/skills/<name>/SKILL.md` (or add a folder via `skills.load.extraDirs` in `~/.openclaw/openclaw.json`). Precedence is `<workspace>/skills` > `~/.openclaw/skills` > bundled, so managed overrides win without touching git. Only upstream-worthy edits should live in the repo and go out as PRs.
|
||||
Use managed overrides instead of editing the repo copy. Put your changes in `~/.openclaw/skills/<name>/SKILL.md` (or add a folder via `skills.load.extraDirs` in `~/.openclaw/openclaw.json`). Precedence is `<workspace>/skills` → `<workspace>/.agents/skills` → `~/.agents/skills` → `~/.openclaw/skills` → bundled → `skills.load.extraDirs`, so managed overrides still win over bundled skills without touching git. If you need the skill installed globally but only visible to some agents, keep the shared copy in `~/.openclaw/skills` and control visibility with `agents.defaults.skills` and `agents.list[].skills`. Only upstream-worthy edits should live in the repo and go out as PRs.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Can I load skills from a custom folder?">
|
||||
Yes. Add extra directories via `skills.load.extraDirs` in `~/.openclaw/openclaw.json` (lowest precedence). Default precedence remains: `<workspace>/skills` → `~/.openclaw/skills` → bundled → `skills.load.extraDirs`. `clawhub` installs into `./skills` by default, which OpenClaw treats as `<workspace>/skills` on the next session.
|
||||
Yes. Add extra directories via `skills.load.extraDirs` in `~/.openclaw/openclaw.json` (lowest precedence). Default precedence is `<workspace>/skills` → `<workspace>/.agents/skills` → `~/.agents/skills` → `~/.openclaw/skills` → bundled → `skills.load.extraDirs`. `clawhub` installs into `./skills` by default, which OpenClaw treats as `<workspace>/skills` on the next session. If the skill should only be visible to certain agents, pair that with `agents.defaults.skills` or `agents.list[].skills`.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="How can I use different models for different tasks?">
|
||||
@@ -1030,7 +1030,7 @@ for usage/billing and raise limits as needed.
|
||||
openclaw skills update --all
|
||||
```
|
||||
|
||||
Install the separate `clawhub` CLI only if you want to publish or sync your own skills.
|
||||
Install the separate `clawhub` CLI only if you want to publish or sync your own skills. For shared installs across agents, put the skill under `~/.openclaw/skills` and use `agents.defaults.skills` or `agents.list[].skills` if you want to narrow which agents can see it.
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -1106,7 +1106,7 @@ for usage/billing and raise limits as needed.
|
||||
openclaw skills update --all
|
||||
```
|
||||
|
||||
Native installs land in the active workspace `skills/` directory. For shared skills across agents, place them in `~/.openclaw/skills/<name>/SKILL.md`. Some skills expect binaries installed via Homebrew; on Linux that means Linuxbrew (see the Homebrew Linux FAQ entry above). See [Skills](/tools/skills) and [ClawHub](/tools/clawhub).
|
||||
Native installs land in the active workspace `skills/` directory. For shared skills across agents, place them in `~/.openclaw/skills/<name>/SKILL.md`. If only some agents should see a shared install, configure `agents.defaults.skills` or `agents.list[].skills`. Some skills expect binaries installed via Homebrew; on Linux that means Linuxbrew (see the Homebrew Linux FAQ entry above). See [Skills](/tools/skills), [Skills config](/tools/skills-config), and [ClawHub](/tools/clawhub).
|
||||
|
||||
</Accordion>
|
||||
|
||||
|
||||
@@ -105,8 +105,10 @@ The YAML frontmatter supports these fields:
|
||||
| Location | Precedence | Scope |
|
||||
| ------------------------------- | ---------- | --------------------- |
|
||||
| `\<workspace\>/skills/` | Highest | Per-agent |
|
||||
| `\<workspace\>/.agents/skills/` | High | Per-workspace agent |
|
||||
| `~/.agents/skills/` | Medium | Shared agent profile |
|
||||
| `~/.openclaw/skills/` | Medium | Shared (all agents) |
|
||||
| Bundled (shipped with OpenClaw) | Lowest | Global |
|
||||
| Bundled (shipped with OpenClaw) | Low | Global |
|
||||
| `skills.load.extraDirs` | Lowest | Custom shared folders |
|
||||
|
||||
## Related
|
||||
|
||||
@@ -8,7 +8,9 @@ title: "Skills Config"
|
||||
|
||||
# Skills Config
|
||||
|
||||
All skills-related configuration lives under `skills` in `~/.openclaw/openclaw.json`.
|
||||
Most skills loader/install configuration lives under `skills` in
|
||||
`~/.openclaw/openclaw.json`. Agent-specific skill visibility lives under
|
||||
`agents.defaults.skills` and `agents.list[].skills`.
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -51,6 +53,35 @@ Examples:
|
||||
- Native Nano Banana-style setup: `agents.defaults.imageGenerationModel.primary: "google/gemini-3-pro-image-preview"`
|
||||
- Native fal setup: `agents.defaults.imageGenerationModel.primary: "fal/fal-ai/flux/dev"`
|
||||
|
||||
## Agent skill allowlists
|
||||
|
||||
Use agent config when you want the same machine/workspace skill roots, but a
|
||||
different visible skill set per agent.
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
skills: ["github", "weather"],
|
||||
},
|
||||
list: [
|
||||
{ id: "writer" }, // inherits defaults -> github, weather
|
||||
{ id: "docs", skills: ["docs-search"] }, // replaces defaults
|
||||
{ id: "locked-down", skills: [] }, // no skills
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `agents.defaults.skills`: shared baseline allowlist for agents that omit
|
||||
`agents.list[].skills`.
|
||||
- Omit `agents.defaults.skills` to leave skills unrestricted by default.
|
||||
- `agents.list[].skills`: explicit final skill set for that agent; it does not
|
||||
merge with defaults.
|
||||
- `agents.list[].skills: []`: expose no skills for that agent.
|
||||
|
||||
## Fields
|
||||
|
||||
- Built-in skill roots always include `~/.openclaw/skills`, `~/.agents/skills`,
|
||||
@@ -65,6 +96,10 @@ Examples:
|
||||
This only affects **skill installs**; the Gateway runtime should still be Node
|
||||
(Bun not recommended for WhatsApp/Telegram).
|
||||
- `entries.<skillKey>`: per-skill overrides.
|
||||
- `agents.defaults.skills`: optional default skill allowlist inherited by agents
|
||||
that omit `agents.list[].skills`.
|
||||
- `agents.list[].skills`: optional per-agent final skill allowlist; explicit
|
||||
lists replace inherited defaults instead of merging.
|
||||
|
||||
Per-skill fields:
|
||||
|
||||
|
||||
@@ -43,6 +43,42 @@ If the same skill name exists in more than one place, the usual precedence
|
||||
applies: workspace wins, then project agent skills, then personal agent skills,
|
||||
then managed/local, then bundled, then extra dirs.
|
||||
|
||||
## Agent skill allowlists
|
||||
|
||||
Skill **location** and skill **visibility** are separate controls.
|
||||
|
||||
- Location/precedence decides which copy of a same-named skill wins.
|
||||
- Agent allowlists decide which visible skills an agent can actually use.
|
||||
|
||||
Use `agents.defaults.skills` for a shared baseline, then override per agent with
|
||||
`agents.list[].skills`:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
skills: ["github", "weather"],
|
||||
},
|
||||
list: [
|
||||
{ id: "writer" }, // inherits github, weather
|
||||
{ id: "docs", skills: ["docs-search"] }, // replaces defaults
|
||||
{ id: "locked-down", skills: [] }, // no skills
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Omit `agents.defaults.skills` for unrestricted skills by default.
|
||||
- Omit `agents.list[].skills` to inherit `agents.defaults.skills`.
|
||||
- Set `agents.list[].skills: []` for no skills.
|
||||
- A non-empty `agents.list[].skills` list is the final set for that agent; it
|
||||
does not merge with defaults.
|
||||
|
||||
OpenClaw applies the effective agent skill set across prompt building, skill
|
||||
slash-command discovery, sandbox sync, and skill snapshots.
|
||||
|
||||
## Plugins + skills
|
||||
|
||||
Plugins can ship their own skills by listing `skills` directories in
|
||||
@@ -267,6 +303,10 @@ OpenClaw snapshots the eligible skills **when a session starts** and reuses that
|
||||
|
||||
Skills can also refresh mid-session when the skills watcher is enabled or when a new eligible remote node appears (see below). Think of this as a **hot reload**: the refreshed list is picked up on the next agent turn.
|
||||
|
||||
If the effective agent skill allowlist changes for that session, OpenClaw
|
||||
refreshes the snapshot so the visible skills stay aligned with the current
|
||||
agent.
|
||||
|
||||
## Remote macOS nodes (Linux gateway)
|
||||
|
||||
If the Gateway is running on Linux but a **macOS node** is connected **with `system.run` allowed** (Exec approvals security not set to `deny`), OpenClaw can treat macOS-only skills as eligible when the required binaries are present on that node. The agent should execute those skills via the `exec` tool with `host=node`.
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import { streamSimple } from "@mariozechner/pi-ai";
|
||||
import { resolveProviderRequestCapabilities } from "openclaw/plugin-sdk/provider-http";
|
||||
import { streamWithPayloadPatch } from "openclaw/plugin-sdk/provider-stream";
|
||||
import {
|
||||
applyAnthropicPayloadPolicyToParams,
|
||||
resolveAnthropicPayloadPolicy,
|
||||
streamWithPayloadPatch,
|
||||
} from "openclaw/plugin-sdk/provider-stream";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
|
||||
const log = createSubsystemLogger("anthropic-stream");
|
||||
@@ -52,20 +55,6 @@ function isAnthropicOAuthApiKey(apiKey: unknown): boolean {
|
||||
return typeof apiKey === "string" && apiKey.includes("sk-ant-oat");
|
||||
}
|
||||
|
||||
function allowsAnthropicServiceTier(model: {
|
||||
api?: unknown;
|
||||
provider?: unknown;
|
||||
baseUrl?: unknown;
|
||||
}): boolean {
|
||||
return resolveProviderRequestCapabilities({
|
||||
provider: typeof model.provider === "string" ? model.provider : undefined,
|
||||
api: typeof model.api === "string" ? model.api : undefined,
|
||||
baseUrl: typeof model.baseUrl === "string" ? model.baseUrl : undefined,
|
||||
capability: "llm",
|
||||
transport: "stream",
|
||||
}).allowsAnthropicServiceTier;
|
||||
}
|
||||
|
||||
function resolveAnthropicFastServiceTier(enabled: boolean): AnthropicServiceTier {
|
||||
return enabled ? "auto" : "standard_only";
|
||||
}
|
||||
@@ -161,15 +150,19 @@ export function createAnthropicFastModeWrapper(
|
||||
const underlying = baseStreamFn ?? streamSimple;
|
||||
const serviceTier = resolveAnthropicFastServiceTier(enabled);
|
||||
return (model, context, options) => {
|
||||
if (!allowsAnthropicServiceTier(model)) {
|
||||
const payloadPolicy = resolveAnthropicPayloadPolicy({
|
||||
provider: typeof model.provider === "string" ? model.provider : undefined,
|
||||
api: typeof model.api === "string" ? model.api : undefined,
|
||||
baseUrl: typeof model.baseUrl === "string" ? model.baseUrl : undefined,
|
||||
serviceTier,
|
||||
});
|
||||
if (!payloadPolicy.allowsServiceTier) {
|
||||
return underlying(model, context, options);
|
||||
}
|
||||
|
||||
return streamWithPayloadPatch(underlying, model, context, options, (payloadObj) => {
|
||||
if (payloadObj.service_tier === undefined) {
|
||||
payloadObj.service_tier = serviceTier;
|
||||
}
|
||||
});
|
||||
return streamWithPayloadPatch(underlying, model, context, options, (payloadObj) =>
|
||||
applyAnthropicPayloadPolicyToParams(payloadObj, payloadPolicy),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -179,15 +172,19 @@ export function createAnthropicServiceTierWrapper(
|
||||
): StreamFn {
|
||||
const underlying = baseStreamFn ?? streamSimple;
|
||||
return (model, context, options) => {
|
||||
if (!allowsAnthropicServiceTier(model)) {
|
||||
const payloadPolicy = resolveAnthropicPayloadPolicy({
|
||||
provider: typeof model.provider === "string" ? model.provider : undefined,
|
||||
api: typeof model.api === "string" ? model.api : undefined,
|
||||
baseUrl: typeof model.baseUrl === "string" ? model.baseUrl : undefined,
|
||||
serviceTier,
|
||||
});
|
||||
if (!payloadPolicy.allowsServiceTier) {
|
||||
return underlying(model, context, options);
|
||||
}
|
||||
|
||||
return streamWithPayloadPatch(underlying, model, context, options, (payloadObj) => {
|
||||
if (payloadObj.service_tier === undefined) {
|
||||
payloadObj.service_tier = serviceTier;
|
||||
}
|
||||
});
|
||||
return streamWithPayloadPatch(underlying, model, context, options, (payloadObj) =>
|
||||
applyAnthropicPayloadPolicyToParams(payloadObj, payloadPolicy),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,9 @@ export function resolveBlueBubblesAccount(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedBlueBubblesAccount {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const accountId = normalizeAccountId(
|
||||
params.accountId ?? resolveDefaultBlueBubblesAccountId(params.cfg),
|
||||
);
|
||||
const baseEnabled = params.cfg.channels?.bluebubbles?.enabled;
|
||||
const merged = mergeBlueBubblesAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { expect, vi } from "vitest";
|
||||
import { expect, vi, type Mock } from "vitest";
|
||||
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
||||
import { handleBlueBubblesWebhookRequest } from "./monitor.js";
|
||||
import { registerBlueBubblesWebhookTarget } from "./monitor.js";
|
||||
@@ -16,6 +16,11 @@ export type WebhookRequestParams = {
|
||||
};
|
||||
|
||||
export const LOOPBACK_REMOTE_ADDRESSES_FOR_TEST = ["127.0.0.1", "::1", "::ffff:127.0.0.1"] as const;
|
||||
type UnknownMock = Mock<(...args: unknown[]) => unknown>;
|
||||
type HangingWebhookRequestForTest = {
|
||||
req: IncomingMessage;
|
||||
destroyMock: UnknownMock;
|
||||
};
|
||||
|
||||
export function createMockAccount(
|
||||
overrides: Partial<ResolvedBlueBubblesAccount["config"]> = {},
|
||||
@@ -182,7 +187,7 @@ export function createLoopbackWebhookRequestParamsForTest(
|
||||
export function createHangingWebhookRequestForTest(
|
||||
url = "/bluebubbles-webhook?password=test-password",
|
||||
remoteAddress = "127.0.0.1",
|
||||
) {
|
||||
): HangingWebhookRequestForTest {
|
||||
const req = new EventEmitter() as IncomingMessage;
|
||||
const destroyMock = vi.fn();
|
||||
req.method = "POST";
|
||||
|
||||
@@ -217,6 +217,34 @@ describe("bluebubbles setup surface", () => {
|
||||
expect(next?.channels?.bluebubbles?.accounts?.work?.dmPolicy).toBe("open");
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount when accountId is omitted in account resolution", async () => {
|
||||
const { resolveBlueBubblesAccount } = await import("./accounts.js");
|
||||
|
||||
const resolved = resolveBlueBubblesAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
defaultAccount: "work",
|
||||
serverUrl: "http://localhost:3000",
|
||||
password: "top-secret",
|
||||
accounts: {
|
||||
work: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "secret",
|
||||
name: "Work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
});
|
||||
|
||||
expect(resolved.accountId).toBe("work");
|
||||
expect(resolved.name).toBe("Work");
|
||||
expect(resolved.baseUrl).toBe("http://localhost:1234");
|
||||
expect(resolved.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('writes open policy state to the named account and preserves inherited allowFrom with "*"', async () => {
|
||||
const { blueBubblesSetupWizard } = await import("./setup-surface.js");
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ const runtimeApiMocks = vi.hoisted(() => ({
|
||||
registerBrowserCli: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./runtime-api.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./runtime-api.js")>();
|
||||
vi.mock("./runtime-api.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./runtime-api.js")>("./runtime-api.js");
|
||||
return {
|
||||
...actual,
|
||||
createBrowserPluginService: runtimeApiMocks.createBrowserPluginService,
|
||||
|
||||
@@ -115,8 +115,10 @@ vi.mock("../../../src/agents/tools/gateway.js", () => gatewayMocks);
|
||||
const configMocks = vi.hoisted(() => ({
|
||||
loadConfig: vi.fn(() => ({ browser: {} })),
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
|
||||
"openclaw/plugin-sdk/config-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: configMocks.loadConfig,
|
||||
|
||||
@@ -23,27 +23,17 @@ export function isWebSocketUrl(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function shouldSkipCreationTimePolicyResolution(ssrfPolicy?: SsrFPolicy): boolean {
|
||||
if (!ssrfPolicy) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
ssrfPolicy.dangerouslyAllowPrivateNetwork === true &&
|
||||
(!ssrfPolicy.hostnameAllowlist || ssrfPolicy.hostnameAllowlist.length === 0)
|
||||
);
|
||||
}
|
||||
|
||||
export async function assertCdpEndpointAllowed(
|
||||
cdpUrl: string,
|
||||
ssrfPolicy?: SsrFPolicy,
|
||||
): Promise<void> {
|
||||
if (!ssrfPolicy) {
|
||||
return;
|
||||
}
|
||||
const parsed = new URL(cdpUrl);
|
||||
if (!["http:", "https:", "ws:", "wss:"].includes(parsed.protocol)) {
|
||||
throw new Error(`Invalid CDP URL protocol: ${parsed.protocol.replace(":", "")}`);
|
||||
}
|
||||
if (shouldSkipCreationTimePolicyResolution(ssrfPolicy)) {
|
||||
return;
|
||||
}
|
||||
await resolvePinnedHostnameWithPolicy(parsed.hostname, {
|
||||
policy: ssrfPolicy,
|
||||
});
|
||||
|
||||
@@ -1,25 +1,32 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
vi.mock("node:child_process", async () => {
|
||||
const { mockNodeBuiltinModule } = await import("../../../../test/helpers/node-builtin-mocks.js");
|
||||
return mockNodeBuiltinModule(importOriginal, {
|
||||
execFileSync: vi.fn(),
|
||||
});
|
||||
return mockNodeBuiltinModule(
|
||||
() => vi.importActual<typeof import("node:child_process")>("node:child_process"),
|
||||
{
|
||||
execFileSync: vi.fn(),
|
||||
},
|
||||
);
|
||||
});
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
vi.mock("node:fs", async () => {
|
||||
const { mockNodeBuiltinModule } = await import("../../../../test/helpers/node-builtin-mocks.js");
|
||||
const existsSync = vi.fn();
|
||||
const readFileSync = vi.fn();
|
||||
return mockNodeBuiltinModule(
|
||||
importOriginal,
|
||||
() => vi.importActual<typeof import("node:fs")>("node:fs"),
|
||||
{ existsSync, readFileSync },
|
||||
{ mirrorToDefault: true },
|
||||
);
|
||||
});
|
||||
vi.mock("node:os", async (importOriginal) => {
|
||||
vi.mock("node:os", async () => {
|
||||
const { mockNodeBuiltinModule } = await import("../../../../test/helpers/node-builtin-mocks.js");
|
||||
const homedir = vi.fn();
|
||||
return mockNodeBuiltinModule(importOriginal, { homedir }, { mirrorToDefault: true });
|
||||
return mockNodeBuiltinModule(
|
||||
() => vi.importActual<typeof import("node:os")>("node:os"),
|
||||
{ homedir },
|
||||
{ mirrorToDefault: true },
|
||||
);
|
||||
});
|
||||
import { execFileSync } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
|
||||
@@ -22,8 +22,8 @@ const mocks = vi.hoisted(() => ({
|
||||
dispatch: vi.fn(async (): Promise<BrowserDispatchResponse> => okDispatchResponse()),
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
vi.mock("../config/config.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: mocks.loadConfig,
|
||||
|
||||
@@ -17,8 +17,8 @@ const mocks = vi.hoisted(() => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
vi.mock("../config/config.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: mocks.loadConfig,
|
||||
|
||||
@@ -6,8 +6,8 @@ import { resolveOpenClawUserDataDir } from "./chrome.js";
|
||||
import type { BrowserRouteContext, BrowserServerState } from "./server-context.js";
|
||||
import { movePathToTrash } from "./trash.js";
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
vi.mock("../config/config.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: vi.fn(),
|
||||
@@ -141,56 +141,6 @@ describe("BrowserProfilesService", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts remote hostnames without DNS resolution in trusted SSRF mode", async () => {
|
||||
const resolved = resolveBrowserConfig({});
|
||||
const { ctx } = createCtx(resolved);
|
||||
|
||||
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
|
||||
|
||||
const service = createBrowserProfilesService(ctx);
|
||||
const result = await service.createProfile({
|
||||
name: "remote-hostname",
|
||||
cdpUrl: "https://vpn-only.invalid:9222",
|
||||
});
|
||||
|
||||
expect(result.cdpUrl).toBe("https://vpn-only.invalid:9222");
|
||||
expect(result.cdpPort).toBe(9222);
|
||||
expect(result.isRemote).toBe(true);
|
||||
expect(writeConfigFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
browser: expect.objectContaining({
|
||||
profiles: expect.objectContaining({
|
||||
"remote-hostname": expect.objectContaining({
|
||||
cdpUrl: "https://vpn-only.invalid:9222",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects private-network cdpUrl when strict SSRF mode is enabled", async () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
ssrfPolicy: {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
},
|
||||
});
|
||||
const { ctx } = createCtx(resolved);
|
||||
|
||||
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
|
||||
|
||||
const service = createBrowserProfilesService(ctx);
|
||||
|
||||
await expect(
|
||||
service.createProfile({
|
||||
name: "remote",
|
||||
cdpUrl: "http://10.0.0.42:9222",
|
||||
}),
|
||||
).rejects.toThrow(/blocked hostname|private\/internal\/special-use ip address/i);
|
||||
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates existing-session profiles as attach-only local entries", async () => {
|
||||
const resolved = resolveBrowserConfig({});
|
||||
const { ctx, state } = createCtx(resolved);
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { BrowserProfileConfig, OpenClawConfig } from "../config/config.js";
|
||||
import { loadConfig, writeConfigFile } from "../config/config.js";
|
||||
import { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { assertCdpEndpointAllowed } from "./cdp.helpers.js";
|
||||
import { resolveOpenClawUserDataDir } from "./chrome.js";
|
||||
import { parseHttpUrl, resolveProfile } from "./config.js";
|
||||
import {
|
||||
@@ -125,7 +124,6 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
|
||||
let parsed: ReturnType<typeof parseHttpUrl>;
|
||||
try {
|
||||
parsed = parseHttpUrl(rawCdpUrl, "browser.profiles.cdpUrl");
|
||||
await assertCdpEndpointAllowed(parsed.normalized, state.resolved.ssrfPolicy);
|
||||
} catch (err) {
|
||||
throw new BrowserValidationError(String(err));
|
||||
}
|
||||
|
||||
@@ -35,8 +35,8 @@ function buildConfig(): TestConfig {
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
vi.mock("../config/config.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
|
||||
return {
|
||||
...actual,
|
||||
createConfigIO: () => ({
|
||||
|
||||
@@ -11,8 +11,8 @@ const mocks = vi.hoisted(() => ({
|
||||
ensureExtensionRelayForProfiles: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
vi.mock("../config/config.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
|
||||
const browserConfig = {
|
||||
enabled: true,
|
||||
};
|
||||
@@ -24,8 +24,8 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./config.js")>();
|
||||
vi.mock("./config.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./config.js")>("./config.js");
|
||||
return {
|
||||
...actual,
|
||||
resolveBrowserConfig: vi.fn(() => ({
|
||||
|
||||
@@ -225,8 +225,8 @@ function defaultProfilesForState(testPort: number): HarnessState["cfgProfiles"]
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
vi.mock("../config/config.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
|
||||
const loadConfig = () => {
|
||||
return {
|
||||
browser: {
|
||||
|
||||
@@ -35,8 +35,8 @@ const routeCtxMocks = vi.hoisted(() => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../config/config.js")>();
|
||||
vi.mock("../config/config.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => ({
|
||||
@@ -57,8 +57,8 @@ vi.mock("./pw-ai-module.js", () => ({
|
||||
getPwAiModule: vi.fn(async () => pwMocks),
|
||||
}));
|
||||
|
||||
vi.mock("./server-context.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./server-context.js")>();
|
||||
vi.mock("./server-context.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./server-context.js")>("./server-context.js");
|
||||
return {
|
||||
...actual,
|
||||
createBrowserRouteContext: routeCtxMocks.createBrowserRouteContext,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Command } from "commander";
|
||||
import { vi } from "vitest";
|
||||
import * as parentCoreApiModule from "../core-api.js";
|
||||
import * as browserCliSharedModule from "./browser-cli-shared.js";
|
||||
@@ -56,7 +57,7 @@ vi.spyOn(cliCoreApiModule.defaultRuntime, "exit").mockImplementation(browserCliR
|
||||
|
||||
const { registerBrowserManageCommands } = await import("./browser-cli-manage.js");
|
||||
|
||||
export function createBrowserManageProgram(params?: { withParentTimeout?: boolean }) {
|
||||
export function createBrowserManageProgram(params?: { withParentTimeout?: boolean }): Command {
|
||||
const { program, browser, parentOpts } = createBrowserProgram();
|
||||
if (params?.withParentTimeout) {
|
||||
browser.option("--timeout <ms>", "Timeout in ms", "30000");
|
||||
|
||||
@@ -8,8 +8,10 @@ const { loadConfigMock, isNodeCommandAllowedMock, resolveNodeCommandAllowlistMoc
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
|
||||
"openclaw/plugin-sdk/config-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: loadConfigMock,
|
||||
|
||||
@@ -6,12 +6,17 @@ export * from "./src/components.js";
|
||||
export * from "./src/directory-config.js";
|
||||
export * from "./src/exec-approvals.js";
|
||||
export * from "./src/group-policy.js";
|
||||
export type {
|
||||
DiscordInteractiveHandlerContext,
|
||||
DiscordInteractiveHandlerRegistration,
|
||||
} from "./src/interactive-dispatch.js";
|
||||
export * from "./src/normalize.js";
|
||||
export * from "./src/pluralkit.js";
|
||||
export * from "./src/probe.js";
|
||||
export * from "./src/session-key-normalization.js";
|
||||
export * from "./src/status-issues.js";
|
||||
export * from "./src/targets.js";
|
||||
export * from "./src/security-audit.js";
|
||||
export { resolveDiscordRuntimeGroupPolicy } from "./src/runtime-group-policy.js";
|
||||
export {
|
||||
DISCORD_DEFAULT_INBOUND_WORKER_TIMEOUT_MS,
|
||||
|
||||
@@ -1,7 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveDiscordAccount, resolveDiscordMaxLinesPerMessage } from "./accounts.js";
|
||||
import {
|
||||
createDiscordActionGate,
|
||||
resolveDiscordAccount,
|
||||
resolveDiscordMaxLinesPerMessage,
|
||||
} from "./accounts.js";
|
||||
|
||||
describe("resolveDiscordAccount allowFrom precedence", () => {
|
||||
it("uses configured defaultAccount when accountId is omitted", () => {
|
||||
const resolved = resolveDiscordAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
discord: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: { token: "token-work", name: "Work" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.accountId).toBe("work");
|
||||
expect(resolved.name).toBe("Work");
|
||||
expect(resolved.token).toBe("token-work");
|
||||
});
|
||||
|
||||
it("prefers accounts.default.allowFrom over top-level for default account", () => {
|
||||
const resolved = resolveDiscordAccount({
|
||||
cfg: {
|
||||
@@ -57,6 +80,29 @@ describe("resolveDiscordAccount allowFrom precedence", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDiscordActionGate", () => {
|
||||
it("uses configured defaultAccount when accountId is omitted", () => {
|
||||
const gate = createDiscordActionGate({
|
||||
cfg: {
|
||||
channels: {
|
||||
discord: {
|
||||
actions: { reactions: false },
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: {
|
||||
token: "token-work",
|
||||
actions: { reactions: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(gate("reactions")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDiscordMaxLinesPerMessage", () => {
|
||||
it("falls back to merged root discord maxLinesPerMessage when runtime config omits it", () => {
|
||||
const resolved = resolveDiscordMaxLinesPerMessage({
|
||||
|
||||
@@ -45,7 +45,9 @@ export function createDiscordActionGate(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): (key: keyof DiscordActionConfig, defaultValue?: boolean) => boolean {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const accountId = normalizeAccountId(
|
||||
params.accountId ?? resolveDefaultDiscordAccountId(params.cfg),
|
||||
);
|
||||
return createAccountActionGate({
|
||||
baseActions: params.cfg.channels?.discord?.actions,
|
||||
accountActions: resolveDiscordAccountConfig(params.cfg, accountId)?.actions,
|
||||
@@ -56,7 +58,9 @@ export function resolveDiscordAccount(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedDiscordAccount {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const accountId = normalizeAccountId(
|
||||
params.accountId ?? resolveDefaultDiscordAccountId(params.cfg),
|
||||
);
|
||||
const baseEnabled = params.cfg.channels?.discord?.enabled !== false;
|
||||
const merged = mergeDiscordAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { resolveDefaultDiscordAccountId } from "../accounts.js";
|
||||
import { getPresence } from "../monitor/presence-cache.js";
|
||||
import {
|
||||
type ActionGate,
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
readStringArrayParam,
|
||||
readStringParam,
|
||||
type DiscordActionConfig,
|
||||
type OpenClawConfig,
|
||||
} from "../runtime-api.js";
|
||||
import {
|
||||
addRoleDiscord,
|
||||
@@ -92,6 +94,7 @@ export async function handleDiscordGuildAction(
|
||||
action: string,
|
||||
params: Record<string, unknown>,
|
||||
isActionEnabled: ActionGate<DiscordActionConfig>,
|
||||
cfg?: OpenClawConfig,
|
||||
): Promise<AgentToolResult<unknown>> {
|
||||
const accountId = readStringParam(params, "accountId");
|
||||
switch (action) {
|
||||
@@ -105,10 +108,13 @@ export async function handleDiscordGuildAction(
|
||||
const userId = readStringParam(params, "userId", {
|
||||
required: true,
|
||||
});
|
||||
const member = accountId
|
||||
? await discordGuildActionRuntime.fetchMemberInfoDiscord(guildId, userId, { accountId })
|
||||
const effectiveAccountId = accountId ?? (cfg ? resolveDefaultDiscordAccountId(cfg) : undefined);
|
||||
const member = effectiveAccountId
|
||||
? await discordGuildActionRuntime.fetchMemberInfoDiscord(guildId, userId, {
|
||||
accountId: effectiveAccountId,
|
||||
})
|
||||
: await discordGuildActionRuntime.fetchMemberInfoDiscord(guildId, userId);
|
||||
const presence = getPresence(accountId, userId);
|
||||
const presence = getPresence(effectiveAccountId, userId);
|
||||
const activities = presence?.activities ?? undefined;
|
||||
const status = presence?.status ?? undefined;
|
||||
return jsonResult({ ok: true, member, ...(presence ? { status, activities } : {}) });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { resolveDefaultDiscordAccountId } from "../accounts.js";
|
||||
import { createDiscordRuntimeAccountContext } from "../client.js";
|
||||
import { readDiscordComponentSpec } from "../components.js";
|
||||
import {
|
||||
@@ -112,7 +113,7 @@ export async function handleDiscordMessagingAction(
|
||||
const reactionRuntimeOptions = cfg
|
||||
? createDiscordRuntimeAccountContext({
|
||||
cfg,
|
||||
accountId: accountId ?? "default",
|
||||
accountId: accountId ?? resolveDefaultDiscordAccountId(cfg),
|
||||
})
|
||||
: accountId
|
||||
? { accountId }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { DiscordActionConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { clearPresences, setPresence } from "../monitor/presence-cache.js";
|
||||
import { discordGuildActionRuntime, handleDiscordGuildAction } from "./runtime.guild.js";
|
||||
import { handleDiscordAction } from "./runtime.js";
|
||||
import {
|
||||
@@ -88,6 +89,7 @@ const moderationEnabled = (key: keyof DiscordActionConfig) => key === "moderatio
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearPresences();
|
||||
Object.assign(
|
||||
discordMessagingActionRuntime,
|
||||
originalDiscordMessagingActionRuntime,
|
||||
@@ -131,6 +133,36 @@ describe("handleDiscordMessagingAction", () => {
|
||||
expect(reactMessageDiscord).toHaveBeenCalledWith("C1", "M1", "✅", {});
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount when cfg is provided and accountId is omitted", async () => {
|
||||
await handleDiscordMessagingAction(
|
||||
"react",
|
||||
{
|
||||
channelId: "C1",
|
||||
messageId: "M1",
|
||||
emoji: "✅",
|
||||
},
|
||||
enableAllActions,
|
||||
undefined,
|
||||
{
|
||||
channels: {
|
||||
discord: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: { token: "token-work" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
);
|
||||
|
||||
expect(reactMessageDiscord).toHaveBeenCalledWith(
|
||||
"C1",
|
||||
"M1",
|
||||
"✅",
|
||||
expect.objectContaining({ accountId: "work" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes reactions on empty emoji", async () => {
|
||||
await handleDiscordMessagingAction(
|
||||
"react",
|
||||
@@ -457,6 +489,50 @@ describe("handleDiscordMessagingAction", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleDiscordGuildAction", () => {
|
||||
it("uses configured defaultAccount for omitted memberInfo presence lookup", async () => {
|
||||
setPresence("work", "U1", {
|
||||
user: { id: "U1" },
|
||||
guild_id: "G1",
|
||||
status: "online",
|
||||
activities: [],
|
||||
client_status: {},
|
||||
} as never);
|
||||
|
||||
discordGuildActionRuntime.fetchMemberInfoDiscord = vi.fn(async () => ({ user: { id: "U1" } })) as never;
|
||||
|
||||
const result = await handleDiscordGuildAction(
|
||||
"memberInfo",
|
||||
{
|
||||
guildId: "G1",
|
||||
userId: "U1",
|
||||
},
|
||||
enableAllActions,
|
||||
{
|
||||
channels: {
|
||||
discord: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: { token: "token-work" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
);
|
||||
|
||||
expect(discordGuildActionRuntime.fetchMemberInfoDiscord).toHaveBeenCalledWith("G1", "U1", {
|
||||
accountId: "work",
|
||||
});
|
||||
expect(result.details).toEqual(
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
status: "online",
|
||||
activities: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const channelsEnabled = (key: keyof DiscordActionConfig) => key === "channels";
|
||||
const channelsDisabled = () => false;
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ export async function handleDiscordAction(
|
||||
return await handleDiscordMessagingAction(action, params, isActionEnabled, options, cfg);
|
||||
}
|
||||
if (guildActions.has(action)) {
|
||||
return await handleDiscordGuildAction(action, params, isActionEnabled);
|
||||
return await handleDiscordGuildAction(action, params, isActionEnabled, cfg);
|
||||
}
|
||||
if (moderationActions.has(action)) {
|
||||
return await handleDiscordModerationAction(action, params, isActionEnabled);
|
||||
|
||||
@@ -67,7 +67,7 @@ describe("fetchDiscord", () => {
|
||||
"/users/@me/guilds",
|
||||
"test",
|
||||
fetcher,
|
||||
{ retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0 } },
|
||||
{ retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 } },
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginRuntime } from "../../../src/plugins/runtime/types.js";
|
||||
import { createStartAccountContext } from "../../../test/helpers/plugins/start-account-context.js";
|
||||
@@ -98,6 +100,15 @@ beforeAll(async () => {
|
||||
});
|
||||
|
||||
describe("discordPlugin outbound", () => {
|
||||
it("avoids local require calls for bundled-only sibling modules", async () => {
|
||||
const source = await readFile(
|
||||
resolve(process.cwd(), "extensions/discord/src/channel.ts"),
|
||||
"utf8",
|
||||
);
|
||||
expect(source).not.toContain('require("./ui.js")');
|
||||
expect(source).not.toContain('require("./channel-actions.js")');
|
||||
});
|
||||
|
||||
it("honors per-account replyToMode overrides", () => {
|
||||
const resolveReplyToMode = discordPlugin.threading?.resolveReplyToMode;
|
||||
if (!resolveReplyToMode) {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
type ResolvedDiscordAccount,
|
||||
} from "./accounts.js";
|
||||
import { getDiscordApprovalCapability } from "./approval-native.js";
|
||||
import { discordMessageActions as discordMessageActionsImpl } from "./channel-actions.js";
|
||||
import {
|
||||
listDiscordDirectoryGroupsFromConfig,
|
||||
listDiscordDirectoryPeersFromConfig,
|
||||
@@ -43,6 +44,11 @@ import {
|
||||
resolveDiscordGroupRequireMention,
|
||||
resolveDiscordGroupToolPolicy,
|
||||
} from "./group-policy.js";
|
||||
import {
|
||||
createThreadBindingManager,
|
||||
setThreadBindingIdleTimeoutBySessionKey,
|
||||
setThreadBindingMaxAgeBySessionKey,
|
||||
} from "./monitor/thread-bindings.js";
|
||||
import {
|
||||
looksLikeDiscordTargetId,
|
||||
normalizeDiscordMessagingTarget,
|
||||
@@ -63,17 +69,17 @@ import {
|
||||
type OpenClawConfig,
|
||||
} from "./runtime-api.js";
|
||||
import { getDiscordRuntime } from "./runtime.js";
|
||||
import { collectDiscordSecurityAuditFindings } from "./security-audit.js";
|
||||
import { fetchChannelPermissionsDiscord, sendMessageDiscord, sendPollDiscord } from "./send.js";
|
||||
import { normalizeExplicitDiscordSessionKey } from "./session-key-normalization.js";
|
||||
import { discordSetupAdapter } from "./setup-core.js";
|
||||
import { createDiscordPluginBase, discordConfigAdapter } from "./shared.js";
|
||||
import { collectDiscordStatusIssues } from "./status-issues.js";
|
||||
import { parseDiscordTarget } from "./targets.js";
|
||||
import { DiscordUiContainer } from "./ui.js";
|
||||
|
||||
type DiscordSendFn = typeof sendMessageDiscord;
|
||||
type DiscordUiModule = typeof import("./ui.js");
|
||||
type DiscordCarbonModule = typeof import("@buape/carbon");
|
||||
type DiscordChannelActionsModule = typeof import("./channel-actions.js");
|
||||
type DiscordTextDisplay = InstanceType<DiscordCarbonModule["TextDisplay"]>;
|
||||
type DiscordSeparator = InstanceType<DiscordCarbonModule["Separator"]>;
|
||||
|
||||
@@ -82,9 +88,7 @@ let discordProviderRuntimePromise:
|
||||
| undefined;
|
||||
let discordProbeRuntimePromise: Promise<typeof import("./probe.runtime.js")> | undefined;
|
||||
let discordAuditModulePromise: Promise<typeof import("./audit.js")> | undefined;
|
||||
let discordUiModuleCache: DiscordUiModule | null = null;
|
||||
let discordCarbonModuleCache: DiscordCarbonModule | null = null;
|
||||
let discordChannelActionsModuleCache: DiscordChannelActionsModule | null = null;
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
@@ -108,17 +112,6 @@ function loadDiscordCarbonModule() {
|
||||
return discordCarbonModuleCache;
|
||||
}
|
||||
|
||||
function loadDiscordUiModule() {
|
||||
discordUiModuleCache ??= require("./ui.js") as DiscordUiModule;
|
||||
return discordUiModuleCache;
|
||||
}
|
||||
|
||||
function loadDiscordChannelActionsModule() {
|
||||
discordChannelActionsModuleCache ??=
|
||||
require("./channel-actions.js") as DiscordChannelActionsModule;
|
||||
return discordChannelActionsModuleCache;
|
||||
}
|
||||
|
||||
const meta = getChatChannelMeta("discord");
|
||||
const REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const;
|
||||
const DISCORD_ACCOUNT_STARTUP_STAGGER_MS = 10_000;
|
||||
@@ -152,13 +145,13 @@ const discordMessageActions = {
|
||||
ctx: Parameters<NonNullable<ChannelMessageActionAdapter["describeMessageTool"]>>[0],
|
||||
): ChannelMessageToolDiscovery | null =>
|
||||
resolveRuntimeDiscordMessageActions()?.describeMessageTool?.(ctx) ??
|
||||
loadDiscordChannelActionsModule().discordMessageActions.describeMessageTool?.(ctx) ??
|
||||
discordMessageActionsImpl.describeMessageTool?.(ctx) ??
|
||||
null,
|
||||
extractToolSend: (
|
||||
ctx: Parameters<NonNullable<ChannelMessageActionAdapter["extractToolSend"]>>[0],
|
||||
) =>
|
||||
resolveRuntimeDiscordMessageActions()?.extractToolSend?.(ctx) ??
|
||||
loadDiscordChannelActionsModule().discordMessageActions.extractToolSend?.(ctx) ??
|
||||
discordMessageActionsImpl.extractToolSend?.(ctx) ??
|
||||
null,
|
||||
handleAction: async (
|
||||
ctx: Parameters<NonNullable<ChannelMessageActionAdapter["handleAction"]>>[0],
|
||||
@@ -167,11 +160,10 @@ const discordMessageActions = {
|
||||
if (runtimeHandleAction) {
|
||||
return await runtimeHandleAction(ctx);
|
||||
}
|
||||
const { discordMessageActions } = loadDiscordChannelActionsModule();
|
||||
if (!discordMessageActions.handleAction) {
|
||||
if (!discordMessageActionsImpl.handleAction) {
|
||||
throw new Error("Discord message actions not available");
|
||||
}
|
||||
return await discordMessageActions.handleAction(ctx);
|
||||
return await discordMessageActionsImpl.handleAction(ctx);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -221,7 +213,6 @@ function buildDiscordCrossContextComponents(params: {
|
||||
accountId?: string | null;
|
||||
}) {
|
||||
const { Separator, TextDisplay } = loadDiscordCarbonModule();
|
||||
const { DiscordUiContainer } = loadDiscordUiModule();
|
||||
const trimmed = params.message.trim();
|
||||
const components: Array<DiscordTextDisplay | DiscordSeparator> = [];
|
||||
if (trimmed) {
|
||||
@@ -353,6 +344,42 @@ function resolveDiscordCommandConversation(params: {
|
||||
return conversationId ? { conversationId } : null;
|
||||
}
|
||||
|
||||
function resolveDiscordInboundConversation(params: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
conversationId?: string;
|
||||
isGroup: boolean;
|
||||
}) {
|
||||
const rawSender = params.from?.trim() || "";
|
||||
if (!params.isGroup && rawSender) {
|
||||
const senderTarget = parseDiscordTarget(rawSender, { defaultKind: "user" });
|
||||
if (senderTarget?.kind === "user") {
|
||||
return { conversationId: `user:${senderTarget.id}` };
|
||||
}
|
||||
}
|
||||
const rawTarget = params.to?.trim() || params.conversationId?.trim() || "";
|
||||
if (!rawTarget) {
|
||||
return null;
|
||||
}
|
||||
const target = parseDiscordTarget(rawTarget, { defaultKind: "channel" });
|
||||
return target ? { conversationId: `${target.kind}:${target.id}` } : null;
|
||||
}
|
||||
|
||||
function toConversationLifecycleBinding(binding: {
|
||||
boundAt: number;
|
||||
lastActivityAt?: number;
|
||||
idleTimeoutMs?: number;
|
||||
maxAgeMs?: number;
|
||||
}) {
|
||||
return {
|
||||
boundAt: binding.boundAt,
|
||||
lastActivityAt:
|
||||
typeof binding.lastActivityAt === "number" ? binding.lastActivityAt : binding.boundAt,
|
||||
idleTimeoutMs: typeof binding.idleTimeoutMs === "number" ? binding.idleTimeoutMs : undefined,
|
||||
maxAgeMs: typeof binding.maxAgeMs === "number" ? binding.maxAgeMs : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function parseDiscordExplicitTarget(raw: string) {
|
||||
try {
|
||||
const target = parseDiscordTarget(raw, { defaultKind: "channel" });
|
||||
@@ -401,6 +428,8 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeDiscordMessagingTarget,
|
||||
resolveInboundConversation: ({ from, to, conversationId, isGroup }) =>
|
||||
resolveDiscordInboundConversation({ from, to, conversationId, isGroup }),
|
||||
normalizeExplicitSessionKey: ({ sessionKey, ctx }) =>
|
||||
normalizeExplicitDiscordSessionKey(sessionKey, ctx),
|
||||
resolveSessionTarget: ({ id }) => normalizeDiscordMessagingTarget(`channel:${id}`),
|
||||
@@ -491,6 +520,29 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
|
||||
fallbackTo,
|
||||
}),
|
||||
},
|
||||
conversationBindings: {
|
||||
supportsCurrentConversationBinding: true,
|
||||
defaultTopLevelPlacement: "child",
|
||||
createManager: ({ cfg, accountId }) =>
|
||||
createThreadBindingManager({
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
}),
|
||||
setIdleTimeoutBySessionKey: ({ targetSessionKey, accountId, idleTimeoutMs }) =>
|
||||
setThreadBindingIdleTimeoutBySessionKey({
|
||||
targetSessionKey,
|
||||
accountId: accountId ?? undefined,
|
||||
idleTimeoutMs,
|
||||
}).map(toConversationLifecycleBinding),
|
||||
setMaxAgeBySessionKey: ({ targetSessionKey, accountId, maxAgeMs }) =>
|
||||
setThreadBindingMaxAgeBySessionKey({
|
||||
targetSessionKey,
|
||||
accountId: accountId ?? undefined,
|
||||
maxAgeMs,
|
||||
}).map(toConversationLifecycleBinding),
|
||||
},
|
||||
status: createComputedAccountStatusAdapter<ResolvedDiscordAccount, DiscordProbe, unknown>({
|
||||
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, {
|
||||
connected: false,
|
||||
@@ -718,6 +770,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
|
||||
security: {
|
||||
resolveDmPolicy: resolveDiscordDmPolicy,
|
||||
collectWarnings: collectDiscordSecurityWarnings,
|
||||
collectAuditFindings: collectDiscordSecurityAuditFindings,
|
||||
},
|
||||
threading: {
|
||||
scopedAccountReplyToMode: {
|
||||
|
||||
@@ -4,8 +4,10 @@ import { createDiscordRestClient } from "./client.js";
|
||||
|
||||
const makeProxyFetchMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
|
||||
vi.mock("openclaw/plugin-sdk/infra-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/infra-runtime")>(
|
||||
"openclaw/plugin-sdk/infra-runtime",
|
||||
);
|
||||
makeProxyFetchMock.mockImplementation((proxyUrl: string) => {
|
||||
if (proxyUrl === "bad-proxy") {
|
||||
throw new Error("bad proxy");
|
||||
|
||||
@@ -12,7 +12,7 @@ type DiscordGuild = { id: string; name: string };
|
||||
type DiscordUser = { id: string; username: string; global_name?: string; bot?: boolean };
|
||||
type DiscordMember = { user: DiscordUser; nick?: string | null };
|
||||
type DiscordChannel = { id: string; name?: string | null };
|
||||
type DiscordDirectoryAccess = { token: string; query: string };
|
||||
type DiscordDirectoryAccess = { token: string; query: string; accountId: string };
|
||||
|
||||
function normalizeQuery(value?: string | null): string {
|
||||
return value?.trim().toLowerCase() ?? "";
|
||||
@@ -30,7 +30,7 @@ function resolveDiscordDirectoryAccess(
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
return { token, query: normalizeQuery(params.query) };
|
||||
return { token, query: normalizeQuery(params.query), accountId: account.accountId };
|
||||
}
|
||||
|
||||
async function listDiscordGuilds(token: string): Promise<DiscordGuild[]> {
|
||||
@@ -45,7 +45,7 @@ export async function listDiscordDirectoryGroupsLive(
|
||||
if (!access) {
|
||||
return [];
|
||||
}
|
||||
const { token, query } = access;
|
||||
const { token, query, accountId } = access;
|
||||
const guilds = await listDiscordGuilds(token);
|
||||
const rows: ChannelDirectoryEntry[] = [];
|
||||
|
||||
@@ -82,7 +82,7 @@ export async function listDiscordDirectoryPeersLive(
|
||||
if (!access) {
|
||||
return [];
|
||||
}
|
||||
const { token, query } = access;
|
||||
const { token, query, accountId } = access;
|
||||
if (!query) {
|
||||
return [];
|
||||
}
|
||||
@@ -106,7 +106,7 @@ export async function listDiscordDirectoryPeersLive(
|
||||
continue;
|
||||
}
|
||||
rememberDiscordDirectoryUser({
|
||||
accountId: params.accountId,
|
||||
accountId,
|
||||
userId: user.id,
|
||||
handles: [
|
||||
user.username,
|
||||
|
||||
69
extensions/discord/src/doctor.test.ts
Normal file
69
extensions/discord/src/doctor.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
collectDiscordNumericIdWarnings,
|
||||
maybeRepairDiscordNumericIds,
|
||||
scanDiscordNumericIdEntries,
|
||||
} from "./doctor.js";
|
||||
|
||||
describe("discord doctor", () => {
|
||||
it("finds numeric id entries across discord scopes", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
discord: {
|
||||
allowFrom: [123],
|
||||
dm: { allowFrom: ["ok"], groupChannels: [456] },
|
||||
execApprovals: { approvers: [789] },
|
||||
guilds: {
|
||||
main: {
|
||||
users: [111],
|
||||
roles: [222],
|
||||
channels: { general: { users: [333], roles: [444] } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const hits = scanDiscordNumericIdEntries(cfg);
|
||||
expect(hits.map((hit) => hit.path)).toEqual([
|
||||
"channels.discord.allowFrom[0]",
|
||||
"channels.discord.dm.groupChannels[0]",
|
||||
"channels.discord.execApprovals.approvers[0]",
|
||||
"channels.discord.guilds.main.users[0]",
|
||||
"channels.discord.guilds.main.roles[0]",
|
||||
"channels.discord.guilds.main.channels.general.users[0]",
|
||||
"channels.discord.guilds.main.channels.general.roles[0]",
|
||||
]);
|
||||
});
|
||||
|
||||
it("repairs safe numeric ids into strings and warns for unsafe lists", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
discord: {
|
||||
allowFrom: [123],
|
||||
dm: { allowFrom: [99] },
|
||||
guilds: { main: { users: [111], roles: [222] } },
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const result = maybeRepairDiscordNumericIds(cfg, "openclaw doctor --fix");
|
||||
expect(result.config.channels?.discord?.allowFrom).toEqual(["123"]);
|
||||
expect(result.config.channels?.discord?.dm?.allowFrom).toEqual(["99"]);
|
||||
expect(result.config.channels?.discord?.guilds?.main?.users).toEqual(["111"]);
|
||||
expect(result.config.channels?.discord?.guilds?.main?.roles).toEqual(["222"]);
|
||||
expect(result.changes).not.toHaveLength(0);
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it("formats repair guidance for unsafe numeric ids", () => {
|
||||
const warnings = collectDiscordNumericIdWarnings({
|
||||
hits: [{ path: "channels.discord.allowFrom[0]", entry: 106232522769186816, safe: false }],
|
||||
doctorFixCommand: "openclaw doctor --fix",
|
||||
});
|
||||
|
||||
expect(warnings[0]).toContain("cannot be auto-repaired");
|
||||
expect(warnings[1]).toContain("openclaw doctor --fix");
|
||||
});
|
||||
});
|
||||
533
extensions/discord/src/doctor.ts
Normal file
533
extensions/discord/src/doctor.ts
Normal file
@@ -0,0 +1,533 @@
|
||||
import {
|
||||
type ChannelDoctorAdapter,
|
||||
type ChannelDoctorConfigMutation,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import {
|
||||
resolveDiscordPreviewStreamMode,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
collectProviderDangerousNameMatchingScopes,
|
||||
isDiscordMutableAllowEntry,
|
||||
} from "openclaw/plugin-sdk/runtime";
|
||||
|
||||
type DiscordNumericIdHit = { path: string; entry: number; safe: boolean };
|
||||
|
||||
type DiscordIdListRef = {
|
||||
pathLabel: string;
|
||||
holder: Record<string, unknown>;
|
||||
key: string;
|
||||
};
|
||||
|
||||
function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function sanitizeForLog(value: string): string {
|
||||
return value.replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
|
||||
}
|
||||
|
||||
function normalizeDiscordDmAliases(params: {
|
||||
entry: Record<string, unknown>;
|
||||
pathPrefix: string;
|
||||
changes: string[];
|
||||
}): { entry: Record<string, unknown>; changed: boolean } {
|
||||
let changed = false;
|
||||
let updated: Record<string, unknown> = params.entry;
|
||||
const rawDm = updated.dm;
|
||||
const dm = asObjectRecord(rawDm) ? (structuredClone(rawDm) as Record<string, unknown>) : null;
|
||||
let dmChanged = false;
|
||||
|
||||
const allowFromEqual = (a: unknown, b: unknown): boolean => {
|
||||
if (!Array.isArray(a) || !Array.isArray(b)) {
|
||||
return false;
|
||||
}
|
||||
const na = a.map((v) => String(v).trim()).filter(Boolean);
|
||||
const nb = b.map((v) => String(v).trim()).filter(Boolean);
|
||||
if (na.length !== nb.length) {
|
||||
return false;
|
||||
}
|
||||
return na.every((v, i) => v === nb[i]);
|
||||
};
|
||||
|
||||
const topDmPolicy = updated.dmPolicy;
|
||||
const legacyDmPolicy = dm?.policy;
|
||||
if (topDmPolicy === undefined && legacyDmPolicy !== undefined) {
|
||||
updated = { ...updated, dmPolicy: legacyDmPolicy };
|
||||
changed = true;
|
||||
if (dm) {
|
||||
delete dm.policy;
|
||||
dmChanged = true;
|
||||
}
|
||||
params.changes.push(`Moved ${params.pathPrefix}.dm.policy → ${params.pathPrefix}.dmPolicy.`);
|
||||
} else if (
|
||||
topDmPolicy !== undefined &&
|
||||
legacyDmPolicy !== undefined &&
|
||||
topDmPolicy === legacyDmPolicy
|
||||
) {
|
||||
if (dm) {
|
||||
delete dm.policy;
|
||||
dmChanged = true;
|
||||
params.changes.push(`Removed ${params.pathPrefix}.dm.policy (dmPolicy already set).`);
|
||||
}
|
||||
}
|
||||
|
||||
const topAllowFrom = updated.allowFrom;
|
||||
const legacyAllowFrom = dm?.allowFrom;
|
||||
if (topAllowFrom === undefined && legacyAllowFrom !== undefined) {
|
||||
updated = { ...updated, allowFrom: legacyAllowFrom };
|
||||
changed = true;
|
||||
if (dm) {
|
||||
delete dm.allowFrom;
|
||||
dmChanged = true;
|
||||
}
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.dm.allowFrom → ${params.pathPrefix}.allowFrom.`,
|
||||
);
|
||||
} else if (
|
||||
topAllowFrom !== undefined &&
|
||||
legacyAllowFrom !== undefined &&
|
||||
allowFromEqual(topAllowFrom, legacyAllowFrom)
|
||||
) {
|
||||
if (dm) {
|
||||
delete dm.allowFrom;
|
||||
dmChanged = true;
|
||||
params.changes.push(`Removed ${params.pathPrefix}.dm.allowFrom (allowFrom already set).`);
|
||||
}
|
||||
}
|
||||
|
||||
if (dm && asObjectRecord(rawDm) && dmChanged) {
|
||||
const keys = Object.keys(dm);
|
||||
if (keys.length === 0) {
|
||||
if (updated.dm !== undefined) {
|
||||
const { dm: _ignored, ...rest } = updated;
|
||||
updated = rest;
|
||||
changed = true;
|
||||
params.changes.push(`Removed empty ${params.pathPrefix}.dm after migration.`);
|
||||
}
|
||||
} else {
|
||||
updated = { ...updated, dm };
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { entry: updated, changed };
|
||||
}
|
||||
|
||||
function normalizeDiscordStreamingAliases(params: {
|
||||
entry: Record<string, unknown>;
|
||||
pathPrefix: string;
|
||||
changes: string[];
|
||||
}): { entry: Record<string, unknown>; changed: boolean } {
|
||||
let updated = params.entry;
|
||||
const hadLegacyStreamMode = updated.streamMode !== undefined;
|
||||
const beforeStreaming = updated.streaming;
|
||||
const resolved = resolveDiscordPreviewStreamMode(updated);
|
||||
const shouldNormalize =
|
||||
hadLegacyStreamMode ||
|
||||
typeof beforeStreaming === "boolean" ||
|
||||
(typeof beforeStreaming === "string" && beforeStreaming !== resolved);
|
||||
if (!shouldNormalize) {
|
||||
return { entry: updated, changed: false };
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
if (beforeStreaming !== resolved) {
|
||||
updated = { ...updated, streaming: resolved };
|
||||
changed = true;
|
||||
}
|
||||
if (hadLegacyStreamMode) {
|
||||
const { streamMode: _ignored, ...rest } = updated;
|
||||
updated = rest;
|
||||
changed = true;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.streamMode → ${params.pathPrefix}.streaming (${resolved}).`,
|
||||
);
|
||||
}
|
||||
if (typeof beforeStreaming === "boolean") {
|
||||
params.changes.push(`Normalized ${params.pathPrefix}.streaming boolean → enum (${resolved}).`);
|
||||
} else if (typeof beforeStreaming === "string" && beforeStreaming !== resolved) {
|
||||
params.changes.push(
|
||||
`Normalized ${params.pathPrefix}.streaming (${beforeStreaming}) → (${resolved}).`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
params.pathPrefix.startsWith("channels.discord") &&
|
||||
resolved === "off" &&
|
||||
hadLegacyStreamMode
|
||||
) {
|
||||
params.changes.push(
|
||||
`${params.pathPrefix}.streaming remains off by default to avoid Discord preview-edit rate limits; set ${params.pathPrefix}.streaming="partial" to opt in explicitly.`,
|
||||
);
|
||||
}
|
||||
return { entry: updated, changed };
|
||||
}
|
||||
|
||||
function normalizeDiscordCompatibilityConfig(cfg: OpenClawConfig): ChannelDoctorConfigMutation {
|
||||
const rawEntry = asObjectRecord((cfg.channels as Record<string, unknown> | undefined)?.discord);
|
||||
if (!rawEntry) {
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
|
||||
const changes: string[] = [];
|
||||
let updated = rawEntry;
|
||||
let changed = false;
|
||||
|
||||
const base = normalizeDiscordDmAliases({
|
||||
entry: rawEntry,
|
||||
pathPrefix: "channels.discord",
|
||||
changes,
|
||||
});
|
||||
updated = base.entry;
|
||||
changed = base.changed;
|
||||
|
||||
const streaming = normalizeDiscordStreamingAliases({
|
||||
entry: updated,
|
||||
pathPrefix: "channels.discord",
|
||||
changes,
|
||||
});
|
||||
updated = streaming.entry;
|
||||
changed = changed || streaming.changed;
|
||||
|
||||
const rawAccounts = asObjectRecord(updated.accounts);
|
||||
if (rawAccounts) {
|
||||
let accountsChanged = false;
|
||||
const accounts = { ...rawAccounts };
|
||||
for (const [accountId, rawAccount] of Object.entries(rawAccounts)) {
|
||||
const account = asObjectRecord(rawAccount);
|
||||
if (!account) {
|
||||
continue;
|
||||
}
|
||||
let accountEntry = account;
|
||||
let accountChanged = false;
|
||||
const dm = normalizeDiscordDmAliases({
|
||||
entry: account,
|
||||
pathPrefix: `channels.discord.accounts.${accountId}`,
|
||||
changes,
|
||||
});
|
||||
accountEntry = dm.entry;
|
||||
accountChanged = dm.changed;
|
||||
const accountStreaming = normalizeDiscordStreamingAliases({
|
||||
entry: accountEntry,
|
||||
pathPrefix: `channels.discord.accounts.${accountId}`,
|
||||
changes,
|
||||
});
|
||||
accountEntry = accountStreaming.entry;
|
||||
accountChanged = accountChanged || accountStreaming.changed;
|
||||
if (accountChanged) {
|
||||
accounts[accountId] = accountEntry;
|
||||
accountsChanged = true;
|
||||
}
|
||||
}
|
||||
if (accountsChanged) {
|
||||
updated = { ...updated, accounts };
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
return {
|
||||
config: {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
discord: updated,
|
||||
} as OpenClawConfig["channels"],
|
||||
},
|
||||
changes,
|
||||
};
|
||||
}
|
||||
|
||||
function collectDiscordAccountScopes(
|
||||
cfg: OpenClawConfig,
|
||||
): Array<{ prefix: string; account: Record<string, unknown> }> {
|
||||
const scopes: Array<{ prefix: string; account: Record<string, unknown> }> = [];
|
||||
const discord = asObjectRecord(cfg.channels?.discord);
|
||||
if (!discord) {
|
||||
return scopes;
|
||||
}
|
||||
|
||||
scopes.push({ prefix: "channels.discord", account: discord });
|
||||
const accounts = asObjectRecord(discord.accounts);
|
||||
if (!accounts) {
|
||||
return scopes;
|
||||
}
|
||||
for (const key of Object.keys(accounts)) {
|
||||
const account = asObjectRecord(accounts[key]);
|
||||
if (account) {
|
||||
scopes.push({ prefix: `channels.discord.accounts.${key}`, account });
|
||||
}
|
||||
}
|
||||
return scopes;
|
||||
}
|
||||
|
||||
function collectDiscordIdLists(
|
||||
prefix: string,
|
||||
account: Record<string, unknown>,
|
||||
): DiscordIdListRef[] {
|
||||
const refs: DiscordIdListRef[] = [
|
||||
{ pathLabel: `${prefix}.allowFrom`, holder: account, key: "allowFrom" },
|
||||
];
|
||||
const dm = asObjectRecord(account.dm);
|
||||
if (dm) {
|
||||
refs.push({ pathLabel: `${prefix}.dm.allowFrom`, holder: dm, key: "allowFrom" });
|
||||
refs.push({ pathLabel: `${prefix}.dm.groupChannels`, holder: dm, key: "groupChannels" });
|
||||
}
|
||||
const execApprovals = asObjectRecord(account.execApprovals);
|
||||
if (execApprovals) {
|
||||
refs.push({
|
||||
pathLabel: `${prefix}.execApprovals.approvers`,
|
||||
holder: execApprovals,
|
||||
key: "approvers",
|
||||
});
|
||||
}
|
||||
const guilds = asObjectRecord(account.guilds);
|
||||
if (!guilds) {
|
||||
return refs;
|
||||
}
|
||||
for (const guildId of Object.keys(guilds)) {
|
||||
const guild = asObjectRecord(guilds[guildId]);
|
||||
if (!guild) {
|
||||
continue;
|
||||
}
|
||||
refs.push({ pathLabel: `${prefix}.guilds.${guildId}.users`, holder: guild, key: "users" });
|
||||
refs.push({ pathLabel: `${prefix}.guilds.${guildId}.roles`, holder: guild, key: "roles" });
|
||||
const channels = asObjectRecord(guild.channels);
|
||||
if (!channels) {
|
||||
continue;
|
||||
}
|
||||
for (const channelId of Object.keys(channels)) {
|
||||
const channel = asObjectRecord(channels[channelId]);
|
||||
if (!channel) {
|
||||
continue;
|
||||
}
|
||||
refs.push({
|
||||
pathLabel: `${prefix}.guilds.${guildId}.channels.${channelId}.users`,
|
||||
holder: channel,
|
||||
key: "users",
|
||||
});
|
||||
refs.push({
|
||||
pathLabel: `${prefix}.guilds.${guildId}.channels.${channelId}.roles`,
|
||||
holder: channel,
|
||||
key: "roles",
|
||||
});
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
export function scanDiscordNumericIdEntries(cfg: OpenClawConfig): DiscordNumericIdHit[] {
|
||||
const hits: DiscordNumericIdHit[] = [];
|
||||
const scanList = (pathLabel: string, list: unknown) => {
|
||||
if (!Array.isArray(list)) {
|
||||
return;
|
||||
}
|
||||
for (const [index, entry] of list.entries()) {
|
||||
if (typeof entry !== "number") {
|
||||
continue;
|
||||
}
|
||||
hits.push({
|
||||
path: `${pathLabel}[${index}]`,
|
||||
entry,
|
||||
safe: Number.isSafeInteger(entry) && entry >= 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
for (const scope of collectDiscordAccountScopes(cfg)) {
|
||||
for (const ref of collectDiscordIdLists(scope.prefix, scope.account)) {
|
||||
scanList(ref.pathLabel, ref.holder[ref.key]);
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
export function collectDiscordNumericIdWarnings(params: {
|
||||
hits: DiscordNumericIdHit[];
|
||||
doctorFixCommand: string;
|
||||
}): string[] {
|
||||
if (params.hits.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const hitsByListPath = new Map<string, DiscordNumericIdHit[]>();
|
||||
for (const hit of params.hits) {
|
||||
const listPath = hit.path.replace(/\[\d+\]$/, "");
|
||||
const existing = hitsByListPath.get(listPath);
|
||||
if (existing) {
|
||||
existing.push(hit);
|
||||
} else {
|
||||
hitsByListPath.set(listPath, [hit]);
|
||||
}
|
||||
}
|
||||
|
||||
const repairableHits: DiscordNumericIdHit[] = [];
|
||||
const blockedHits: DiscordNumericIdHit[] = [];
|
||||
for (const hits of hitsByListPath.values()) {
|
||||
if (hits.some((hit) => !hit.safe)) {
|
||||
blockedHits.push(...hits);
|
||||
} else {
|
||||
repairableHits.push(...hits);
|
||||
}
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
if (repairableHits.length > 0) {
|
||||
const sample = repairableHits[0]!;
|
||||
lines.push(
|
||||
`- Discord allowlists contain ${repairableHits.length} numeric ${repairableHits.length === 1 ? "entry" : "entries"} (e.g. ${sanitizeForLog(sample.path)}=${sanitizeForLog(String(sample.entry))}).`,
|
||||
`- Discord IDs must be strings; run "${params.doctorFixCommand}" to convert numeric IDs to quoted strings.`,
|
||||
);
|
||||
}
|
||||
if (blockedHits.length > 0) {
|
||||
const sample = blockedHits[0]!;
|
||||
lines.push(
|
||||
`- Discord allowlists contain ${blockedHits.length} numeric ${blockedHits.length === 1 ? "entry" : "entries"} in lists that cannot be auto-repaired (e.g. ${sanitizeForLog(sample.path)}).`,
|
||||
`- These lists include invalid or precision-losing numeric IDs; manually quote the original values in your config file, then rerun "${params.doctorFixCommand}".`,
|
||||
);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function maybeRepairDiscordNumericIds(
|
||||
cfg: OpenClawConfig,
|
||||
doctorFixCommand: string,
|
||||
): { config: OpenClawConfig; changes: string[]; warnings?: string[] } {
|
||||
const hits = scanDiscordNumericIdEntries(cfg);
|
||||
if (hits.length === 0) {
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
|
||||
const next = structuredClone(cfg);
|
||||
const changes: string[] = [];
|
||||
|
||||
const repairList = (pathLabel: string, holder: Record<string, unknown>, key: string) => {
|
||||
const raw = holder[key];
|
||||
if (!Array.isArray(raw)) {
|
||||
return;
|
||||
}
|
||||
const hasUnsafe = raw.some(
|
||||
(entry) => typeof entry === "number" && (!Number.isSafeInteger(entry) || entry < 0),
|
||||
);
|
||||
if (hasUnsafe) {
|
||||
return;
|
||||
}
|
||||
let converted = 0;
|
||||
holder[key] = raw.map((entry) => {
|
||||
if (typeof entry === "number") {
|
||||
converted += 1;
|
||||
return String(entry);
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
if (converted > 0) {
|
||||
changes.push(
|
||||
`- ${sanitizeForLog(pathLabel)}: converted ${converted} numeric ${converted === 1 ? "ID" : "IDs"} to strings`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
for (const scope of collectDiscordAccountScopes(next)) {
|
||||
for (const ref of collectDiscordIdLists(scope.prefix, scope.account)) {
|
||||
repairList(ref.pathLabel, ref.holder, ref.key);
|
||||
}
|
||||
}
|
||||
|
||||
if (changes.length === 0) {
|
||||
return {
|
||||
config: cfg,
|
||||
changes: [],
|
||||
warnings: collectDiscordNumericIdWarnings({ hits, doctorFixCommand }),
|
||||
};
|
||||
}
|
||||
return {
|
||||
config: next,
|
||||
changes,
|
||||
warnings: collectDiscordNumericIdWarnings({
|
||||
hits: hits.filter((hit) => !hit.safe),
|
||||
doctorFixCommand,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function collectDiscordMutableAllowlistWarnings(cfg: OpenClawConfig): string[] {
|
||||
const hits: Array<{ path: string; entry: string }> = [];
|
||||
const addHits = (pathLabel: string, list: unknown) => {
|
||||
if (!Array.isArray(list)) {
|
||||
return;
|
||||
}
|
||||
for (const entry of list) {
|
||||
const text = String(entry).trim();
|
||||
if (!text || text === "*" || !isDiscordMutableAllowEntry(text)) {
|
||||
continue;
|
||||
}
|
||||
hits.push({ path: pathLabel, entry: text });
|
||||
}
|
||||
};
|
||||
|
||||
for (const scope of collectProviderDangerousNameMatchingScopes(cfg, "discord")) {
|
||||
if (scope.dangerousNameMatchingEnabled) {
|
||||
continue;
|
||||
}
|
||||
addHits(`${scope.prefix}.allowFrom`, scope.account.allowFrom);
|
||||
const dm = asObjectRecord(scope.account.dm);
|
||||
if (dm) {
|
||||
addHits(`${scope.prefix}.dm.allowFrom`, dm.allowFrom);
|
||||
}
|
||||
const guilds = asObjectRecord(scope.account.guilds);
|
||||
if (!guilds) {
|
||||
continue;
|
||||
}
|
||||
for (const [guildId, guildRaw] of Object.entries(guilds)) {
|
||||
const guild = asObjectRecord(guildRaw);
|
||||
if (!guild) {
|
||||
continue;
|
||||
}
|
||||
addHits(`${scope.prefix}.guilds.${guildId}.users`, guild.users);
|
||||
const channels = asObjectRecord(guild.channels);
|
||||
if (!channels) {
|
||||
continue;
|
||||
}
|
||||
for (const [channelId, channelRaw] of Object.entries(channels)) {
|
||||
const channel = asObjectRecord(channelRaw);
|
||||
if (channel) {
|
||||
addHits(`${scope.prefix}.guilds.${guildId}.channels.${channelId}.users`, channel.users);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hits.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const exampleLines = hits
|
||||
.slice(0, 8)
|
||||
.map((hit) => `- ${sanitizeForLog(hit.path)}: ${sanitizeForLog(hit.entry)}`);
|
||||
const remaining =
|
||||
hits.length > 8 ? `- +${hits.length - 8} more mutable allowlist entries.` : null;
|
||||
return [
|
||||
`- Found ${hits.length} mutable allowlist ${hits.length === 1 ? "entry" : "entries"} across discord while name matching is disabled by default.`,
|
||||
...exampleLines,
|
||||
...(remaining ? [remaining] : []),
|
||||
`- Option A (break-glass): enable channels.discord.dangerousNameMatching=true for the affected scope.`,
|
||||
`- Option B (recommended): resolve names to stable Discord IDs and rewrite the allowlist entries.`,
|
||||
];
|
||||
}
|
||||
|
||||
export const discordDoctor: ChannelDoctorAdapter = {
|
||||
dmAllowFromMode: "topOrNested",
|
||||
groupModel: "route",
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
warnOnEmptyGroupSenderAllowlist: false,
|
||||
normalizeCompatibilityConfig: ({ cfg }) => normalizeDiscordCompatibilityConfig(cfg),
|
||||
collectPreviewWarnings: ({ cfg, doctorFixCommand }) =>
|
||||
collectDiscordNumericIdWarnings({
|
||||
hits: scanDiscordNumericIdEntries(cfg),
|
||||
doctorFixCommand,
|
||||
}),
|
||||
collectMutableAllowlistWarnings: ({ cfg }) => collectDiscordMutableAllowlistWarnings(cfg),
|
||||
repairConfig: ({ cfg, doctorFixCommand }) => maybeRepairDiscordNumericIds(cfg, doctorFixCommand),
|
||||
};
|
||||
104
extensions/discord/src/interactive-dispatch.ts
Normal file
104
extensions/discord/src/interactive-dispatch.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type { ChannelStructuredComponents } from "openclaw/plugin-sdk/channel-contract";
|
||||
import {
|
||||
createInteractiveConversationBindingHelpers,
|
||||
dispatchPluginInteractiveHandler,
|
||||
type PluginConversationBinding,
|
||||
type PluginConversationBindingRequestParams,
|
||||
type PluginConversationBindingRequestResult,
|
||||
type PluginInteractiveRegistration,
|
||||
} from "openclaw/plugin-sdk/plugin-runtime";
|
||||
|
||||
export type DiscordInteractiveHandlerContext = {
|
||||
channel: "discord";
|
||||
accountId: string;
|
||||
interactionId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
guildId?: string;
|
||||
senderId?: string;
|
||||
senderUsername?: string;
|
||||
auth: {
|
||||
isAuthorizedSender: boolean;
|
||||
};
|
||||
interaction: {
|
||||
kind: "button" | "select" | "modal";
|
||||
data: string;
|
||||
namespace: string;
|
||||
payload: string;
|
||||
messageId?: string;
|
||||
values?: string[];
|
||||
fields?: Array<{ id: string; name: string; values: string[] }>;
|
||||
};
|
||||
respond: {
|
||||
acknowledge: () => Promise<void>;
|
||||
reply: (params: { text: string; ephemeral?: boolean }) => Promise<void>;
|
||||
followUp: (params: { text: string; ephemeral?: boolean }) => Promise<void>;
|
||||
editMessage: (params: {
|
||||
text?: string;
|
||||
components?: ChannelStructuredComponents;
|
||||
}) => Promise<void>;
|
||||
clearComponents: (params?: { text?: string }) => Promise<void>;
|
||||
};
|
||||
requestConversationBinding: (
|
||||
params?: PluginConversationBindingRequestParams,
|
||||
) => Promise<PluginConversationBindingRequestResult>;
|
||||
detachConversationBinding: () => Promise<{ removed: boolean }>;
|
||||
getCurrentConversationBinding: () => Promise<PluginConversationBinding | null>;
|
||||
};
|
||||
|
||||
export type DiscordInteractiveHandlerRegistration = PluginInteractiveRegistration<
|
||||
DiscordInteractiveHandlerContext,
|
||||
"discord"
|
||||
>;
|
||||
|
||||
export type DiscordInteractiveDispatchContext = Omit<
|
||||
DiscordInteractiveHandlerContext,
|
||||
| "interaction"
|
||||
| "respond"
|
||||
| "channel"
|
||||
| "requestConversationBinding"
|
||||
| "detachConversationBinding"
|
||||
| "getCurrentConversationBinding"
|
||||
> & {
|
||||
interaction: Omit<
|
||||
DiscordInteractiveHandlerContext["interaction"],
|
||||
"data" | "namespace" | "payload"
|
||||
>;
|
||||
};
|
||||
|
||||
export async function dispatchDiscordPluginInteractiveHandler(params: {
|
||||
data: string;
|
||||
interactionId: string;
|
||||
ctx: DiscordInteractiveDispatchContext;
|
||||
respond: DiscordInteractiveHandlerContext["respond"];
|
||||
onMatched?: () => Promise<void> | void;
|
||||
}) {
|
||||
return await dispatchPluginInteractiveHandler<DiscordInteractiveHandlerRegistration>({
|
||||
channel: "discord",
|
||||
data: params.data,
|
||||
dedupeId: params.interactionId,
|
||||
onMatched: params.onMatched,
|
||||
invoke: ({ registration, namespace, payload }) =>
|
||||
registration.handler({
|
||||
...params.ctx,
|
||||
channel: "discord",
|
||||
interaction: {
|
||||
...params.ctx.interaction,
|
||||
data: params.data,
|
||||
namespace,
|
||||
payload,
|
||||
},
|
||||
respond: params.respond,
|
||||
...createInteractiveConversationBindingHelpers({
|
||||
registration,
|
||||
senderId: params.ctx.senderId,
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: params.ctx.accountId,
|
||||
conversationId: params.ctx.conversationId,
|
||||
parentConversationId: params.ctx.parentConversationId,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -3,8 +3,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const loadConfigMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
|
||||
"openclaw/plugin-sdk/config-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => loadConfigMock(),
|
||||
|
||||
@@ -28,7 +28,6 @@ import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
|
||||
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
|
||||
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
|
||||
import type { PluginInteractiveDiscordHandlerContext } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { createNonExitingRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy";
|
||||
@@ -40,6 +39,8 @@ import {
|
||||
} from "../component-custom-id.js";
|
||||
import { resolveDiscordComponentEntry, resolveDiscordModalEntry } from "../components-registry.js";
|
||||
import type { DiscordComponentEntry, DiscordModalEntry } from "../components.js";
|
||||
import { type DiscordInteractiveHandlerContext } from "../interactive-dispatch.js";
|
||||
import { dispatchDiscordPluginInteractiveHandler } from "../interactive-dispatch.js";
|
||||
import { editDiscordComponentMessage } from "../send.components.js";
|
||||
import {
|
||||
AGENT_BUTTON_KEY,
|
||||
@@ -91,6 +92,7 @@ import { deliverDiscordReply } from "./reply-delivery.js";
|
||||
|
||||
let conversationRuntimePromise: Promise<typeof import("./agent-components.runtime.js")> | undefined;
|
||||
let componentsRuntimePromise: Promise<typeof import("../components.js")> | undefined;
|
||||
let replyRuntimePromise: Promise<typeof import("openclaw/plugin-sdk/reply-runtime")> | undefined;
|
||||
let replyPipelineRuntimePromise:
|
||||
| Promise<typeof import("openclaw/plugin-sdk/channel-reply-pipeline")>
|
||||
| undefined;
|
||||
@@ -106,6 +108,10 @@ async function loadComponentsRuntime() {
|
||||
return await componentsRuntimePromise;
|
||||
}
|
||||
|
||||
async function loadReplyRuntime() {
|
||||
replyRuntimePromise ??= import("openclaw/plugin-sdk/reply-runtime");
|
||||
return await replyRuntimePromise;
|
||||
}
|
||||
async function loadReplyPipelineRuntime() {
|
||||
replyPipelineRuntimePromise ??= import("openclaw/plugin-sdk/channel-reply-pipeline");
|
||||
return await replyPipelineRuntimePromise;
|
||||
@@ -191,7 +197,7 @@ async function dispatchPluginDiscordInteractiveEvent(params: {
|
||||
}
|
||||
await params.interaction.update(payload);
|
||||
};
|
||||
const respond: PluginInteractiveDiscordHandlerContext["respond"] = {
|
||||
const respond: DiscordInteractiveHandlerContext["respond"] = {
|
||||
acknowledge: async () => {
|
||||
if (responded) {
|
||||
return;
|
||||
@@ -215,7 +221,7 @@ async function dispatchPluginDiscordInteractiveEvent(params: {
|
||||
});
|
||||
},
|
||||
editMessage: async (
|
||||
input: Parameters<PluginInteractiveDiscordHandlerContext["respond"]["editMessage"]>[0],
|
||||
input: Parameters<DiscordInteractiveHandlerContext["respond"]["editMessage"]>[0],
|
||||
) => {
|
||||
const { text, components } = input;
|
||||
responded = true;
|
||||
@@ -279,9 +285,7 @@ async function dispatchPluginDiscordInteractiveEvent(params: {
|
||||
}
|
||||
return "handled";
|
||||
}
|
||||
const { dispatchPluginInteractiveHandler } = await loadConversationRuntime();
|
||||
const dispatched = await dispatchPluginInteractiveHandler({
|
||||
channel: "discord",
|
||||
const dispatched = await dispatchDiscordPluginInteractiveHandler({
|
||||
data: params.data,
|
||||
interactionId: resolveDiscordInteractionId(params.interaction),
|
||||
ctx: {
|
||||
|
||||
@@ -57,8 +57,8 @@ const mockGatewayClientCtor = vi.hoisted(() => vi.fn());
|
||||
const mockResolveGatewayConnectionAuth = vi.hoisted(() => vi.fn());
|
||||
const mockCreateOperatorApprovalsGatewayClient = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../send.shared.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../send.shared.js")>();
|
||||
vi.mock("../send.shared.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../send.shared.js")>("../send.shared.js");
|
||||
return {
|
||||
...actual,
|
||||
createDiscordClient: () => ({
|
||||
@@ -72,8 +72,10 @@ vi.mock("../send.shared.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
|
||||
"openclaw/plugin-sdk/config-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
loadSessionStore: () => mockSessionStoreEntries.value,
|
||||
@@ -148,8 +150,10 @@ vi.mock("../../../../src/gateway/client.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/text-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/text-runtime")>();
|
||||
vi.mock("openclaw/plugin-sdk/text-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/text-runtime")>(
|
||||
"openclaw/plugin-sdk/text-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
logDebug: vi.fn(),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() => vi.fn());
|
||||
const resolveConfiguredBindingRouteMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../../../src/channels/plugins/binding-routing.js", async (importOriginal) => {
|
||||
vi.mock("../../../../src/channels/plugins/binding-routing.js", async () => {
|
||||
const { createConfiguredBindingConversationRuntimeModuleMock } =
|
||||
await import("../test-support/configured-binding-runtime.js");
|
||||
return await createConfiguredBindingConversationRuntimeModuleMock(
|
||||
@@ -12,7 +12,10 @@ vi.mock("../../../../src/channels/plugins/binding-routing.js", async (importOrig
|
||||
ensureConfiguredBindingRouteReadyMock,
|
||||
resolveConfiguredBindingRouteMock,
|
||||
},
|
||||
importOriginal,
|
||||
() =>
|
||||
vi.importActual<typeof import("../../../../src/channels/plugins/binding-routing.js")>(
|
||||
"../../../../src/channels/plugins/binding-routing.js",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,9 +2,17 @@ import { ChannelType } from "@buape/carbon";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const transcribeFirstAudioMock = vi.hoisted(() => vi.fn());
|
||||
const resolveDiscordDmCommandAccessMock = vi.hoisted(() => vi.fn());
|
||||
const handleDiscordDmCommandDecisionMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
|
||||
vi.mock("./preflight-audio.runtime.js", () => ({
|
||||
transcribeFirstAudio: (...args: unknown[]) => transcribeFirstAudioMock(...args),
|
||||
transcribeFirstAudio: transcribeFirstAudioMock,
|
||||
}));
|
||||
vi.mock("./dm-command-auth.js", () => ({
|
||||
resolveDiscordDmCommandAccess: resolveDiscordDmCommandAccessMock,
|
||||
}));
|
||||
vi.mock("./dm-command-decision.js", () => ({
|
||||
handleDiscordDmCommandDecision: handleDiscordDmCommandDecisionMock,
|
||||
}));
|
||||
import {
|
||||
__testing as sessionBindingTesting,
|
||||
@@ -261,6 +269,14 @@ describe("preflightDiscordMessage", () => {
|
||||
beforeEach(() => {
|
||||
sessionBindingTesting.resetSessionBindingAdaptersForTests();
|
||||
transcribeFirstAudioMock.mockReset();
|
||||
resolveDiscordDmCommandAccessMock.mockReset();
|
||||
resolveDiscordDmCommandAccessMock.mockResolvedValue({
|
||||
commandAuthorized: true,
|
||||
decision: "allow",
|
||||
allowMatch: { allowed: true, matchedBy: "allowFrom", value: "123" },
|
||||
});
|
||||
handleDiscordDmCommandDecisionMock.mockReset();
|
||||
handleDiscordDmCommandDecisionMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("drops bound-thread bot system messages to prevent ACP self-loop", async () => {
|
||||
@@ -349,6 +365,56 @@ describe("preflightDiscordMessage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the default discord account for omitted-account dm authorization", async () => {
|
||||
const message = createDiscordMessage({
|
||||
id: "m-dm-default-account",
|
||||
channelId: "dm-channel-default-account",
|
||||
content: "who are you",
|
||||
author: {
|
||||
id: "user-1",
|
||||
bot: false,
|
||||
username: "alice",
|
||||
},
|
||||
});
|
||||
|
||||
await preflightDiscordMessage({
|
||||
...createPreflightArgs({
|
||||
cfg: {
|
||||
...DEFAULT_PREFLIGHT_CFG,
|
||||
channels: {
|
||||
discord: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
default: {
|
||||
token: "token-default",
|
||||
},
|
||||
work: {
|
||||
token: "token-work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
discordConfig: {
|
||||
defaultAccount: "work",
|
||||
dmPolicy: "allowlist",
|
||||
} as DiscordConfig,
|
||||
data: {
|
||||
channel_id: "dm-channel-default-account",
|
||||
author: message.author,
|
||||
message,
|
||||
} as DiscordMessageEvent,
|
||||
client: createDmClient("dm-channel-default-account"),
|
||||
}),
|
||||
});
|
||||
|
||||
expect(resolveDiscordDmCommandAccessMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accountId: "default",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps bound-thread regular bot messages flowing when allowBots=true", async () => {
|
||||
const threadBinding = createThreadBinding({
|
||||
targetKind: "session",
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from "./allow-list.js";
|
||||
import { resolveDiscordDmCommandAccess } from "./dm-command-auth.js";
|
||||
import { handleDiscordDmCommandDecision } from "./dm-command-decision.js";
|
||||
import { resolveDefaultDiscordAccountId } from "../accounts.js";
|
||||
import {
|
||||
formatDiscordUserTag,
|
||||
resolveDiscordSystemLocation,
|
||||
@@ -386,7 +387,7 @@ export async function preflightDiscordMessage(
|
||||
|
||||
const dmPolicy = params.discordConfig?.dmPolicy ?? params.discordConfig?.dm?.policy ?? "pairing";
|
||||
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
|
||||
const resolvedAccountId = params.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||
const resolvedAccountId = params.accountId ?? resolveDefaultDiscordAccountId(params.cfg);
|
||||
const allowNameMatching = isDangerousNameMatchingEnabled(params.discordConfig);
|
||||
let commandAuthorized = true;
|
||||
if (isDirectMessage) {
|
||||
|
||||
@@ -5,8 +5,10 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const fetchRemoteMedia = vi.fn();
|
||||
const saveMediaBuffer = vi.fn();
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/media-runtime")>();
|
||||
vi.mock("openclaw/plugin-sdk/media-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/media-runtime")>(
|
||||
"openclaw/plugin-sdk/media-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
fetchRemoteMedia: (...args: unknown[]) => fetchRemoteMedia(...args),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { peekSystemEvents, resetSystemEventsForTest } from "../../../../src/infra/system-events.js";
|
||||
import { expectPairingReplyText } from "../../../../test/helpers/pairing-reply.js";
|
||||
import {
|
||||
enqueueSystemEventMock,
|
||||
readAllowFromStoreMock,
|
||||
resetDiscordComponentRuntimeMocks,
|
||||
upsertPairingRequestMock,
|
||||
@@ -29,7 +30,6 @@ describe("agent components", () => {
|
||||
});
|
||||
|
||||
const createCfg = (): OpenClawConfig => ({}) as OpenClawConfig;
|
||||
|
||||
const createBaseDmInteraction = (overrides: Record<string, unknown> = {}) => {
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const defer = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -204,9 +204,12 @@ describe("agent components", () => {
|
||||
|
||||
expect(defer).not.toHaveBeenCalled();
|
||||
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
|
||||
expect(peekSystemEvents(defaultGroupDmSessionKey)).toEqual([
|
||||
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
|
||||
"[Discord component: hello clicked by Alice#1234 (123456789)]",
|
||||
]);
|
||||
expect.objectContaining({
|
||||
sessionKey: defaultGroupDmSessionKey,
|
||||
}),
|
||||
);
|
||||
expect(peekSystemEvents(defaultDmSessionKey)).toEqual([]);
|
||||
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -224,9 +227,12 @@ describe("agent components", () => {
|
||||
|
||||
expect(defer).not.toHaveBeenCalled();
|
||||
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
|
||||
expect(peekSystemEvents(defaultDmSessionKey)).toEqual([
|
||||
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
|
||||
"[Discord component: hello clicked by Alice#1234 (123456789)]",
|
||||
]);
|
||||
expect.objectContaining({
|
||||
sessionKey: defaultDmSessionKey,
|
||||
}),
|
||||
);
|
||||
expect(upsertPairingRequestMock).not.toHaveBeenCalled();
|
||||
expect(readAllowFromStoreMock).toHaveBeenCalledWith("discord", "default");
|
||||
});
|
||||
@@ -244,9 +250,12 @@ describe("agent components", () => {
|
||||
|
||||
expect(defer).not.toHaveBeenCalled();
|
||||
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
|
||||
expect(peekSystemEvents(defaultDmSessionKey)).toEqual([
|
||||
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
|
||||
"[Discord component: hello clicked by Alice#1234 (123456789)]",
|
||||
]);
|
||||
expect.objectContaining({
|
||||
sessionKey: defaultDmSessionKey,
|
||||
}),
|
||||
);
|
||||
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -284,9 +293,12 @@ describe("agent components", () => {
|
||||
|
||||
expect(defer).not.toHaveBeenCalled();
|
||||
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
|
||||
expect(peekSystemEvents(defaultDmSessionKey)).toEqual([
|
||||
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
|
||||
"[Discord select menu: hello interacted by Alice#1234 (123456789) (selected: alpha)]",
|
||||
]);
|
||||
expect.objectContaining({
|
||||
sessionKey: defaultDmSessionKey,
|
||||
}),
|
||||
);
|
||||
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -303,9 +315,12 @@ describe("agent components", () => {
|
||||
|
||||
expect(defer).not.toHaveBeenCalled();
|
||||
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
|
||||
expect(peekSystemEvents(defaultDmSessionKey)).toEqual([
|
||||
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
|
||||
"[Discord component: hello_cid clicked by Alice#1234 (123456789)]",
|
||||
]);
|
||||
expect.objectContaining({
|
||||
sessionKey: defaultDmSessionKey,
|
||||
}),
|
||||
);
|
||||
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -322,9 +337,12 @@ describe("agent components", () => {
|
||||
|
||||
expect(defer).not.toHaveBeenCalled();
|
||||
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
|
||||
expect(peekSystemEvents(defaultDmSessionKey)).toEqual([
|
||||
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
|
||||
"[Discord component: hello%2G clicked by Alice#1234 (123456789)]",
|
||||
]);
|
||||
expect.objectContaining({
|
||||
sessionKey: defaultDmSessionKey,
|
||||
}),
|
||||
);
|
||||
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,6 +52,11 @@ type CreateDiscordComponentModal =
|
||||
typeof import("./agent-components.js").createDiscordComponentModal;
|
||||
type CreateDiscordComponentStringSelect =
|
||||
typeof import("./agent-components.js").createDiscordComponentStringSelect;
|
||||
type DispatchReplyWithBufferedBlockDispatcherFn =
|
||||
typeof import("openclaw/plugin-sdk/reply-dispatch-runtime").dispatchReplyWithBufferedBlockDispatcher;
|
||||
type DispatchReplyWithBufferedBlockDispatcherResult = Awaited<
|
||||
ReturnType<DispatchReplyWithBufferedBlockDispatcherFn>
|
||||
>;
|
||||
|
||||
let createDiscordComponentButton: CreateDiscordComponentButton;
|
||||
let createDiscordComponentStringSelect: CreateDiscordComponentStringSelect;
|
||||
@@ -81,12 +86,7 @@ describe("discord component interactions", () => {
|
||||
...overrides,
|
||||
}) as DiscordAccountConfig;
|
||||
|
||||
type DispatchParams = {
|
||||
ctx: Record<string, unknown>;
|
||||
dispatcherOptions: {
|
||||
deliver: (payload: { text?: string }) => Promise<void> | void;
|
||||
};
|
||||
};
|
||||
type DispatchParams = Parameters<DispatchReplyWithBufferedBlockDispatcherFn>[0];
|
||||
|
||||
type ComponentContext = Parameters<CreateDiscordComponentButton>[0];
|
||||
|
||||
@@ -285,10 +285,22 @@ describe("discord component interactions", () => {
|
||||
resetDiscordComponentRuntimeMocks();
|
||||
lastDispatchCtx = undefined;
|
||||
enqueueSystemEventMock.mockClear();
|
||||
dispatchReplyMock.mockClear().mockImplementation(async (params: DispatchParams) => {
|
||||
lastDispatchCtx = params.ctx;
|
||||
await params.dispatcherOptions.deliver({ text: "ok" });
|
||||
});
|
||||
dispatchReplyMock
|
||||
.mockClear()
|
||||
.mockImplementation(
|
||||
async (params: DispatchParams): Promise<DispatchReplyWithBufferedBlockDispatcherResult> => {
|
||||
lastDispatchCtx = params.ctx;
|
||||
await params.dispatcherOptions.deliver({ text: "ok" }, { kind: "final" });
|
||||
return {
|
||||
queuedFinal: false,
|
||||
counts: {
|
||||
block: 0,
|
||||
final: 1,
|
||||
tool: 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
recordInboundSessionMock.mockClear().mockResolvedValue(undefined);
|
||||
readSessionUpdatedAtMock.mockClear().mockReturnValue(undefined);
|
||||
resolveStorePathMock.mockClear().mockReturnValue("/tmp/openclaw-sessions-test.json");
|
||||
|
||||
@@ -153,7 +153,7 @@ describe("createDiscordNativeCommand option wiring", () => {
|
||||
});
|
||||
|
||||
it("keeps static choices for non-acp string action arguments", () => {
|
||||
const command = createNativeCommand("voice");
|
||||
const command = createNativeCommand("config");
|
||||
const action = requireOption(command, "action");
|
||||
const choices = readChoices(action);
|
||||
|
||||
|
||||
@@ -21,8 +21,10 @@ const runtimeModuleMocks = vi.hoisted(() => ({
|
||||
dispatchReplyWithDispatcher: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/plugin-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/plugin-runtime")>();
|
||||
vi.mock("openclaw/plugin-sdk/plugin-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/plugin-runtime")>(
|
||||
"openclaw/plugin-sdk/plugin-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
matchPluginCommand: (...args: unknown[]) => runtimeModuleMocks.matchPluginCommand(...args),
|
||||
@@ -30,8 +32,10 @@ vi.mock("openclaw/plugin-sdk/plugin-runtime", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
|
||||
vi.mock("openclaw/plugin-sdk/reply-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/reply-runtime")>(
|
||||
"openclaw/plugin-sdk/reply-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
dispatchReplyWithDispatcher: (...args: unknown[]) =>
|
||||
|
||||
@@ -11,39 +11,62 @@ import { clearSessionStoreCacheForTest } from "openclaw/plugin-sdk/config-runtim
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createNoopThreadBindingManager } from "./thread-bindings.js";
|
||||
|
||||
type EnsureConfiguredBindingRouteReady =
|
||||
typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredBindingRouteReady;
|
||||
type ResolveConfiguredBindingRoute =
|
||||
typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConfiguredBindingRoute;
|
||||
|
||||
const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() =>
|
||||
vi.fn<() => Promise<{ ok: boolean; error?: string }>>(async () => ({ ok: true })),
|
||||
vi.fn<EnsureConfiguredBindingRouteReady>(async () => ({ ok: true })),
|
||||
);
|
||||
const resolveConfiguredBindingRouteMock = vi.hoisted(() =>
|
||||
vi.fn<
|
||||
() => {
|
||||
bindingResolution: {
|
||||
record: {
|
||||
conversation: {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
boundSessionKey: string;
|
||||
route: {
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
};
|
||||
} | null
|
||||
>(() => null),
|
||||
vi.fn<ResolveConfiguredBindingRoute>(({ route }) => ({
|
||||
bindingResolution: null,
|
||||
route,
|
||||
})),
|
||||
);
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
|
||||
type ConfiguredBindingRoute = ReturnType<ResolveConfiguredBindingRoute>;
|
||||
type ConfiguredBindingResolution = NonNullable<ConfiguredBindingRoute["bindingResolution"]>;
|
||||
|
||||
function createConfiguredRouteResult(
|
||||
params: Parameters<ResolveConfiguredBindingRoute>[0],
|
||||
): ConfiguredBindingRoute {
|
||||
return {
|
||||
bindingResolution: {
|
||||
record: {
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "C1",
|
||||
},
|
||||
},
|
||||
} as ConfiguredBindingResolution,
|
||||
boundSessionKey: SESSION_KEY,
|
||||
route: {
|
||||
...params.route,
|
||||
agentId: "main",
|
||||
sessionKey: SESSION_KEY,
|
||||
matchedBy: "binding.channel",
|
||||
lastRoutePolicy: "session",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => {
|
||||
const { createConfiguredBindingConversationRuntimeModuleMock } =
|
||||
await import("../test-support/configured-binding-runtime.js");
|
||||
return await createConfiguredBindingConversationRuntimeModuleMock(
|
||||
return await createConfiguredBindingConversationRuntimeModuleMock<
|
||||
typeof import("openclaw/plugin-sdk/conversation-runtime")
|
||||
>(
|
||||
{
|
||||
ensureConfiguredBindingRouteReadyMock,
|
||||
resolveConfiguredBindingRouteMock,
|
||||
},
|
||||
importOriginal,
|
||||
() =>
|
||||
vi.importActual<typeof import("openclaw/plugin-sdk/conversation-runtime")>(
|
||||
"openclaw/plugin-sdk/conversation-runtime",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -64,7 +87,10 @@ describe("discord native /think autocomplete", () => {
|
||||
ensureConfiguredBindingRouteReadyMock.mockReset();
|
||||
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({ ok: true });
|
||||
resolveConfiguredBindingRouteMock.mockReset();
|
||||
resolveConfiguredBindingRouteMock.mockReturnValue(null);
|
||||
resolveConfiguredBindingRouteMock.mockImplementation(({ route }) => ({
|
||||
bindingResolution: null,
|
||||
route,
|
||||
}));
|
||||
fs.mkdirSync(path.dirname(STORE_PATH), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
STORE_PATH,
|
||||
@@ -149,22 +175,7 @@ describe("discord native /think autocomplete", () => {
|
||||
|
||||
it("falls back when a configured binding is unavailable", async () => {
|
||||
const cfg = createConfig();
|
||||
resolveConfiguredBindingRouteMock.mockReturnValue({
|
||||
bindingResolution: {
|
||||
record: {
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "C1",
|
||||
},
|
||||
},
|
||||
},
|
||||
boundSessionKey: SESSION_KEY,
|
||||
route: {
|
||||
agentId: "main",
|
||||
sessionKey: SESSION_KEY,
|
||||
},
|
||||
});
|
||||
resolveConfiguredBindingRouteMock.mockImplementation(createConfiguredRouteResult);
|
||||
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "acpx exited",
|
||||
|
||||
@@ -36,8 +36,8 @@ const retryAsyncMock = vi.hoisted(() =>
|
||||
),
|
||||
);
|
||||
|
||||
vi.mock("../send.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../send.js")>();
|
||||
vi.mock("../send.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../send.js")>("../send.js");
|
||||
return {
|
||||
...actual,
|
||||
sendMessageDiscord: (...args: unknown[]) => sendMessageDiscordMock(...args),
|
||||
@@ -50,8 +50,10 @@ vi.mock("../send.shared.js", () => ({
|
||||
sendDiscordText: (...args: unknown[]) => sendDiscordTextMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/retry-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/retry-runtime")>();
|
||||
vi.mock("openclaw/plugin-sdk/retry-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/retry-runtime")>(
|
||||
"openclaw/plugin-sdk/retry-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
retryAsync: retryAsyncMock,
|
||||
|
||||
@@ -41,8 +41,8 @@ const hoisted = vi.hoisted(() => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../send.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../send.js")>();
|
||||
vi.mock("../send.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../send.js")>("../send.js");
|
||||
return {
|
||||
...actual,
|
||||
addRoleDiscord: vi.fn(),
|
||||
|
||||
@@ -6,8 +6,10 @@ const hoisted = vi.hoisted(() => {
|
||||
return { updateSessionStore, resolveStorePath };
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
|
||||
"openclaw/plugin-sdk/config-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
updateSessionStore: hoisted.updateSessionStore,
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
import { expect, vi } from "vitest";
|
||||
import { expect, vi, type Mock } from "vitest";
|
||||
|
||||
export function createDiscordOutboundHoisted() {
|
||||
type UnknownMock = Mock<(...args: unknown[]) => unknown>;
|
||||
type AsyncUnknownMock = Mock<(...args: unknown[]) => Promise<unknown>>;
|
||||
|
||||
type DiscordOutboundHoisted = {
|
||||
sendMessageDiscordMock: AsyncUnknownMock;
|
||||
sendDiscordComponentMessageMock: AsyncUnknownMock;
|
||||
sendPollDiscordMock: AsyncUnknownMock;
|
||||
sendWebhookMessageDiscordMock: AsyncUnknownMock;
|
||||
getThreadBindingManagerMock: UnknownMock;
|
||||
};
|
||||
|
||||
type DiscordSendModule = typeof import("./send.js");
|
||||
type DiscordSendComponentsModule = typeof import("./send.components.js");
|
||||
type DiscordThreadBindingsModule = typeof import("./monitor/thread-bindings.js");
|
||||
|
||||
function invokeMock<TArgs extends unknown[], TResult>(
|
||||
mock: (...args: unknown[]) => unknown,
|
||||
...args: TArgs
|
||||
): TResult {
|
||||
return mock(...args) as TResult;
|
||||
}
|
||||
|
||||
export function createDiscordOutboundHoisted(): DiscordOutboundHoisted {
|
||||
const sendMessageDiscordMock = vi.fn();
|
||||
const sendDiscordComponentMessageMock = vi.fn();
|
||||
const sendPollDiscordMock = vi.fn();
|
||||
@@ -21,28 +43,94 @@ export const DEFAULT_DISCORD_SEND_RESULT = {
|
||||
channelId: "ch-1",
|
||||
} as const;
|
||||
|
||||
type DiscordOutboundHoisted = ReturnType<typeof createDiscordOutboundHoisted>;
|
||||
export async function createDiscordSendModuleMock(
|
||||
hoisted: DiscordOutboundHoisted,
|
||||
loadActual: () => Promise<DiscordSendModule>,
|
||||
): Promise<DiscordSendModule> {
|
||||
const actual = await loadActual();
|
||||
return {
|
||||
...actual,
|
||||
sendMessageDiscord: (...args: Parameters<DiscordSendModule["sendMessageDiscord"]>) =>
|
||||
invokeMock<
|
||||
Parameters<DiscordSendModule["sendMessageDiscord"]>,
|
||||
ReturnType<DiscordSendModule["sendMessageDiscord"]>
|
||||
>(hoisted.sendMessageDiscordMock, ...args),
|
||||
sendPollDiscord: (...args: Parameters<DiscordSendModule["sendPollDiscord"]>) =>
|
||||
invokeMock<
|
||||
Parameters<DiscordSendModule["sendPollDiscord"]>,
|
||||
ReturnType<DiscordSendModule["sendPollDiscord"]>
|
||||
>(hoisted.sendPollDiscordMock, ...args),
|
||||
sendWebhookMessageDiscord: (
|
||||
...args: Parameters<DiscordSendModule["sendWebhookMessageDiscord"]>
|
||||
) =>
|
||||
invokeMock<
|
||||
Parameters<DiscordSendModule["sendWebhookMessageDiscord"]>,
|
||||
ReturnType<DiscordSendModule["sendWebhookMessageDiscord"]>
|
||||
>(hoisted.sendWebhookMessageDiscordMock, ...args),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createDiscordSendComponentsModuleMock(
|
||||
hoisted: DiscordOutboundHoisted,
|
||||
loadActual: () => Promise<DiscordSendComponentsModule>,
|
||||
): Promise<DiscordSendComponentsModule> {
|
||||
const actual = await loadActual();
|
||||
return {
|
||||
...actual,
|
||||
sendDiscordComponentMessage: (
|
||||
...args: Parameters<DiscordSendComponentsModule["sendDiscordComponentMessage"]>
|
||||
) =>
|
||||
invokeMock<
|
||||
Parameters<DiscordSendComponentsModule["sendDiscordComponentMessage"]>,
|
||||
ReturnType<DiscordSendComponentsModule["sendDiscordComponentMessage"]>
|
||||
>(hoisted.sendDiscordComponentMessageMock, ...args),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createDiscordThreadBindingsModuleMock(
|
||||
hoisted: DiscordOutboundHoisted,
|
||||
loadActual: () => Promise<DiscordThreadBindingsModule>,
|
||||
): Promise<DiscordThreadBindingsModule> {
|
||||
const actual = await loadActual();
|
||||
return {
|
||||
...actual,
|
||||
getThreadBindingManager: (
|
||||
...args: Parameters<DiscordThreadBindingsModule["getThreadBindingManager"]>
|
||||
) =>
|
||||
invokeMock<
|
||||
Parameters<DiscordThreadBindingsModule["getThreadBindingManager"]>,
|
||||
ReturnType<DiscordThreadBindingsModule["getThreadBindingManager"]>
|
||||
>(hoisted.getThreadBindingManagerMock, ...args),
|
||||
};
|
||||
}
|
||||
|
||||
export async function installDiscordOutboundModuleSpies(hoisted: DiscordOutboundHoisted) {
|
||||
const sendModule = await import("./send.js");
|
||||
vi.spyOn(sendModule, "sendMessageDiscord").mockImplementation((...args: unknown[]) =>
|
||||
hoisted.sendMessageDiscordMock(...args),
|
||||
const mockedSendModule = await createDiscordSendModuleMock(hoisted, async () => sendModule);
|
||||
vi.spyOn(sendModule, "sendMessageDiscord").mockImplementation(
|
||||
mockedSendModule.sendMessageDiscord,
|
||||
);
|
||||
vi.spyOn(sendModule, "sendPollDiscord").mockImplementation((...args: unknown[]) =>
|
||||
hoisted.sendPollDiscordMock(...args),
|
||||
);
|
||||
vi.spyOn(sendModule, "sendWebhookMessageDiscord").mockImplementation((...args: unknown[]) =>
|
||||
hoisted.sendWebhookMessageDiscordMock(...args),
|
||||
vi.spyOn(sendModule, "sendPollDiscord").mockImplementation(mockedSendModule.sendPollDiscord);
|
||||
vi.spyOn(sendModule, "sendWebhookMessageDiscord").mockImplementation(
|
||||
mockedSendModule.sendWebhookMessageDiscord,
|
||||
);
|
||||
|
||||
const sendComponentsModule = await import("./send.components.js");
|
||||
const mockedSendComponentsModule = await createDiscordSendComponentsModuleMock(
|
||||
hoisted,
|
||||
async () => sendComponentsModule,
|
||||
);
|
||||
vi.spyOn(sendComponentsModule, "sendDiscordComponentMessage").mockImplementation(
|
||||
(...args: unknown[]) => hoisted.sendDiscordComponentMessageMock(...args),
|
||||
mockedSendComponentsModule.sendDiscordComponentMessage,
|
||||
);
|
||||
|
||||
const threadBindingsModule = await import("./monitor/thread-bindings.js");
|
||||
const mockedThreadBindingsModule = await createDiscordThreadBindingsModuleMock(
|
||||
hoisted,
|
||||
async () => threadBindingsModule,
|
||||
);
|
||||
vi.spyOn(threadBindingsModule, "getThreadBindingManager").mockImplementation(
|
||||
(...args: unknown[]) => hoisted.getThreadBindingManagerMock(...args),
|
||||
mockedThreadBindingsModule.getThreadBindingManager,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
235
extensions/discord/src/security-audit.ts
Normal file
235
extensions/discord/src/security-audit.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import {
|
||||
isDangerousNameMatchingEnabled,
|
||||
resolveNativeCommandsEnabled,
|
||||
resolveNativeSkillsEnabled,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import type { ResolvedDiscordAccount } from "./accounts.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
|
||||
function normalizeAllowFromList(list: Array<string | number> | undefined | null): string[] {
|
||||
if (!Array.isArray(list)) {
|
||||
return [];
|
||||
}
|
||||
return list.map((value) => String(value).trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function coerceNativeSetting(value: unknown): boolean | "auto" | undefined {
|
||||
if (value === true || value === false || value === "auto") {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isDiscordMutableAllowEntry(raw: string): boolean {
|
||||
const text = raw.trim();
|
||||
if (!text || text === "*") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const maybeMentionId = text.replace(/^<@!?/, "").replace(/>$/, "");
|
||||
if (/^\d+$/.test(maybeMentionId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const prefix of ["discord:", "user:", "pk:"]) {
|
||||
if (!text.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
return text.slice(prefix.length).trim().length === 0;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function addDiscordNameBasedEntries(params: {
|
||||
target: Set<string>;
|
||||
values: unknown;
|
||||
source: string;
|
||||
}) {
|
||||
if (!Array.isArray(params.values)) {
|
||||
return;
|
||||
}
|
||||
for (const value of params.values) {
|
||||
if (!isDiscordMutableAllowEntry(String(value))) {
|
||||
continue;
|
||||
}
|
||||
const text = String(value).trim();
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
params.target.add(`${params.source}:${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function collectDiscordSecurityAuditFindings(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
account: ResolvedDiscordAccount;
|
||||
orderedAccountIds: string[];
|
||||
hasExplicitAccountPath: boolean;
|
||||
}) {
|
||||
const findings: Array<{
|
||||
checkId: string;
|
||||
severity: "info" | "warn" | "critical";
|
||||
title: string;
|
||||
detail: string;
|
||||
remediation?: string;
|
||||
}> = [];
|
||||
const discordCfg = params.account.config ?? {};
|
||||
const accountId = params.accountId?.trim() || params.account.accountId || "default";
|
||||
const dangerousNameMatchingEnabled = isDangerousNameMatchingEnabled(discordCfg);
|
||||
const storeAllowFrom = await readChannelAllowFromStore("discord", process.env, accountId).catch(
|
||||
() => [],
|
||||
);
|
||||
const discordNameBasedAllowEntries = new Set<string>();
|
||||
const discordPathPrefix =
|
||||
params.orderedAccountIds.length > 1 || params.hasExplicitAccountPath
|
||||
? `channels.discord.accounts.${accountId}`
|
||||
: "channels.discord";
|
||||
|
||||
addDiscordNameBasedEntries({
|
||||
target: discordNameBasedAllowEntries,
|
||||
values: discordCfg.allowFrom,
|
||||
source: `${discordPathPrefix}.allowFrom`,
|
||||
});
|
||||
addDiscordNameBasedEntries({
|
||||
target: discordNameBasedAllowEntries,
|
||||
values: (discordCfg.dm as { allowFrom?: unknown } | undefined)?.allowFrom,
|
||||
source: `${discordPathPrefix}.dm.allowFrom`,
|
||||
});
|
||||
addDiscordNameBasedEntries({
|
||||
target: discordNameBasedAllowEntries,
|
||||
values: storeAllowFrom,
|
||||
source: "~/.openclaw/credentials/discord-allowFrom.json",
|
||||
});
|
||||
|
||||
const guildEntries = (discordCfg.guilds as Record<string, unknown> | undefined) ?? {};
|
||||
for (const [guildKey, guildValue] of Object.entries(guildEntries)) {
|
||||
if (!guildValue || typeof guildValue !== "object") {
|
||||
continue;
|
||||
}
|
||||
const guild = guildValue as Record<string, unknown>;
|
||||
addDiscordNameBasedEntries({
|
||||
target: discordNameBasedAllowEntries,
|
||||
values: guild.users,
|
||||
source: `${discordPathPrefix}.guilds.${guildKey}.users`,
|
||||
});
|
||||
const channels = guild.channels;
|
||||
if (!channels || typeof channels !== "object") {
|
||||
continue;
|
||||
}
|
||||
for (const [channelKey, channelValue] of Object.entries(channels as Record<string, unknown>)) {
|
||||
if (!channelValue || typeof channelValue !== "object") {
|
||||
continue;
|
||||
}
|
||||
const channel = channelValue as Record<string, unknown>;
|
||||
addDiscordNameBasedEntries({
|
||||
target: discordNameBasedAllowEntries,
|
||||
values: channel.users,
|
||||
source: `${discordPathPrefix}.guilds.${guildKey}.channels.${channelKey}.users`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (discordNameBasedAllowEntries.size > 0) {
|
||||
const examples = Array.from(discordNameBasedAllowEntries).slice(0, 5);
|
||||
const more =
|
||||
discordNameBasedAllowEntries.size > examples.length
|
||||
? ` (+${discordNameBasedAllowEntries.size - examples.length} more)`
|
||||
: "";
|
||||
findings.push({
|
||||
checkId: "channels.discord.allowFrom.name_based_entries",
|
||||
severity: dangerousNameMatchingEnabled ? "info" : "warn",
|
||||
title: dangerousNameMatchingEnabled
|
||||
? "Discord allowlist uses break-glass name/tag matching"
|
||||
: "Discord allowlist contains name or tag entries",
|
||||
detail: dangerousNameMatchingEnabled
|
||||
? "Discord name/tag allowlist matching is explicitly enabled via dangerouslyAllowNameMatching. This mutable-identity mode is operator-selected break-glass behavior and out-of-scope for vulnerability reports by itself. " +
|
||||
`Found: ${examples.join(", ")}${more}.`
|
||||
: "Discord name/tag allowlist matching uses normalized slugs and can collide across users. " +
|
||||
`Found: ${examples.join(", ")}${more}.`,
|
||||
remediation: dangerousNameMatchingEnabled
|
||||
? "Prefer stable Discord IDs (or <@id>/user:<id>/pk:<id>), then disable dangerouslyAllowNameMatching."
|
||||
: "Prefer stable Discord IDs (or <@id>/user:<id>/pk:<id>) in channels.discord.allowFrom and channels.discord.guilds.*.users, or explicitly opt in with dangerouslyAllowNameMatching=true if you accept the risk.",
|
||||
});
|
||||
}
|
||||
|
||||
const nativeEnabled = resolveNativeCommandsEnabled({
|
||||
providerId: "discord",
|
||||
providerSetting: coerceNativeSetting(
|
||||
(discordCfg.commands as { native?: unknown } | undefined)?.native,
|
||||
),
|
||||
globalSetting: params.cfg.commands?.native,
|
||||
});
|
||||
const nativeSkillsEnabled = resolveNativeSkillsEnabled({
|
||||
providerId: "discord",
|
||||
providerSetting: coerceNativeSetting(
|
||||
(discordCfg.commands as { nativeSkills?: unknown } | undefined)?.nativeSkills,
|
||||
),
|
||||
globalSetting: params.cfg.commands?.nativeSkills,
|
||||
});
|
||||
if (!nativeEnabled && !nativeSkillsEnabled) {
|
||||
return findings;
|
||||
}
|
||||
|
||||
const defaultGroupPolicy = params.cfg.channels?.defaults?.groupPolicy;
|
||||
const groupPolicy =
|
||||
(discordCfg.groupPolicy as string | undefined) ?? defaultGroupPolicy ?? "allowlist";
|
||||
const guildsConfigured = Object.keys(guildEntries).length > 0;
|
||||
const hasAnyUserAllowlist = Object.values(guildEntries).some((guild) => {
|
||||
if (!guild || typeof guild !== "object") {
|
||||
return false;
|
||||
}
|
||||
const record = guild as Record<string, unknown>;
|
||||
if (Array.isArray(record.users) && record.users.length > 0) {
|
||||
return true;
|
||||
}
|
||||
const channels = record.channels;
|
||||
if (!channels || typeof channels !== "object") {
|
||||
return false;
|
||||
}
|
||||
return Object.values(channels as Record<string, unknown>).some((channel) => {
|
||||
if (!channel || typeof channel !== "object") {
|
||||
return false;
|
||||
}
|
||||
const channelRecord = channel as Record<string, unknown>;
|
||||
return Array.isArray(channelRecord.users) && channelRecord.users.length > 0;
|
||||
});
|
||||
});
|
||||
const dmAllowFromRaw = (discordCfg.dm as { allowFrom?: unknown } | undefined)?.allowFrom;
|
||||
const dmAllowFrom = Array.isArray(dmAllowFromRaw) ? dmAllowFromRaw : [];
|
||||
const ownerAllowFromConfigured =
|
||||
normalizeAllowFromList([...dmAllowFrom, ...storeAllowFrom]).length > 0;
|
||||
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
|
||||
|
||||
if (!useAccessGroups && groupPolicy !== "disabled" && guildsConfigured && !hasAnyUserAllowlist) {
|
||||
findings.push({
|
||||
checkId: "channels.discord.commands.native.unrestricted",
|
||||
severity: "critical",
|
||||
title: "Discord slash commands are unrestricted",
|
||||
detail:
|
||||
"commands.useAccessGroups=false disables sender allowlists for Discord slash commands unless a per-guild/channel users allowlist is configured; with no users allowlist, any user in allowed guild channels can invoke /… commands.",
|
||||
remediation:
|
||||
"Set commands.useAccessGroups=true (recommended), or configure channels.discord.guilds.<id>.users (or channels.discord.guilds.<id>.channels.<channel>.users).",
|
||||
});
|
||||
} else if (
|
||||
useAccessGroups &&
|
||||
groupPolicy !== "disabled" &&
|
||||
guildsConfigured &&
|
||||
!ownerAllowFromConfigured &&
|
||||
!hasAnyUserAllowlist
|
||||
) {
|
||||
findings.push({
|
||||
checkId: "channels.discord.commands.native.no_allowlists",
|
||||
severity: "warn",
|
||||
title: "Discord slash commands have no allowlists",
|
||||
detail:
|
||||
"Discord slash commands are enabled, but neither an owner allowFrom list nor any per-guild/channel users allowlist is configured; /… commands will be rejected for everyone.",
|
||||
remediation:
|
||||
"Add your user id to channels.discord.allowFrom (or approve yourself via pairing), or configure channels.discord.guilds.<id>.users.",
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
@@ -365,15 +365,15 @@ export async function sendWebhookMessageDiscord(
|
||||
throw new Error("Discord webhook id/token are required");
|
||||
}
|
||||
|
||||
const rewrittenText = rewriteDiscordKnownMentions(text, {
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const replyTo = typeof opts.replyTo === "string" ? opts.replyTo.trim() : "";
|
||||
const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined;
|
||||
const { account, proxyFetch } = resolveDiscordClientAccountContext({
|
||||
cfg: opts.cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const rewrittenText = rewriteDiscordKnownMentions(text, {
|
||||
accountId: account.accountId,
|
||||
});
|
||||
|
||||
const response = await (proxyFetch ?? fetch)(
|
||||
resolveWebhookExecutionUrl({
|
||||
@@ -430,11 +430,16 @@ export async function sendStickerDiscord(
|
||||
stickerIds: string[],
|
||||
opts: DiscordSendOpts & { content?: string } = {},
|
||||
): Promise<DiscordSendResult> {
|
||||
const cfg = opts.cfg ?? loadConfig();
|
||||
const accountInfo = resolveDiscordAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const { rest, request, channelId } = await resolveDiscordSendTarget(to, opts);
|
||||
const content = opts.content?.trim();
|
||||
const rewrittenContent = content
|
||||
? rewriteDiscordKnownMentions(content, {
|
||||
accountId: opts.accountId,
|
||||
accountId: accountInfo.accountId,
|
||||
})
|
||||
: undefined;
|
||||
const stickers = normalizeStickerIds(stickerIds);
|
||||
@@ -456,11 +461,16 @@ export async function sendPollDiscord(
|
||||
poll: PollInput,
|
||||
opts: DiscordSendOpts & { content?: string } = {},
|
||||
): Promise<DiscordSendResult> {
|
||||
const cfg = opts.cfg ?? loadConfig();
|
||||
const accountInfo = resolveDiscordAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const { rest, request, channelId } = await resolveDiscordSendTarget(to, opts);
|
||||
const content = opts.content?.trim();
|
||||
const rewrittenContent = content
|
||||
? rewriteDiscordKnownMentions(content, {
|
||||
accountId: opts.accountId,
|
||||
accountId: accountInfo.accountId,
|
||||
})
|
||||
: undefined;
|
||||
if (poll.durationSeconds !== undefined) {
|
||||
|
||||
@@ -126,6 +126,40 @@ describe("sendMessageDiscord", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount for cached mention rewriting when accountId is omitted", async () => {
|
||||
rememberDiscordDirectoryUser({
|
||||
accountId: "work",
|
||||
userId: "222333444555666777",
|
||||
handles: ["Alice"],
|
||||
});
|
||||
const { rest, postMock, getMock } = makeDiscordRest();
|
||||
getMock.mockResolvedValueOnce({ type: ChannelType.GuildText });
|
||||
postMock.mockResolvedValue({
|
||||
id: "msg1",
|
||||
channel_id: "789",
|
||||
});
|
||||
await sendMessageDiscord("channel:789", "ping @Alice", {
|
||||
rest,
|
||||
token: "t",
|
||||
cfg: {
|
||||
channels: {
|
||||
discord: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: {
|
||||
token: "Bot work-token", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
Routes.channelMessages("789"),
|
||||
expect.objectContaining({ body: { content: "ping <@222333444555666777>" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("auto-creates a forum thread when target is a Forum channel", async () => {
|
||||
const { rest, postMock, getMock } = makeDiscordRest();
|
||||
// Channel type lookup returns a Forum channel.
|
||||
|
||||
@@ -3,16 +3,20 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite
|
||||
const recordChannelActivityMock = vi.hoisted(() => vi.fn());
|
||||
const loadConfigMock = vi.hoisted(() => vi.fn(() => ({ channels: { discord: {} } })));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
|
||||
"openclaw/plugin-sdk/config-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => loadConfigMock(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../src/infra/channel-activity.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../src/infra/channel-activity.js")>();
|
||||
vi.mock("../../../src/infra/channel-activity.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../../src/infra/channel-activity.js")>(
|
||||
"../../../src/infra/channel-activity.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
recordChannelActivity: (...args: unknown[]) => recordChannelActivityMock(...args),
|
||||
|
||||
@@ -4,8 +4,10 @@ import { sendWebhookMessageDiscord } from "./send.outbound.js";
|
||||
|
||||
const makeProxyFetchMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
|
||||
vi.mock("openclaw/plugin-sdk/infra-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/infra-runtime")>(
|
||||
"openclaw/plugin-sdk/infra-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
makeProxyFetch: makeProxyFetchMock,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
inspectDiscordSetupAccount,
|
||||
listDiscordSetupAccountIds,
|
||||
resolveDefaultDiscordSetupAccountId,
|
||||
resolveDiscordSetupAccountConfig,
|
||||
} from "./setup-account-state.js";
|
||||
|
||||
@@ -41,6 +42,31 @@ describe("discord setup account state", () => {
|
||||
expect(resolved.config.allowFrom).toEqual(["acct"]);
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount for omitted setup account resolution", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
discord: {
|
||||
defaultAccount: "work",
|
||||
allowFrom: ["top"],
|
||||
accounts: {
|
||||
alerts: { allowFrom: ["alerts-only"] },
|
||||
work: { name: "Work", allowFrom: ["work-only"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveDefaultDiscordSetupAccountId(cfg)).toBe("work");
|
||||
|
||||
const resolved = resolveDiscordSetupAccountConfig({
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(resolved.accountId).toBe("work");
|
||||
expect(resolved.config.name).toBe("Work");
|
||||
expect(resolved.config.allowFrom).toEqual(["work-only"]);
|
||||
});
|
||||
|
||||
it("treats explicit blank account tokens as missing without falling back", () => {
|
||||
const inspected = inspectDiscordSetupAccount({
|
||||
cfg: {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
hasConfiguredSecretInput,
|
||||
normalizeSecretInputString,
|
||||
} from "openclaw/plugin-sdk/secret-input";
|
||||
import { resolveDefaultDiscordAccountId } from "./accounts.js";
|
||||
import { mergeDiscordAccountConfig, resolveDiscordAccountConfig } from "./accounts.js";
|
||||
import type { DiscordAccountConfig } from "./runtime-api.js";
|
||||
import { resolveDiscordToken } from "./token.js";
|
||||
@@ -54,14 +55,16 @@ export function listDiscordSetupAccountIds(cfg: OpenClawConfig): string[] {
|
||||
}
|
||||
|
||||
export function resolveDefaultDiscordSetupAccountId(cfg: OpenClawConfig): string {
|
||||
return listDiscordSetupAccountIds(cfg)[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
return resolveDefaultDiscordAccountId(cfg);
|
||||
}
|
||||
|
||||
export function resolveDiscordSetupAccountConfig(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): { accountId: string; config: DiscordAccountConfig } {
|
||||
const accountId = normalizeAccountId(params.accountId ?? DEFAULT_ACCOUNT_ID);
|
||||
const accountId = normalizeAccountId(
|
||||
params.accountId ?? resolveDefaultDiscordSetupAccountId(params.cfg),
|
||||
);
|
||||
return {
|
||||
accountId,
|
||||
config: mergeDiscordAccountConfig(params.cfg, accountId),
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type ResolvedDiscordAccount,
|
||||
} from "./accounts.js";
|
||||
import { DiscordChannelConfigSchema } from "./config-schema.js";
|
||||
import { discordDoctor } from "./doctor.js";
|
||||
import {
|
||||
createScopedChannelConfigAdapter,
|
||||
getChatChannelMeta,
|
||||
@@ -47,6 +48,8 @@ export function createDiscordPluginBase(params: {
|
||||
| "meta"
|
||||
| "setupWizard"
|
||||
| "capabilities"
|
||||
| "commands"
|
||||
| "doctor"
|
||||
| "streaming"
|
||||
| "reload"
|
||||
| "configSchema"
|
||||
@@ -65,6 +68,13 @@ export function createDiscordPluginBase(params: {
|
||||
media: true,
|
||||
nativeCommands: true,
|
||||
},
|
||||
commands: {
|
||||
nativeCommandsAutoEnabled: true,
|
||||
nativeSkillsAutoEnabled: true,
|
||||
resolveNativeCommandName: ({ commandKey, defaultName }) =>
|
||||
commandKey === "tts" ? "voice" : defaultName,
|
||||
},
|
||||
doctor: discordDoctor,
|
||||
streaming: {
|
||||
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
||||
},
|
||||
@@ -89,6 +99,8 @@ export function createDiscordPluginBase(params: {
|
||||
| "meta"
|
||||
| "setupWizard"
|
||||
| "capabilities"
|
||||
| "commands"
|
||||
| "doctor"
|
||||
| "streaming"
|
||||
| "reload"
|
||||
| "configSchema"
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetDiscordDirectoryCacheForTest,
|
||||
resolveDiscordDirectoryUserId,
|
||||
} from "./directory-cache.js";
|
||||
import * as directoryLive from "./directory-live.js";
|
||||
import {
|
||||
resolveDiscordGroupRequireMention,
|
||||
@@ -76,6 +80,7 @@ describe("resolveDiscordTarget", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
__resetDiscordDirectoryCacheForTest();
|
||||
});
|
||||
|
||||
it("returns a resolved user for usernames", async () => {
|
||||
@@ -102,6 +107,33 @@ describe("resolveDiscordTarget", () => {
|
||||
).resolves.toMatchObject({ kind: "user", id: "123" });
|
||||
expect(listPeers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("caches username lookups under the configured default account when accountId is omitted", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
discord: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: {
|
||||
token: "discord-work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
vi.spyOn(directoryLive, "listDiscordDirectoryPeersLive").mockResolvedValueOnce([
|
||||
{ kind: "user", id: "user:999", name: "Jane" } as const,
|
||||
]);
|
||||
|
||||
await expect(resolveDiscordTarget("jane", { cfg })).resolves.toMatchObject({
|
||||
kind: "user",
|
||||
id: "999",
|
||||
normalized: "user:999",
|
||||
});
|
||||
expect(resolveDiscordDirectoryUserId({ accountId: "work", handle: "jane" })).toBe("999");
|
||||
expect(resolveDiscordDirectoryUserId({ accountId: "default", handle: "jane" })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeDiscordMessagingTarget", () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type MessagingTargetParseOptions,
|
||||
} from "openclaw/plugin-sdk/channel-targets";
|
||||
import type { DirectoryConfigParams } from "openclaw/plugin-sdk/directory-runtime";
|
||||
import { resolveDiscordAccount } from "./accounts.js";
|
||||
import { rememberDiscordDirectoryUser } from "./directory-cache.js";
|
||||
import { listDiscordDirectoryPeersLive } from "./directory-live.js";
|
||||
|
||||
@@ -100,8 +101,12 @@ export async function resolveDiscordTarget(
|
||||
if (match && match.kind === "user") {
|
||||
// Extract user ID from the directory entry (format: "user:<id>")
|
||||
const userId = match.id.replace(/^user:/, "");
|
||||
rememberDiscordDirectoryUser({
|
||||
const resolvedAccountId = resolveDiscordAccount({
|
||||
cfg: options.cfg,
|
||||
accountId: options.accountId,
|
||||
}).accountId;
|
||||
rememberDiscordDirectoryUser({
|
||||
accountId: resolvedAccountId,
|
||||
userId,
|
||||
handles: [trimmed, match.name, match.handle],
|
||||
});
|
||||
|
||||
@@ -1,32 +1,54 @@
|
||||
import { vi } from "vitest";
|
||||
import { vi, type Mock } from "vitest";
|
||||
import { parsePluginBindingApprovalCustomId } from "../../../../src/plugins/conversation-binding.js";
|
||||
import { resolvePinnedMainDmOwnerFromAllowlist } from "../../../../src/security/dm-policy-shared.js";
|
||||
|
||||
const runtimeMocks = vi.hoisted(() => ({
|
||||
buildPluginBindingResolvedTextMock: vi.fn(),
|
||||
dispatchPluginInteractiveHandlerMock: vi.fn(),
|
||||
dispatchReplyMock: vi.fn(),
|
||||
enqueueSystemEventMock: vi.fn(),
|
||||
readAllowFromStoreMock: vi.fn(),
|
||||
readSessionUpdatedAtMock: vi.fn(),
|
||||
recordInboundSessionMock: vi.fn(),
|
||||
resolveStorePathMock: vi.fn(),
|
||||
resolvePluginConversationBindingApprovalMock: vi.fn(),
|
||||
upsertPairingRequestMock: vi.fn(),
|
||||
}));
|
||||
type UnknownMock = Mock<(...args: unknown[]) => unknown>;
|
||||
type AsyncUnknownMock = Mock<(...args: unknown[]) => Promise<unknown>>;
|
||||
type DispatchReplyWithBufferedBlockDispatcherFn =
|
||||
typeof import("openclaw/plugin-sdk/reply-dispatch-runtime").dispatchReplyWithBufferedBlockDispatcher;
|
||||
type DispatchReplyMock = Mock<DispatchReplyWithBufferedBlockDispatcherFn>;
|
||||
|
||||
export const readAllowFromStoreMock = runtimeMocks.readAllowFromStoreMock;
|
||||
export const dispatchPluginInteractiveHandlerMock =
|
||||
type DiscordComponentRuntimeMocks = {
|
||||
buildPluginBindingResolvedTextMock: UnknownMock;
|
||||
dispatchPluginInteractiveHandlerMock: AsyncUnknownMock;
|
||||
dispatchReplyMock: DispatchReplyMock;
|
||||
enqueueSystemEventMock: UnknownMock;
|
||||
readAllowFromStoreMock: AsyncUnknownMock;
|
||||
readSessionUpdatedAtMock: UnknownMock;
|
||||
recordInboundSessionMock: AsyncUnknownMock;
|
||||
resolveStorePathMock: UnknownMock;
|
||||
resolvePluginConversationBindingApprovalMock: AsyncUnknownMock;
|
||||
upsertPairingRequestMock: AsyncUnknownMock;
|
||||
};
|
||||
|
||||
const runtimeMocks = vi.hoisted(
|
||||
(): DiscordComponentRuntimeMocks => ({
|
||||
buildPluginBindingResolvedTextMock: vi.fn(),
|
||||
dispatchPluginInteractiveHandlerMock: vi.fn(),
|
||||
dispatchReplyMock: vi.fn<DispatchReplyWithBufferedBlockDispatcherFn>(),
|
||||
enqueueSystemEventMock: vi.fn(),
|
||||
readAllowFromStoreMock: vi.fn(),
|
||||
readSessionUpdatedAtMock: vi.fn(),
|
||||
recordInboundSessionMock: vi.fn(),
|
||||
resolveStorePathMock: vi.fn(),
|
||||
resolvePluginConversationBindingApprovalMock: vi.fn(),
|
||||
upsertPairingRequestMock: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
export const readAllowFromStoreMock: AsyncUnknownMock = runtimeMocks.readAllowFromStoreMock;
|
||||
export const dispatchPluginInteractiveHandlerMock: AsyncUnknownMock =
|
||||
runtimeMocks.dispatchPluginInteractiveHandlerMock;
|
||||
export const dispatchReplyMock = runtimeMocks.dispatchReplyMock;
|
||||
export const enqueueSystemEventMock = runtimeMocks.enqueueSystemEventMock;
|
||||
export const upsertPairingRequestMock = runtimeMocks.upsertPairingRequestMock;
|
||||
export const recordInboundSessionMock = runtimeMocks.recordInboundSessionMock;
|
||||
export const readSessionUpdatedAtMock = runtimeMocks.readSessionUpdatedAtMock;
|
||||
export const resolveStorePathMock = runtimeMocks.resolveStorePathMock;
|
||||
export const resolvePluginConversationBindingApprovalMock =
|
||||
export const dispatchReplyMock: DispatchReplyMock = runtimeMocks.dispatchReplyMock;
|
||||
export const enqueueSystemEventMock: UnknownMock = runtimeMocks.enqueueSystemEventMock;
|
||||
export const upsertPairingRequestMock: AsyncUnknownMock = runtimeMocks.upsertPairingRequestMock;
|
||||
export const recordInboundSessionMock: AsyncUnknownMock = runtimeMocks.recordInboundSessionMock;
|
||||
export const readSessionUpdatedAtMock: UnknownMock = runtimeMocks.readSessionUpdatedAtMock;
|
||||
export const resolveStorePathMock: UnknownMock = runtimeMocks.resolveStorePathMock;
|
||||
export const resolvePluginConversationBindingApprovalMock: AsyncUnknownMock =
|
||||
runtimeMocks.resolvePluginConversationBindingApprovalMock;
|
||||
export const buildPluginBindingResolvedTextMock = runtimeMocks.buildPluginBindingResolvedTextMock;
|
||||
export const buildPluginBindingResolvedTextMock: UnknownMock =
|
||||
runtimeMocks.buildPluginBindingResolvedTextMock;
|
||||
|
||||
async function readStoreAllowFromForDmPolicy(params: {
|
||||
provider: string;
|
||||
@@ -85,7 +107,7 @@ vi.mock("../monitor/agent-components.runtime.js", () => {
|
||||
),
|
||||
dispatchPluginInteractiveHandler: (...args: unknown[]) =>
|
||||
dispatchPluginInteractiveHandlerMock(...args),
|
||||
dispatchReplyWithBufferedBlockDispatcher: (...args: unknown[]) => dispatchReplyMock(...args),
|
||||
dispatchReplyWithBufferedBlockDispatcher: dispatchReplyMock,
|
||||
finalizeInboundContext: vi.fn((ctx) => ctx),
|
||||
parsePluginBindingApprovalCustomId,
|
||||
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
|
||||
@@ -96,6 +118,13 @@ vi.mock("../monitor/agent-components.runtime.js", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../interactive-dispatch.js", () => {
|
||||
return {
|
||||
dispatchDiscordPluginInteractiveHandler: (...args: unknown[]) =>
|
||||
dispatchPluginInteractiveHandlerMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../monitor/agent-components.deps.runtime.js", () => {
|
||||
return {
|
||||
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
|
||||
@@ -104,6 +133,17 @@ vi.mock("../monitor/agent-components.deps.runtime.js", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../interactive-dispatch.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../interactive-dispatch.js")>(
|
||||
"../interactive-dispatch.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
dispatchDiscordPluginInteractiveHandler: (...args: unknown[]) =>
|
||||
dispatchPluginInteractiveHandlerMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
export function resetDiscordComponentRuntimeMocks() {
|
||||
dispatchPluginInteractiveHandlerMock.mockReset().mockResolvedValue({
|
||||
matched: false,
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
export async function createConfiguredBindingConversationRuntimeModuleMock(
|
||||
type ConfiguredBindingConversationRuntimeModule = {
|
||||
ensureConfiguredBindingRouteReady: (...args: never[]) => unknown;
|
||||
resolveConfiguredBindingRoute: (...args: never[]) => unknown;
|
||||
};
|
||||
|
||||
export async function createConfiguredBindingConversationRuntimeModuleMock<
|
||||
TModule extends ConfiguredBindingConversationRuntimeModule,
|
||||
>(
|
||||
params: {
|
||||
ensureConfiguredBindingRouteReadyMock: (...args: unknown[]) => unknown;
|
||||
resolveConfiguredBindingRouteMock: (...args: unknown[]) => unknown;
|
||||
ensureConfiguredBindingRouteReadyMock: (
|
||||
...args: Parameters<TModule["ensureConfiguredBindingRouteReady"]>
|
||||
) => ReturnType<TModule["ensureConfiguredBindingRouteReady"]>;
|
||||
resolveConfiguredBindingRouteMock: (
|
||||
...args: Parameters<TModule["resolveConfiguredBindingRoute"]>
|
||||
) => ReturnType<TModule["resolveConfiguredBindingRoute"]>;
|
||||
},
|
||||
importOriginal: () => Promise<{
|
||||
ensureConfiguredBindingRouteReady: (...args: unknown[]) => unknown;
|
||||
resolveConfiguredBindingRoute: (...args: unknown[]) => unknown;
|
||||
}>,
|
||||
importOriginal: () => Promise<TModule>,
|
||||
) {
|
||||
const actual = await importOriginal();
|
||||
return {
|
||||
...actual,
|
||||
ensureConfiguredBindingRouteReady: (...args: unknown[]) =>
|
||||
params.ensureConfiguredBindingRouteReadyMock(...args),
|
||||
resolveConfiguredBindingRoute: (...args: unknown[]) =>
|
||||
params.resolveConfiguredBindingRouteMock(...args),
|
||||
};
|
||||
ensureConfiguredBindingRouteReady: (
|
||||
...args: Parameters<TModule["ensureConfiguredBindingRouteReady"]>
|
||||
) => params.ensureConfiguredBindingRouteReadyMock(...args),
|
||||
resolveConfiguredBindingRoute: (
|
||||
...args: Parameters<TModule["resolveConfiguredBindingRoute"]>
|
||||
) => params.resolveConfiguredBindingRouteMock(...args),
|
||||
} satisfies TModule;
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const runFfprobeMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<string>>());
|
||||
const runFfmpegMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<void>>());
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/temp-path", async (importOriginal) => {
|
||||
vi.mock("openclaw/plugin-sdk/temp-path", async () => {
|
||||
return {
|
||||
resolvePreferredOpenClawTmpDir: () => "/tmp",
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => {
|
||||
vi.mock("openclaw/plugin-sdk/media-runtime", async () => {
|
||||
return {
|
||||
runFfprobe: runFfprobeMock,
|
||||
runFfmpeg: runFfmpegMock,
|
||||
|
||||
@@ -87,16 +87,20 @@ vi.mock("./sdk-runtime.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/routing", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/routing")>();
|
||||
vi.mock("openclaw/plugin-sdk/routing", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/routing")>(
|
||||
"openclaw/plugin-sdk/routing",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
resolveAgentRoute: resolveAgentRouteMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/agent-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/agent-runtime")>();
|
||||
vi.mock("openclaw/plugin-sdk/agent-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/agent-runtime")>(
|
||||
"openclaw/plugin-sdk/agent-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
agentCommandFromIngress: agentCommandMock,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { discordPlugin } from "./src/channel.js";
|
||||
export { buildFinalizedDiscordDirectInboundContext } from "./src/monitor/inbound-context.test-helpers.js";
|
||||
export { __testing as discordThreadBindingTesting } from "./src/monitor/thread-bindings.manager.js";
|
||||
export { discordOutbound } from "./src/outbound-adapter.js";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { feishuPlugin } from "./src/channel.js";
|
||||
export * from "./src/conversation-id.js";
|
||||
export * from "./src/setup-core.js";
|
||||
export * from "./src/setup-surface.js";
|
||||
|
||||
@@ -289,8 +289,10 @@ vi.mock("./client.js", () => ({
|
||||
createFeishuClient: mockCreateFeishuClient,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
|
||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/conversation-runtime")>(
|
||||
"openclaw/plugin-sdk/conversation-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
resolveConfiguredBindingRoute: (params: unknown) =>
|
||||
|
||||
@@ -565,6 +565,9 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
|
||||
groups: {
|
||||
resolveToolPolicy: resolveFeishuGroupToolPolicy,
|
||||
},
|
||||
conversationBindings: {
|
||||
defaultTopLevelPlacement: "current",
|
||||
},
|
||||
mentions: {
|
||||
stripPatterns: () => ['<at user_id="[^"]*">[^<]*</at>'],
|
||||
},
|
||||
|
||||
@@ -1,31 +1,77 @@
|
||||
import { vi } from "vitest";
|
||||
import { vi, type Mock } from "vitest";
|
||||
|
||||
type BoundConversation = {
|
||||
bindingId: string;
|
||||
targetSessionKey: string;
|
||||
};
|
||||
type UnknownMock = Mock<(...args: unknown[]) => unknown>;
|
||||
type AsyncUnknownMock = Mock<(...args: unknown[]) => Promise<unknown>>;
|
||||
type FinalizeInboundContextMock = Mock<
|
||||
(ctx: Record<string, unknown>, opts?: unknown) => Record<string, unknown>
|
||||
>;
|
||||
type DispatchReplyCounts = {
|
||||
final: number;
|
||||
block?: number;
|
||||
tool?: number;
|
||||
};
|
||||
type DispatchReplyContext = Record<string, unknown> & {
|
||||
SessionKey?: string;
|
||||
};
|
||||
type DispatchReplyDispatcher = {
|
||||
sendFinalReply: (payload: { text: string }) => unknown | Promise<unknown>;
|
||||
};
|
||||
type DispatchReplyFromConfigMock = Mock<
|
||||
(params: {
|
||||
ctx: DispatchReplyContext;
|
||||
dispatcher: DispatchReplyDispatcher;
|
||||
}) => Promise<{ queuedFinal: boolean; counts: DispatchReplyCounts }>
|
||||
>;
|
||||
type WithReplyDispatcherMock = Mock<
|
||||
(params: { run: () => unknown | Promise<unknown> }) => Promise<unknown>
|
||||
>;
|
||||
type FeishuLifecycleTestMocks = {
|
||||
createEventDispatcherMock: UnknownMock;
|
||||
monitorWebSocketMock: AsyncUnknownMock;
|
||||
monitorWebhookMock: AsyncUnknownMock;
|
||||
createFeishuThreadBindingManagerMock: UnknownMock;
|
||||
createFeishuReplyDispatcherMock: UnknownMock;
|
||||
resolveBoundConversationMock: Mock<() => BoundConversation | null>;
|
||||
touchBindingMock: UnknownMock;
|
||||
resolveAgentRouteMock: UnknownMock;
|
||||
resolveConfiguredBindingRouteMock: UnknownMock;
|
||||
ensureConfiguredBindingRouteReadyMock: UnknownMock;
|
||||
dispatchReplyFromConfigMock: DispatchReplyFromConfigMock;
|
||||
withReplyDispatcherMock: WithReplyDispatcherMock;
|
||||
finalizeInboundContextMock: FinalizeInboundContextMock;
|
||||
getMessageFeishuMock: AsyncUnknownMock;
|
||||
listFeishuThreadMessagesMock: AsyncUnknownMock;
|
||||
sendMessageFeishuMock: AsyncUnknownMock;
|
||||
sendCardFeishuMock: AsyncUnknownMock;
|
||||
};
|
||||
|
||||
const feishuLifecycleTestMocks = vi.hoisted(() => ({
|
||||
createEventDispatcherMock: vi.fn(),
|
||||
monitorWebSocketMock: vi.fn(async () => {}),
|
||||
monitorWebhookMock: vi.fn(async () => {}),
|
||||
createFeishuThreadBindingManagerMock: vi.fn(() => ({ stop: vi.fn() })),
|
||||
createFeishuReplyDispatcherMock: vi.fn(),
|
||||
resolveBoundConversationMock: vi.fn<() => BoundConversation | null>(() => null),
|
||||
touchBindingMock: vi.fn(),
|
||||
resolveAgentRouteMock: vi.fn(),
|
||||
resolveConfiguredBindingRouteMock: vi.fn(),
|
||||
ensureConfiguredBindingRouteReadyMock: vi.fn(),
|
||||
dispatchReplyFromConfigMock: vi.fn(),
|
||||
withReplyDispatcherMock: vi.fn(),
|
||||
finalizeInboundContextMock: vi.fn((ctx) => ctx),
|
||||
getMessageFeishuMock: vi.fn(async () => null),
|
||||
listFeishuThreadMessagesMock: vi.fn(async () => []),
|
||||
sendMessageFeishuMock: vi.fn(async () => ({ messageId: "om_sent", chatId: "chat_default" })),
|
||||
sendCardFeishuMock: vi.fn(async () => ({ messageId: "om_card", chatId: "chat_default" })),
|
||||
}));
|
||||
const feishuLifecycleTestMocks = vi.hoisted(
|
||||
(): FeishuLifecycleTestMocks => ({
|
||||
createEventDispatcherMock: vi.fn(),
|
||||
monitorWebSocketMock: vi.fn(async () => {}),
|
||||
monitorWebhookMock: vi.fn(async () => {}),
|
||||
createFeishuThreadBindingManagerMock: vi.fn(() => ({ stop: vi.fn() })),
|
||||
createFeishuReplyDispatcherMock: vi.fn(),
|
||||
resolveBoundConversationMock: vi.fn<() => BoundConversation | null>(() => null),
|
||||
touchBindingMock: vi.fn(),
|
||||
resolveAgentRouteMock: vi.fn(),
|
||||
resolveConfiguredBindingRouteMock: vi.fn(),
|
||||
ensureConfiguredBindingRouteReadyMock: vi.fn(),
|
||||
dispatchReplyFromConfigMock: vi.fn(),
|
||||
withReplyDispatcherMock: vi.fn(),
|
||||
finalizeInboundContextMock: vi.fn((ctx) => ctx),
|
||||
getMessageFeishuMock: vi.fn(async () => null),
|
||||
listFeishuThreadMessagesMock: vi.fn(async () => []),
|
||||
sendMessageFeishuMock: vi.fn(async () => ({ messageId: "om_sent", chatId: "chat_default" })),
|
||||
sendCardFeishuMock: vi.fn(async () => ({ messageId: "om_card", chatId: "chat_default" })),
|
||||
}),
|
||||
);
|
||||
|
||||
export function getFeishuLifecycleTestMocks() {
|
||||
export function getFeishuLifecycleTestMocks(): FeishuLifecycleTestMocks {
|
||||
return feishuLifecycleTestMocks;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type ChannelSetupAdapter,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import { resolveDefaultFeishuAccountId } from "./accounts.js";
|
||||
import type { FeishuConfig } from "./types.js";
|
||||
|
||||
export function setFeishuNamedAccountEnabled(
|
||||
@@ -30,7 +31,7 @@ export function setFeishuNamedAccountEnabled(
|
||||
}
|
||||
|
||||
export const feishuSetupAdapter: ChannelSetupAdapter = {
|
||||
resolveAccountId: ({ accountId }) => accountId?.trim() || DEFAULT_ACCOUNT_ID,
|
||||
resolveAccountId: ({ cfg, accountId }) => accountId?.trim() || resolveDefaultFeishuAccountId(cfg),
|
||||
applyAccountConfig: ({ cfg, accountId }) => {
|
||||
const isDefault = !accountId || accountId === DEFAULT_ACCOUNT_ID;
|
||||
if (isDefault) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
createPluginSetupWizardStatus,
|
||||
createTestWizardPrompter,
|
||||
runSetupWizardConfigure,
|
||||
runSetupWizardFinalize,
|
||||
type WizardPrompter,
|
||||
} from "../../../test/helpers/plugins/setup-wizard.js";
|
||||
|
||||
@@ -71,6 +72,28 @@ describe("feishu setup wizard", () => {
|
||||
).toBe("work");
|
||||
});
|
||||
|
||||
it("setup adapter uses configured defaultAccount when accountId is omitted", () => {
|
||||
expect(
|
||||
feishuPlugin.setup?.resolveAccountId?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
feishu: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: {
|
||||
appId: "work-app",
|
||||
appSecret: "work-secret", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
accountId: undefined,
|
||||
input: {},
|
||||
} as never),
|
||||
).toBe("work");
|
||||
});
|
||||
|
||||
it("does not throw when config appId/appSecret are SecretRef objects", async () => {
|
||||
const text = vi
|
||||
.fn()
|
||||
@@ -151,6 +174,81 @@ describe("feishu setup wizard", () => {
|
||||
appSecret: "work-secret",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount for omitted finalize writes", async () => {
|
||||
const prompter = createTestWizardPrompter({
|
||||
text: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Enter Feishu App Secret") {
|
||||
return "work-secret"; // pragma: allowlist secret
|
||||
}
|
||||
if (message === "Enter Feishu App ID") {
|
||||
return "work-app";
|
||||
}
|
||||
if (message === "Feishu webhook path") {
|
||||
return "/feishu/events";
|
||||
}
|
||||
if (message === "Group chat allowlist (chat_ids)") {
|
||||
return "";
|
||||
}
|
||||
throw new Error(`Unexpected prompt: ${message}`);
|
||||
}) as WizardPrompter["text"],
|
||||
select: vi.fn(
|
||||
async ({ message, initialValue }: { message: string; initialValue?: string }) => {
|
||||
if (message === "Feishu connection mode") {
|
||||
return initialValue ?? "websocket";
|
||||
}
|
||||
if (message === "Which Feishu domain?") {
|
||||
return initialValue ?? "feishu";
|
||||
}
|
||||
if (message === "Group chat policy") {
|
||||
return "disabled";
|
||||
}
|
||||
return initialValue ?? "websocket";
|
||||
},
|
||||
) as never,
|
||||
note: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
const setupWizard = feishuPlugin.setupWizard;
|
||||
if (!setupWizard || !("finalize" in setupWizard) || !setupWizard.finalize) {
|
||||
throw new Error("feishu setupWizard.finalize unavailable");
|
||||
}
|
||||
|
||||
const result = await setupWizard.finalize({
|
||||
cfg: {
|
||||
channels: {
|
||||
feishu: {
|
||||
appId: "top-level-app",
|
||||
appSecret: "top-level-secret", // pragma: allowlist secret
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: {
|
||||
appId: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
accountId: "work",
|
||||
credentialValues: {},
|
||||
forceAllowFrom: false,
|
||||
prompter,
|
||||
runtime: createNonExitingTypedRuntimeEnv<FeishuConfigureRuntime>(),
|
||||
options: {},
|
||||
});
|
||||
|
||||
expect(result && typeof result === "object" && "cfg" in result).toBe(true);
|
||||
const nextCfg =
|
||||
result && typeof result === "object" && "cfg" in result ? result.cfg : undefined;
|
||||
expect(nextCfg?.channels?.feishu).toBeDefined();
|
||||
expect(nextCfg?.channels?.feishu?.appId).toBe("top-level-app");
|
||||
expect(nextCfg?.channels?.feishu?.appSecret).toBe("top-level-secret");
|
||||
expect(nextCfg?.channels?.feishu?.accounts?.work).toMatchObject({
|
||||
enabled: true,
|
||||
appId: "work-app",
|
||||
appSecret: "work-secret",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("feishu setup wizard status", () => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
formatDocsLink,
|
||||
hasConfiguredSecretInput,
|
||||
mergeAllowFromEntries,
|
||||
patchChannelConfigForAccount,
|
||||
patchTopLevelChannelConfigSection,
|
||||
promptSingleChannelSecretInput,
|
||||
splitSetupEntries,
|
||||
@@ -33,15 +32,14 @@ function normalizeString(value: unknown): string | undefined {
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function getScopedFeishuConfig(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
): FeishuConfig | FeishuAccountConfig {
|
||||
type ScopedFeishuConfig = Partial<FeishuConfig> & Partial<FeishuAccountConfig>;
|
||||
|
||||
function getScopedFeishuConfig(cfg: OpenClawConfig, accountId: string): ScopedFeishuConfig {
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return feishuCfg ?? {};
|
||||
}
|
||||
return (feishuCfg.accounts?.[accountId] as FeishuAccountConfig | undefined) ?? {};
|
||||
return (feishuCfg?.accounts?.[accountId] as FeishuAccountConfig | undefined) ?? {};
|
||||
}
|
||||
|
||||
function patchFeishuConfig(
|
||||
@@ -49,11 +47,30 @@ function patchFeishuConfig(
|
||||
accountId: string,
|
||||
patch: Record<string, unknown>,
|
||||
): OpenClawConfig {
|
||||
return patchChannelConfigForAccount({
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return patchTopLevelChannelConfigSection({
|
||||
cfg,
|
||||
channel,
|
||||
enabled: true,
|
||||
patch,
|
||||
});
|
||||
}
|
||||
const nextAccountPatch = {
|
||||
...((feishuCfg?.accounts?.[accountId] as Record<string, unknown> | undefined) ?? {}),
|
||||
enabled: true,
|
||||
...patch,
|
||||
};
|
||||
return patchTopLevelChannelConfigSection({
|
||||
cfg,
|
||||
channel,
|
||||
accountId,
|
||||
patch,
|
||||
enabled: true,
|
||||
patch: {
|
||||
accounts: {
|
||||
...(feishuCfg?.accounts ?? {}),
|
||||
[accountId]: nextAccountPatch,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -82,7 +99,7 @@ function setFeishuGroupAllowFrom(
|
||||
}
|
||||
|
||||
function isFeishuConfigured(cfg: OpenClawConfig): boolean {
|
||||
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
const feishuCfg = ((cfg.channels?.feishu as FeishuConfig | undefined) ?? {}) as FeishuConfig;
|
||||
|
||||
const isAppIdConfigured = (value: unknown): boolean => {
|
||||
const asString = normalizeString(value);
|
||||
@@ -105,7 +122,7 @@ function isFeishuConfigured(cfg: OpenClawConfig): boolean {
|
||||
isAppIdConfigured(feishuCfg?.appId) && hasConfiguredSecretInput(feishuCfg?.appSecret),
|
||||
);
|
||||
|
||||
const accountConfigured = Object.values(feishuCfg?.accounts ?? {}).some((account) => {
|
||||
const accountConfigured = Object.values(feishuCfg.accounts ?? {}).some((account) => {
|
||||
if (!account || typeof account !== "object") {
|
||||
return false;
|
||||
}
|
||||
@@ -275,7 +292,7 @@ export const feishuSetupWizard: ChannelSetupWizard = {
|
||||
},
|
||||
credentials: [],
|
||||
finalize: async ({ cfg, accountId, prompter, options }) => {
|
||||
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
|
||||
const resolvedAccountId = accountId ?? resolveDefaultFeishuAccountId(cfg);
|
||||
const resolvedAccount = resolveFeishuAccount({ cfg, accountId: resolvedAccountId });
|
||||
const scopedConfig = getScopedFeishuConfig(cfg, resolvedAccountId);
|
||||
const resolved =
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expect, vi } from "vitest";
|
||||
import { expect, vi, type Mock } from "vitest";
|
||||
import { createPluginRuntimeMock } from "../../../../test/helpers/plugins/plugin-runtime-mock.js";
|
||||
import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "../../runtime-api.js";
|
||||
import { setFeishuRuntime } from "../runtime.js";
|
||||
@@ -9,6 +9,37 @@ type InboundDebouncerParams<T> = {
|
||||
onFlush?: (items: T[]) => Promise<void>;
|
||||
onError?: (err: unknown, items: T[]) => void;
|
||||
};
|
||||
type UnknownMock = Mock<(...args: unknown[]) => unknown>;
|
||||
type AsyncUnknownMock = Mock<(...args: unknown[]) => Promise<unknown>>;
|
||||
type FeishuDispatchReplyCounts = {
|
||||
final: number;
|
||||
block?: number;
|
||||
tool?: number;
|
||||
};
|
||||
type FeishuDispatchReplyContext = Record<string, unknown> & {
|
||||
SessionKey?: string;
|
||||
};
|
||||
type FeishuDispatchReplyDispatcher = {
|
||||
sendFinalReply: (payload: { text: string }) => unknown | Promise<unknown>;
|
||||
};
|
||||
type FeishuDispatchReplyMock = Mock<
|
||||
(args: {
|
||||
ctx: FeishuDispatchReplyContext;
|
||||
dispatcher: FeishuDispatchReplyDispatcher;
|
||||
}) => Promise<{ queuedFinal: boolean; counts: FeishuDispatchReplyCounts }>
|
||||
>;
|
||||
type FeishuLifecycleReplyDispatcher = {
|
||||
dispatcher: {
|
||||
sendToolResult: UnknownMock;
|
||||
sendBlockReply: UnknownMock;
|
||||
sendFinalReply: AsyncUnknownMock;
|
||||
waitForIdle: AsyncUnknownMock;
|
||||
getQueuedCounts: UnknownMock;
|
||||
markComplete: UnknownMock;
|
||||
};
|
||||
replyOptions: Record<string, never>;
|
||||
markDispatchIdle: UnknownMock;
|
||||
};
|
||||
|
||||
export function setFeishuLifecycleStateDir(prefix: string) {
|
||||
process.env.OPENCLAW_STATE_DIR = `/tmp/${prefix}-${randomUUID()}`;
|
||||
@@ -28,7 +59,7 @@ export const FEISHU_PREFETCHED_BOT_OPEN_ID_SOURCE = {
|
||||
botName: "Bot",
|
||||
} as const;
|
||||
|
||||
export function createFeishuLifecycleReplyDispatcher() {
|
||||
export function createFeishuLifecycleReplyDispatcher(): FeishuLifecycleReplyDispatcher {
|
||||
return {
|
||||
dispatcher: {
|
||||
sendToolResult: vi.fn(() => false),
|
||||
@@ -134,16 +165,7 @@ export function installFeishuLifecycleReplyRuntime(params: {
|
||||
}
|
||||
|
||||
export function mockFeishuReplyOnceDispatch(params: {
|
||||
dispatchReplyFromConfigMock: {
|
||||
mockImplementation: (
|
||||
fn: (args: {
|
||||
ctx?: unknown;
|
||||
dispatcher?: {
|
||||
sendFinalReply?: (payload: { text: string }) => Promise<unknown>;
|
||||
};
|
||||
}) => Promise<unknown>,
|
||||
) => void;
|
||||
};
|
||||
dispatchReplyFromConfigMock: FeishuDispatchReplyMock;
|
||||
replyText: string;
|
||||
shouldSendFinalReply?: (ctx: unknown) => boolean;
|
||||
}) {
|
||||
|
||||
@@ -81,7 +81,7 @@ describe("feishu tool account routing", () => {
|
||||
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b");
|
||||
});
|
||||
|
||||
test("wiki tool prefers configured defaultAccount over inherited default account context", async () => {
|
||||
test("wiki tool prefers the active contextual account over configured defaultAccount", async () => {
|
||||
const { api, resolveTool } = createToolFactoryHarness(
|
||||
createConfig({
|
||||
defaultAccount: "b",
|
||||
@@ -94,7 +94,7 @@ describe("feishu tool account routing", () => {
|
||||
const tool = resolveTool("feishu_wiki", { agentAccountId: "a" });
|
||||
await tool.execute("call", { action: "search" });
|
||||
|
||||
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b");
|
||||
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-a");
|
||||
});
|
||||
|
||||
test("drive tool registers when first account disables it and routes to agentAccountId", async () => {
|
||||
|
||||
44
extensions/feishu/src/tool-account.test.ts
Normal file
44
extensions/feishu/src/tool-account.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveFeishuToolAccount } from "./tool-account.js";
|
||||
|
||||
describe("resolveFeishuToolAccount", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
defaultAccount: "ops",
|
||||
appId: "base-app-id",
|
||||
appSecret: "base-app-secret", // pragma: allowlist secret
|
||||
accounts: {
|
||||
ops: {
|
||||
enabled: true,
|
||||
appId: "ops-app-id",
|
||||
appSecret: "ops-app-secret", // pragma: allowlist secret
|
||||
},
|
||||
work: {
|
||||
enabled: true,
|
||||
appId: "work-app-id",
|
||||
appSecret: "work-app-secret", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("prefers the active contextual account over configured defaultAccount", () => {
|
||||
const resolved = resolveFeishuToolAccount({
|
||||
api: { config: cfg },
|
||||
defaultAccountId: "work",
|
||||
});
|
||||
|
||||
expect(resolved.accountId).toBe("work");
|
||||
});
|
||||
|
||||
it("falls back to configured defaultAccount when there is no contextual account", () => {
|
||||
const resolved = resolveFeishuToolAccount({
|
||||
api: { config: cfg },
|
||||
});
|
||||
|
||||
expect(resolved.accountId).toBe("ops");
|
||||
});
|
||||
});
|
||||
@@ -35,25 +35,23 @@ function resolveImplicitToolAccountId(params: {
|
||||
return explicitAccountId;
|
||||
}
|
||||
|
||||
const contextualAccountId = normalizeOptionalAccountId(params.defaultAccountId);
|
||||
if (contextualAccountId && listFeishuAccountIds(params.api.config).includes(contextualAccountId)) {
|
||||
const contextualAccount = resolveFeishuAccount({
|
||||
cfg: params.api.config,
|
||||
accountId: contextualAccountId,
|
||||
});
|
||||
if (contextualAccount.enabled) {
|
||||
return contextualAccountId;
|
||||
}
|
||||
}
|
||||
|
||||
const configuredDefaultAccountId = readConfiguredDefaultAccountId(params.api.config);
|
||||
if (configuredDefaultAccountId) {
|
||||
return configuredDefaultAccountId;
|
||||
}
|
||||
|
||||
const contextualAccountId = normalizeOptionalAccountId(params.defaultAccountId);
|
||||
if (!contextualAccountId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!listFeishuAccountIds(params.api.config).includes(contextualAccountId)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const contextualAccount = resolveFeishuAccount({
|
||||
cfg: params.api.config,
|
||||
accountId: contextualAccountId,
|
||||
});
|
||||
return contextualAccount.enabled ? contextualAccountId : undefined;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveFeishuToolAccount(params: {
|
||||
|
||||
@@ -1,76 +1,13 @@
|
||||
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import { streamSimple } from "@mariozechner/pi-ai";
|
||||
import { streamWithPayloadPatch } from "openclaw/plugin-sdk/provider-stream";
|
||||
import {
|
||||
applyAnthropicEphemeralCacheControlMarkers,
|
||||
buildCopilotDynamicHeaders,
|
||||
hasCopilotVisionInput,
|
||||
streamWithPayloadPatch,
|
||||
} from "openclaw/plugin-sdk/provider-stream";
|
||||
|
||||
type StreamContext = Parameters<StreamFn>[1];
|
||||
type StreamMessage = StreamContext["messages"][number];
|
||||
|
||||
function inferCopilotInitiator(messages: StreamContext["messages"]): "agent" | "user" {
|
||||
const last = messages[messages.length - 1];
|
||||
return last && last.role !== "user" ? "agent" : "user";
|
||||
}
|
||||
|
||||
function hasCopilotVisionInput(messages: StreamContext["messages"]): boolean {
|
||||
return messages.some((message: StreamMessage) => {
|
||||
if (message.role === "user" && Array.isArray(message.content)) {
|
||||
return message.content.some((item) => item.type === "image");
|
||||
}
|
||||
if (message.role === "toolResult" && Array.isArray(message.content)) {
|
||||
return message.content.some((item) => item.type === "image");
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function buildCopilotDynamicHeaders(params: {
|
||||
messages: StreamContext["messages"];
|
||||
}): Record<string, string> {
|
||||
return {
|
||||
"X-Initiator": inferCopilotInitiator(params.messages),
|
||||
"Openai-Intent": "conversation-edits",
|
||||
...(hasCopilotVisionInput(params.messages) ? { "Copilot-Vision-Request": "true" } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function applyAnthropicPromptCacheMarkers(payloadObj: Record<string, unknown>): void {
|
||||
const messages = payloadObj.messages;
|
||||
if (!Array.isArray(messages)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const message of messages as Array<{ role?: string; content?: unknown }>) {
|
||||
if (message.role === "system" || message.role === "developer") {
|
||||
if (typeof message.content === "string") {
|
||||
message.content = [
|
||||
{ type: "text", text: message.content, cache_control: { type: "ephemeral" } },
|
||||
];
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(message.content) && message.content.length > 0) {
|
||||
const last = message.content[message.content.length - 1];
|
||||
if (last && typeof last === "object") {
|
||||
const record = last as Record<string, unknown>;
|
||||
if (record.type !== "thinking" && record.type !== "redacted_thinking") {
|
||||
record.cache_control = { type: "ephemeral" };
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === "assistant" && Array.isArray(message.content)) {
|
||||
for (const block of message.content) {
|
||||
if (!block || typeof block !== "object") {
|
||||
continue;
|
||||
}
|
||||
const record = block as Record<string, unknown>;
|
||||
if (record.type === "thinking" || record.type === "redacted_thinking") {
|
||||
delete record.cache_control;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function wrapCopilotAnthropicStream(baseStreamFn: StreamFn | undefined): StreamFn {
|
||||
const underlying = baseStreamFn ?? streamSimple;
|
||||
@@ -86,11 +23,14 @@ export function wrapCopilotAnthropicStream(baseStreamFn: StreamFn | undefined):
|
||||
{
|
||||
...options,
|
||||
headers: {
|
||||
...buildCopilotDynamicHeaders({ messages: context.messages }),
|
||||
...buildCopilotDynamicHeaders({
|
||||
messages: context.messages as StreamContext["messages"],
|
||||
hasImages: hasCopilotVisionInput(context.messages as StreamContext["messages"]),
|
||||
}),
|
||||
...(options?.headers ?? {}),
|
||||
},
|
||||
},
|
||||
applyAnthropicPromptCacheMarkers,
|
||||
applyAnthropicEphemeralCacheControlMarkers,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,7 +130,9 @@ export function resolveGoogleChatAccount(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedGoogleChatAccount {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const accountId = normalizeAccountId(
|
||||
params.accountId ?? params.cfg.channels?.["googlechat"]?.defaultAccount,
|
||||
);
|
||||
const baseEnabled = params.cfg.channels?.["googlechat"]?.enabled !== false;
|
||||
const merged = mergeGoogleChatAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
|
||||
@@ -32,8 +32,8 @@ vi.mock("./targets.js", () => ({
|
||||
resolveGoogleChatOutboundSpace,
|
||||
}));
|
||||
|
||||
vi.mock("../runtime-api.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../runtime-api.js")>();
|
||||
vi.mock("../runtime-api.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../runtime-api.js")>("../runtime-api.js");
|
||||
return {
|
||||
...actual,
|
||||
loadOutboundMediaFromUrl: (...args: Parameters<typeof actual.loadOutboundMediaFromUrl>) =>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/directory-runtime";
|
||||
import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared";
|
||||
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import { sanitizeForPlainText } from "openclaw/plugin-sdk/outbound-runtime";
|
||||
import {
|
||||
createComputedAccountStatusAdapter,
|
||||
createDefaultChannelRuntimeState,
|
||||
@@ -212,6 +213,12 @@ export const googlechatPlugin = createChatChannelPlugin({
|
||||
},
|
||||
},
|
||||
actions: googlechatActions,
|
||||
doctor: {
|
||||
dmAllowFromMode: "nestedOnly",
|
||||
groupModel: "route",
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
warnOnEmptyGroupSenderAllowlist: false,
|
||||
},
|
||||
status: createComputedAccountStatusAdapter<ResolvedGoogleChatAccount>({
|
||||
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
|
||||
collectStatusIssues: (accounts): ChannelStatusIssue[] =>
|
||||
@@ -355,6 +362,7 @@ export const googlechatPlugin = createChatChannelPlugin({
|
||||
chunker: chunkTextForOutbound,
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
sanitizeText: ({ text }) => sanitizeForPlainText(text),
|
||||
resolveTarget: ({ to }) => {
|
||||
const trimmed = to?.trim() ?? "";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
addWildcardAllowFrom,
|
||||
applySetupAccountConfigPatch,
|
||||
createNestedChannelParsedAllowFromPrompt,
|
||||
createPromptParsedAllowFromForAccount,
|
||||
createStandardChannelSetupStatus,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
formatDocsLink,
|
||||
@@ -25,16 +25,27 @@ const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE";
|
||||
const USE_ENV_FLAG = "__googlechatUseEnv";
|
||||
const AUTH_METHOD_FLAG = "__googlechatAuthMethod";
|
||||
|
||||
const promptAllowFrom = createNestedChannelParsedAllowFromPrompt({
|
||||
channel,
|
||||
section: "dm",
|
||||
defaultAccountId: DEFAULT_ACCOUNT_ID,
|
||||
enabled: true,
|
||||
const promptAllowFrom = createPromptParsedAllowFromForAccount({
|
||||
defaultAccountId: resolveDefaultGoogleChatAccountId,
|
||||
message: "Google Chat allowFrom (users/<id> or raw email; avoid users/<email>)",
|
||||
placeholder: "users/123456789, name@example.com",
|
||||
parseEntries: (raw) => ({
|
||||
entries: mergeAllowFromEntries(undefined, splitSetupEntries(raw)),
|
||||
}),
|
||||
getExistingAllowFrom: ({ cfg, accountId }) =>
|
||||
resolveGoogleChatAccount({ cfg, accountId }).config.dm?.allowFrom ?? [],
|
||||
applyAllowFrom: ({ cfg, accountId, allowFrom }) =>
|
||||
applySetupAccountConfigPatch({
|
||||
cfg,
|
||||
channelKey: channel,
|
||||
accountId,
|
||||
patch: {
|
||||
dm: {
|
||||
...(resolveGoogleChatAccount({ cfg, accountId }).config.dm ?? {}),
|
||||
allowFrom,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const googlechatDmPolicy: ChannelSetupDmPolicy = {
|
||||
@@ -93,13 +104,7 @@ export const googlechatSetupWizard: ChannelSetupWizard = {
|
||||
unconfiguredHint: "needs auth",
|
||||
includeStatusLine: true,
|
||||
resolveConfigured: ({ cfg, accountId }) =>
|
||||
accountId
|
||||
? resolveGoogleChatAccount({ cfg, accountId }).credentialSource !== "none"
|
||||
: listGoogleChatAccountIds(cfg).some(
|
||||
(resolvedAccountId) =>
|
||||
resolveGoogleChatAccount({ cfg, accountId: resolvedAccountId }).credentialSource !==
|
||||
"none",
|
||||
),
|
||||
resolveGoogleChatAccount({ cfg, accountId }).credentialSource !== "none",
|
||||
}),
|
||||
introNote: {
|
||||
title: "Google Chat setup",
|
||||
|
||||
@@ -211,6 +211,28 @@ describe("googlechat setup", () => {
|
||||
expect(status.configured).toBe(false);
|
||||
});
|
||||
|
||||
it("reports configured state for the configured defaultAccount instead of any account", async () => {
|
||||
const status = await googlechatStatus({
|
||||
cfg: {
|
||||
channels: {
|
||||
googlechat: {
|
||||
defaultAccount: "alerts",
|
||||
accounts: {
|
||||
default: {
|
||||
serviceAccount: { client_email: "default@example.com" },
|
||||
},
|
||||
alerts: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
accountOverrides: {},
|
||||
options: {},
|
||||
});
|
||||
|
||||
expect(status.configured).toBe(false);
|
||||
});
|
||||
|
||||
it("reports account-scoped config keys for named accounts", () => {
|
||||
expect(googlechatPlugin.setupWizard?.dmPolicy?.resolveConfigKeys?.({}, "alerts")).toEqual({
|
||||
policyKey: "channels.googlechat.accounts.alerts.dm.policy",
|
||||
@@ -249,6 +271,41 @@ describe("googlechat setup", () => {
|
||||
expect(next?.channels?.googlechat?.accounts?.alerts?.dm?.policy).toBe("open");
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount for omitted allowFrom prompt context", async () => {
|
||||
const prompter = {
|
||||
note: vi.fn(async () => {}),
|
||||
text: vi.fn(async () => "users/123456789"),
|
||||
};
|
||||
|
||||
const next = await googlechatPlugin.setupWizard?.dmPolicy?.promptAllowFrom?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
googlechat: {
|
||||
defaultAccount: "alerts",
|
||||
dm: {
|
||||
allowFrom: ["users/root"],
|
||||
},
|
||||
accounts: {
|
||||
alerts: {
|
||||
serviceAccount: { client_email: "bot@example.com" },
|
||||
dm: {
|
||||
allowFrom: ["users/alerts"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
prompter: prompter as any,
|
||||
});
|
||||
|
||||
expect(next?.channels?.googlechat?.dm?.allowFrom).toEqual(["users/root"]);
|
||||
expect(next?.channels?.googlechat?.accounts?.alerts?.dm?.allowFrom).toEqual([
|
||||
"users/123456789",
|
||||
]);
|
||||
});
|
||||
|
||||
it('writes open DM policy to the named account and preserves inherited allowFrom with "*"', () => {
|
||||
const next = googlechatPlugin.setupWizard?.dmPolicy?.setPolicy(
|
||||
{
|
||||
@@ -454,4 +511,24 @@ describe("resolveGoogleChatAccount", () => {
|
||||
expect(resolved.config.dangerouslyAllowNameMatching).toBeUndefined();
|
||||
expect(resolved.config.audienceType).toBe("app-url");
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount when accountId is omitted", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
defaultAccount: "alerts",
|
||||
accounts: {
|
||||
alerts: {
|
||||
serviceAccountFile: "/tmp/alerts-sa.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveGoogleChatAccount({ cfg });
|
||||
expect(resolved.accountId).toBe("alerts");
|
||||
expect(resolved.credentialSource).toBe("file");
|
||||
expect(resolved.credentialsFile).toBe("/tmp/alerts-sa.json");
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user