Compare commits

..

2 Commits

Author SHA1 Message Date
Agustin Rivera
5d815b216b fix(browser): skip trusted cdp dns gate 2026-04-03 18:21:25 +00:00
Agustin Rivera
6072e462a2 fix(browser): enforce profile cdp policy 2026-04-03 18:04:10 +00:00
740 changed files with 11004 additions and 18252 deletions

View File

@@ -85,7 +85,6 @@ 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
@@ -195,7 +194,6 @@ 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

View File

@@ -12279,7 +12279,7 @@
"advanced"
],
"label": "Internal Hooks Enabled",
"help": "Enables processing for internal hooks and configured entries in the internal hook runtime. Keep disabled unless internal hooks are intentionally configured.",
"help": "Enables processing for internal hook handlers and configured entries in the internal hook runtime. Keep disabled unless internal hook handlers are intentionally configured.",
"hasChildren": false
},
{
@@ -12345,6 +12345,72 @@
"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",

View File

@@ -12278,7 +12278,7 @@
"advanced"
],
"label": "Internal Hooks Enabled",
"help": "Enables processing for internal hooks and configured entries in the internal hook runtime. Keep disabled unless internal hooks are intentionally configured.",
"help": "Enables processing for internal hook handlers and configured entries in the internal hook runtime. Keep disabled unless internal hook handlers are intentionally configured.",
"hasChildren": false
},
{
@@ -12344,6 +12344,72 @@
"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

View File

@@ -654,7 +654,7 @@ Matrix can act as an exec approval client for a Matrix account.
- `channels.matrix.execApprovals.agentFilter`
- `channels.matrix.execApprovals.sessionFilter`
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.
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`.
Delivery rules:

View File

@@ -392,7 +392,7 @@ Notes:
## Manifest and scope checklist
<AccordionGroup>
<Accordion title="Slack app manifest example" defaultOpen>
<Accordion title="Slack app manifest example">
```json
{

View File

@@ -13,7 +13,6 @@ 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
@@ -32,11 +31,6 @@ 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

View File

@@ -111,8 +111,7 @@ See [Memory](/concepts/memory) for the workflow and automatic memory flush.
- `skills/` (optional)
- Workspace-specific skills.
- 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.
- Overrides managed/bundled skills when names collide.
- `canvas/` (optional)
- Canvas UI files for node displays (for example `canvas/index.html`).

View File

@@ -55,14 +55,11 @@ guidance for how _you_ want them used.
## Skills
OpenClaw loads skills from these locations (highest precedence first):
OpenClaw loads skills from three locations (workspace wins on name conflict):
- 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`
- Managed/local: `~/.openclaw/skills`
- Workspace: `<workspace>/skills`
Skills can be gated by config/env (see `skills` in [Gateway configuration](/gateway/configuration)).

View File

@@ -27,12 +27,8 @@ 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 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).
Skills are per-agent via each workspaces `skills/` folder, with shared skills
available from `~/.openclaw/skills`. See [Skills: per-agent vs shared](/tools/skills#per-agent-vs-shared-skills).
The Gateway can host **one agent** (default) or **many agents** side-by-side.

View File

@@ -110,10 +110,6 @@ 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>

View File

@@ -250,7 +250,6 @@ 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",
@@ -309,14 +308,12 @@ 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",
},
@@ -465,27 +462,6 @@ 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

View File

@@ -818,30 +818,6 @@ 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`).
@@ -1449,7 +1425,6 @@ 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",
@@ -1484,7 +1459,6 @@ 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.

View File

@@ -175,33 +175,6 @@ 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:

View File

@@ -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` → `<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.
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.
</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 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`.
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.
</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. 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.
Install the separate `clawhub` CLI only if you want to publish or sync your own skills.
</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`. 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).
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).
</Accordion>

View File

@@ -105,10 +105,8 @@ 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) | Low | Global |
| Bundled (shipped with OpenClaw) | Lowest | Global |
| `skills.load.extraDirs` | Lowest | Custom shared folders |
## Related

View File

@@ -8,9 +8,7 @@ title: "Skills Config"
# Skills Config
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`.
All skills-related configuration lives under `skills` in `~/.openclaw/openclaw.json`.
```json5
{
@@ -53,35 +51,6 @@ 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`,
@@ -96,10 +65,6 @@ Rules:
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:

View File

@@ -43,42 +43,6 @@ 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
@@ -303,10 +267,6 @@ 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`.

View File

@@ -1,10 +1,7 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import { streamSimple } from "@mariozechner/pi-ai";
import {
applyAnthropicPayloadPolicyToParams,
resolveAnthropicPayloadPolicy,
streamWithPayloadPatch,
} from "openclaw/plugin-sdk/provider-stream";
import { resolveProviderRequestCapabilities } from "openclaw/plugin-sdk/provider-http";
import { streamWithPayloadPatch } from "openclaw/plugin-sdk/provider-stream";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
const log = createSubsystemLogger("anthropic-stream");
@@ -55,6 +52,20 @@ 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";
}
@@ -150,19 +161,15 @@ export function createAnthropicFastModeWrapper(
const underlying = baseStreamFn ?? streamSimple;
const serviceTier = resolveAnthropicFastServiceTier(enabled);
return (model, context, options) => {
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) {
if (!allowsAnthropicServiceTier(model)) {
return underlying(model, context, options);
}
return streamWithPayloadPatch(underlying, model, context, options, (payloadObj) =>
applyAnthropicPayloadPolicyToParams(payloadObj, payloadPolicy),
);
return streamWithPayloadPatch(underlying, model, context, options, (payloadObj) => {
if (payloadObj.service_tier === undefined) {
payloadObj.service_tier = serviceTier;
}
});
};
}
@@ -172,19 +179,15 @@ export function createAnthropicServiceTierWrapper(
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
return (model, context, options) => {
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) {
if (!allowsAnthropicServiceTier(model)) {
return underlying(model, context, options);
}
return streamWithPayloadPatch(underlying, model, context, options, (payloadObj) =>
applyAnthropicPayloadPolicyToParams(payloadObj, payloadPolicy),
);
return streamWithPayloadPatch(underlying, model, context, options, (payloadObj) => {
if (payloadObj.service_tier === undefined) {
payloadObj.service_tier = serviceTier;
}
});
};
}

View File

@@ -41,9 +41,7 @@ export function resolveBlueBubblesAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): ResolvedBlueBubblesAccount {
const accountId = normalizeAccountId(
params.accountId ?? resolveDefaultBlueBubblesAccountId(params.cfg),
);
const accountId = normalizeAccountId(params.accountId);
const baseEnabled = params.cfg.channels?.bluebubbles?.enabled;
const merged = mergeBlueBubblesAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false;

View File

@@ -1,6 +1,6 @@
import { EventEmitter } from "node:events";
import type { IncomingMessage, ServerResponse } from "node:http";
import { expect, vi, type Mock } from "vitest";
import { expect, vi } from "vitest";
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
import { handleBlueBubblesWebhookRequest } from "./monitor.js";
import { registerBlueBubblesWebhookTarget } from "./monitor.js";
@@ -16,11 +16,6 @@ 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"]> = {},
@@ -187,7 +182,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";

View File

@@ -217,34 +217,6 @@ 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");

View File

@@ -14,8 +14,8 @@ const runtimeApiMocks = vi.hoisted(() => ({
registerBrowserCli: vi.fn(),
}));
vi.mock("./runtime-api.js", async () => {
const actual = await vi.importActual<typeof import("./runtime-api.js")>("./runtime-api.js");
vi.mock("./runtime-api.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./runtime-api.js")>();
return {
...actual,
createBrowserPluginService: runtimeApiMocks.createBrowserPluginService,

View File

@@ -115,10 +115,8 @@ vi.mock("../../../src/agents/tools/gateway.js", () => gatewayMocks);
const configMocks = vi.hoisted(() => ({
loadConfig: vi.fn(() => ({ browser: {} })),
}));
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",
);
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
return {
...actual,
loadConfig: configMocks.loadConfig,

View File

@@ -23,17 +23,27 @@ 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,
});

View File

@@ -1,32 +1,25 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("node:child_process", async () => {
vi.mock("node:child_process", async (importOriginal) => {
const { mockNodeBuiltinModule } = await import("../../../../test/helpers/node-builtin-mocks.js");
return mockNodeBuiltinModule(
() => vi.importActual<typeof import("node:child_process")>("node:child_process"),
{
execFileSync: vi.fn(),
},
);
return mockNodeBuiltinModule(importOriginal, {
execFileSync: vi.fn(),
});
});
vi.mock("node:fs", async () => {
vi.mock("node:fs", async (importOriginal) => {
const { mockNodeBuiltinModule } = await import("../../../../test/helpers/node-builtin-mocks.js");
const existsSync = vi.fn();
const readFileSync = vi.fn();
return mockNodeBuiltinModule(
() => vi.importActual<typeof import("node:fs")>("node:fs"),
importOriginal,
{ existsSync, readFileSync },
{ mirrorToDefault: true },
);
});
vi.mock("node:os", async () => {
vi.mock("node:os", async (importOriginal) => {
const { mockNodeBuiltinModule } = await import("../../../../test/helpers/node-builtin-mocks.js");
const homedir = vi.fn();
return mockNodeBuiltinModule(
() => vi.importActual<typeof import("node:os")>("node:os"),
{ homedir },
{ mirrorToDefault: true },
);
return mockNodeBuiltinModule(importOriginal, { homedir }, { mirrorToDefault: true });
});
import { execFileSync } from "node:child_process";
import * as fs from "node:fs";

View File

@@ -22,8 +22,8 @@ const mocks = vi.hoisted(() => ({
dispatch: vi.fn(async (): Promise<BrowserDispatchResponse> => okDispatchResponse()),
}));
vi.mock("../config/config.js", async () => {
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: mocks.loadConfig,

View File

@@ -17,8 +17,8 @@ const mocks = vi.hoisted(() => ({
})),
}));
vi.mock("../config/config.js", async () => {
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: mocks.loadConfig,

View File

@@ -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 () => {
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: vi.fn(),
@@ -141,6 +141,56 @@ 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);

View File

@@ -4,6 +4,7 @@ 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 {
@@ -124,6 +125,7 @@ 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));
}

View File

@@ -35,8 +35,8 @@ function buildConfig(): TestConfig {
};
}
vi.mock("../config/config.js", async () => {
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
createConfigIO: () => ({

View File

@@ -11,8 +11,8 @@ const mocks = vi.hoisted(() => ({
ensureExtensionRelayForProfiles: vi.fn(async () => {}),
}));
vi.mock("../config/config.js", async () => {
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
const browserConfig = {
enabled: true,
};
@@ -24,8 +24,8 @@ vi.mock("../config/config.js", async () => {
};
});
vi.mock("./config.js", async () => {
const actual = await vi.importActual<typeof import("./config.js")>("./config.js");
vi.mock("./config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./config.js")>();
return {
...actual,
resolveBrowserConfig: vi.fn(() => ({

View File

@@ -225,8 +225,8 @@ function defaultProfilesForState(testPort: number): HarnessState["cfgProfiles"]
};
}
vi.mock("../config/config.js", async () => {
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
const loadConfig = () => {
return {
browser: {

View File

@@ -35,8 +35,8 @@ const routeCtxMocks = vi.hoisted(() => {
};
});
vi.mock("../config/config.js", async () => {
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../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 () => {
const actual = await vi.importActual<typeof import("./server-context.js")>("./server-context.js");
vi.mock("./server-context.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./server-context.js")>();
return {
...actual,
createBrowserRouteContext: routeCtxMocks.createBrowserRouteContext,

View File

@@ -1,4 +1,3 @@
import type { Command } from "commander";
import { vi } from "vitest";
import * as parentCoreApiModule from "../core-api.js";
import * as browserCliSharedModule from "./browser-cli-shared.js";
@@ -57,7 +56,7 @@ vi.spyOn(cliCoreApiModule.defaultRuntime, "exit").mockImplementation(browserCliR
const { registerBrowserManageCommands } = await import("./browser-cli-manage.js");
export function createBrowserManageProgram(params?: { withParentTimeout?: boolean }): Command {
export function createBrowserManageProgram(params?: { withParentTimeout?: boolean }) {
const { program, browser, parentOpts } = createBrowserProgram();
if (params?.withParentTimeout) {
browser.option("--timeout <ms>", "Timeout in ms", "30000");

View File

@@ -8,10 +8,8 @@ const { loadConfigMock, isNodeCommandAllowedMock, resolveNodeCommandAllowlistMoc
}),
);
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",
);
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
return {
...actual,
loadConfig: loadConfigMock,

View File

@@ -6,17 +6,12 @@ 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,

View File

@@ -1,30 +1,7 @@
import { describe, expect, it } from "vitest";
import {
createDiscordActionGate,
resolveDiscordAccount,
resolveDiscordMaxLinesPerMessage,
} from "./accounts.js";
import { 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: {
@@ -80,29 +57,6 @@ 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({

View File

@@ -45,9 +45,7 @@ export function createDiscordActionGate(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): (key: keyof DiscordActionConfig, defaultValue?: boolean) => boolean {
const accountId = normalizeAccountId(
params.accountId ?? resolveDefaultDiscordAccountId(params.cfg),
);
const accountId = normalizeAccountId(params.accountId);
return createAccountActionGate({
baseActions: params.cfg.channels?.discord?.actions,
accountActions: resolveDiscordAccountConfig(params.cfg, accountId)?.actions,
@@ -58,9 +56,7 @@ export function resolveDiscordAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): ResolvedDiscordAccount {
const accountId = normalizeAccountId(
params.accountId ?? resolveDefaultDiscordAccountId(params.cfg),
);
const accountId = normalizeAccountId(params.accountId);
const baseEnabled = params.cfg.channels?.discord?.enabled !== false;
const merged = mergeDiscordAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false;

View File

@@ -1,5 +1,4 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { resolveDefaultDiscordAccountId } from "../accounts.js";
import { getPresence } from "../monitor/presence-cache.js";
import {
type ActionGate,
@@ -9,7 +8,6 @@ import {
readStringArrayParam,
readStringParam,
type DiscordActionConfig,
type OpenClawConfig,
} from "../runtime-api.js";
import {
addRoleDiscord,
@@ -94,7 +92,6 @@ 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) {
@@ -108,13 +105,10 @@ export async function handleDiscordGuildAction(
const userId = readStringParam(params, "userId", {
required: true,
});
const effectiveAccountId = accountId ?? (cfg ? resolveDefaultDiscordAccountId(cfg) : undefined);
const member = effectiveAccountId
? await discordGuildActionRuntime.fetchMemberInfoDiscord(guildId, userId, {
accountId: effectiveAccountId,
})
const member = accountId
? await discordGuildActionRuntime.fetchMemberInfoDiscord(guildId, userId, { accountId })
: await discordGuildActionRuntime.fetchMemberInfoDiscord(guildId, userId);
const presence = getPresence(effectiveAccountId, userId);
const presence = getPresence(accountId, userId);
const activities = presence?.activities ?? undefined;
const status = presence?.status ?? undefined;
return jsonResult({ ok: true, member, ...(presence ? { status, activities } : {}) });

View File

@@ -1,5 +1,4 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { resolveDefaultDiscordAccountId } from "../accounts.js";
import { createDiscordRuntimeAccountContext } from "../client.js";
import { readDiscordComponentSpec } from "../components.js";
import {
@@ -113,7 +112,7 @@ export async function handleDiscordMessagingAction(
const reactionRuntimeOptions = cfg
? createDiscordRuntimeAccountContext({
cfg,
accountId: accountId ?? resolveDefaultDiscordAccountId(cfg),
accountId: accountId ?? "default",
})
: accountId
? { accountId }

View File

@@ -1,7 +1,6 @@
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 {
@@ -89,7 +88,6 @@ const moderationEnabled = (key: keyof DiscordActionConfig) => key === "moderatio
beforeEach(() => {
vi.clearAllMocks();
clearPresences();
Object.assign(
discordMessagingActionRuntime,
originalDiscordMessagingActionRuntime,
@@ -133,36 +131,6 @@ 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",
@@ -489,50 +457,6 @@ 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;

View File

@@ -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, cfg);
return await handleDiscordGuildAction(action, params, isActionEnabled);
}
if (moderationActions.has(action)) {
return await handleDiscordModerationAction(action, params, isActionEnabled);

View File

@@ -67,7 +67,7 @@ describe("fetchDiscord", () => {
"/users/@me/guilds",
"test",
fetcher,
{ retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 } },
{ retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0 } },
);
expect(result).toHaveLength(1);

View File

@@ -1,5 +1,3 @@
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";
@@ -100,15 +98,6 @@ 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) {

View File

@@ -33,7 +33,6 @@ import {
type ResolvedDiscordAccount,
} from "./accounts.js";
import { getDiscordApprovalCapability } from "./approval-native.js";
import { discordMessageActions as discordMessageActionsImpl } from "./channel-actions.js";
import {
listDiscordDirectoryGroupsFromConfig,
listDiscordDirectoryPeersFromConfig,
@@ -44,11 +43,6 @@ import {
resolveDiscordGroupRequireMention,
resolveDiscordGroupToolPolicy,
} from "./group-policy.js";
import {
createThreadBindingManager,
setThreadBindingIdleTimeoutBySessionKey,
setThreadBindingMaxAgeBySessionKey,
} from "./monitor/thread-bindings.js";
import {
looksLikeDiscordTargetId,
normalizeDiscordMessagingTarget,
@@ -69,17 +63,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"]>;
@@ -88,7 +82,9 @@ 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);
@@ -112,6 +108,17 @@ 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;
@@ -145,13 +152,13 @@ const discordMessageActions = {
ctx: Parameters<NonNullable<ChannelMessageActionAdapter["describeMessageTool"]>>[0],
): ChannelMessageToolDiscovery | null =>
resolveRuntimeDiscordMessageActions()?.describeMessageTool?.(ctx) ??
discordMessageActionsImpl.describeMessageTool?.(ctx) ??
loadDiscordChannelActionsModule().discordMessageActions.describeMessageTool?.(ctx) ??
null,
extractToolSend: (
ctx: Parameters<NonNullable<ChannelMessageActionAdapter["extractToolSend"]>>[0],
) =>
resolveRuntimeDiscordMessageActions()?.extractToolSend?.(ctx) ??
discordMessageActionsImpl.extractToolSend?.(ctx) ??
loadDiscordChannelActionsModule().discordMessageActions.extractToolSend?.(ctx) ??
null,
handleAction: async (
ctx: Parameters<NonNullable<ChannelMessageActionAdapter["handleAction"]>>[0],
@@ -160,10 +167,11 @@ const discordMessageActions = {
if (runtimeHandleAction) {
return await runtimeHandleAction(ctx);
}
if (!discordMessageActionsImpl.handleAction) {
const { discordMessageActions } = loadDiscordChannelActionsModule();
if (!discordMessageActions.handleAction) {
throw new Error("Discord message actions not available");
}
return await discordMessageActionsImpl.handleAction(ctx);
return await discordMessageActions.handleAction(ctx);
},
};
@@ -213,6 +221,7 @@ 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) {
@@ -344,42 +353,6 @@ 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" });
@@ -428,8 +401,6 @@ 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}`),
@@ -520,29 +491,6 @@ 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,
@@ -770,7 +718,6 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
security: {
resolveDmPolicy: resolveDiscordDmPolicy,
collectWarnings: collectDiscordSecurityWarnings,
collectAuditFindings: collectDiscordSecurityAuditFindings,
},
threading: {
scopedAccountReplyToMode: {

View File

@@ -4,10 +4,8 @@ import { createDiscordRestClient } from "./client.js";
const makeProxyFetchMock = vi.hoisted(() => vi.fn());
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",
);
vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
makeProxyFetchMock.mockImplementation((proxyUrl: string) => {
if (proxyUrl === "bad-proxy") {
throw new Error("bad proxy");

View File

@@ -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; accountId: string };
type DiscordDirectoryAccess = { token: string; query: 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), accountId: account.accountId };
return { token, query: normalizeQuery(params.query) };
}
async function listDiscordGuilds(token: string): Promise<DiscordGuild[]> {
@@ -45,7 +45,7 @@ export async function listDiscordDirectoryGroupsLive(
if (!access) {
return [];
}
const { token, query, accountId } = access;
const { token, query } = access;
const guilds = await listDiscordGuilds(token);
const rows: ChannelDirectoryEntry[] = [];
@@ -82,7 +82,7 @@ export async function listDiscordDirectoryPeersLive(
if (!access) {
return [];
}
const { token, query, accountId } = access;
const { token, query } = access;
if (!query) {
return [];
}
@@ -106,7 +106,7 @@ export async function listDiscordDirectoryPeersLive(
continue;
}
rememberDiscordDirectoryUser({
accountId,
accountId: params.accountId,
userId: user.id,
handles: [
user.username,

View File

@@ -1,69 +0,0 @@
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");
});
});

View File

@@ -1,533 +0,0 @@
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),
};

View File

@@ -1,104 +0,0 @@
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,
},
}),
}),
});
}

View File

@@ -3,10 +3,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const loadConfigMock = vi.hoisted(() => vi.fn());
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",
);
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
return {
...actual,
loadConfig: () => loadConfigMock(),

View File

@@ -28,6 +28,7 @@ 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";
@@ -39,8 +40,6 @@ 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,
@@ -92,7 +91,6 @@ 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;
@@ -108,10 +106,6 @@ 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;
@@ -197,7 +191,7 @@ async function dispatchPluginDiscordInteractiveEvent(params: {
}
await params.interaction.update(payload);
};
const respond: DiscordInteractiveHandlerContext["respond"] = {
const respond: PluginInteractiveDiscordHandlerContext["respond"] = {
acknowledge: async () => {
if (responded) {
return;
@@ -221,7 +215,7 @@ async function dispatchPluginDiscordInteractiveEvent(params: {
});
},
editMessage: async (
input: Parameters<DiscordInteractiveHandlerContext["respond"]["editMessage"]>[0],
input: Parameters<PluginInteractiveDiscordHandlerContext["respond"]["editMessage"]>[0],
) => {
const { text, components } = input;
responded = true;
@@ -285,7 +279,9 @@ async function dispatchPluginDiscordInteractiveEvent(params: {
}
return "handled";
}
const dispatched = await dispatchDiscordPluginInteractiveHandler({
const { dispatchPluginInteractiveHandler } = await loadConversationRuntime();
const dispatched = await dispatchPluginInteractiveHandler({
channel: "discord",
data: params.data,
interactionId: resolveDiscordInteractionId(params.interaction),
ctx: {

View File

@@ -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 () => {
const actual = await vi.importActual<typeof import("../send.shared.js")>("../send.shared.js");
vi.mock("../send.shared.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../send.shared.js")>();
return {
...actual,
createDiscordClient: () => ({
@@ -72,10 +72,8 @@ vi.mock("../send.shared.js", async () => {
};
});
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",
);
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
return {
...actual,
loadSessionStore: () => mockSessionStoreEntries.value,
@@ -150,10 +148,8 @@ vi.mock("../../../../src/gateway/client.js", () => ({
},
}));
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",
);
vi.mock("openclaw/plugin-sdk/text-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/text-runtime")>();
return {
...actual,
logDebug: vi.fn(),

View File

@@ -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 () => {
vi.mock("../../../../src/channels/plugins/binding-routing.js", async (importOriginal) => {
const { createConfiguredBindingConversationRuntimeModuleMock } =
await import("../test-support/configured-binding-runtime.js");
return await createConfiguredBindingConversationRuntimeModuleMock(
@@ -12,10 +12,7 @@ vi.mock("../../../../src/channels/plugins/binding-routing.js", async () => {
ensureConfiguredBindingRouteReadyMock,
resolveConfiguredBindingRouteMock,
},
() =>
vi.importActual<typeof import("../../../../src/channels/plugins/binding-routing.js")>(
"../../../../src/channels/plugins/binding-routing.js",
),
importOriginal,
);
});

View File

@@ -2,17 +2,9 @@ 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: transcribeFirstAudioMock,
}));
vi.mock("./dm-command-auth.js", () => ({
resolveDiscordDmCommandAccess: resolveDiscordDmCommandAccessMock,
}));
vi.mock("./dm-command-decision.js", () => ({
handleDiscordDmCommandDecision: handleDiscordDmCommandDecisionMock,
transcribeFirstAudio: (...args: unknown[]) => transcribeFirstAudioMock(...args),
}));
import {
__testing as sessionBindingTesting,
@@ -269,14 +261,6 @@ 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 () => {
@@ -365,56 +349,6 @@ 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",

View File

@@ -34,7 +34,6 @@ 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,
@@ -387,7 +386,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 ?? resolveDefaultDiscordAccountId(params.cfg);
const resolvedAccountId = params.accountId ?? DEFAULT_ACCOUNT_ID;
const allowNameMatching = isDangerousNameMatchingEnabled(params.discordConfig);
let commandAuthorized = true;
if (isDirectMessage) {

View File

@@ -5,10 +5,8 @@ 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 () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/media-runtime")>(
"openclaw/plugin-sdk/media-runtime",
);
vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/media-runtime")>();
return {
...actual,
fetchRemoteMedia: (...args: unknown[]) => fetchRemoteMedia(...args),

View File

@@ -7,7 +7,6 @@ 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,
@@ -30,6 +29,7 @@ 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,12 +204,9 @@ describe("agent components", () => {
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
expect(peekSystemEvents(defaultGroupDmSessionKey)).toEqual([
"[Discord component: hello clicked by Alice#1234 (123456789)]",
expect.objectContaining({
sessionKey: defaultGroupDmSessionKey,
}),
);
]);
expect(peekSystemEvents(defaultDmSessionKey)).toEqual([]);
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
});
@@ -227,12 +224,9 @@ describe("agent components", () => {
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
expect(peekSystemEvents(defaultDmSessionKey)).toEqual([
"[Discord component: hello clicked by Alice#1234 (123456789)]",
expect.objectContaining({
sessionKey: defaultDmSessionKey,
}),
);
]);
expect(upsertPairingRequestMock).not.toHaveBeenCalled();
expect(readAllowFromStoreMock).toHaveBeenCalledWith("discord", "default");
});
@@ -250,12 +244,9 @@ describe("agent components", () => {
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
expect(peekSystemEvents(defaultDmSessionKey)).toEqual([
"[Discord component: hello clicked by Alice#1234 (123456789)]",
expect.objectContaining({
sessionKey: defaultDmSessionKey,
}),
);
]);
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
});
@@ -293,12 +284,9 @@ describe("agent components", () => {
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
expect(peekSystemEvents(defaultDmSessionKey)).toEqual([
"[Discord select menu: hello interacted by Alice#1234 (123456789) (selected: alpha)]",
expect.objectContaining({
sessionKey: defaultDmSessionKey,
}),
);
]);
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
});
@@ -315,12 +303,9 @@ describe("agent components", () => {
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
expect(peekSystemEvents(defaultDmSessionKey)).toEqual([
"[Discord component: hello_cid clicked by Alice#1234 (123456789)]",
expect.objectContaining({
sessionKey: defaultDmSessionKey,
}),
);
]);
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
});
@@ -337,12 +322,9 @@ describe("agent components", () => {
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
expect(peekSystemEvents(defaultDmSessionKey)).toEqual([
"[Discord component: hello%2G clicked by Alice#1234 (123456789)]",
expect.objectContaining({
sessionKey: defaultDmSessionKey,
}),
);
]);
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
});
});

View File

@@ -52,11 +52,6 @@ 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;
@@ -86,7 +81,12 @@ describe("discord component interactions", () => {
...overrides,
}) as DiscordAccountConfig;
type DispatchParams = Parameters<DispatchReplyWithBufferedBlockDispatcherFn>[0];
type DispatchParams = {
ctx: Record<string, unknown>;
dispatcherOptions: {
deliver: (payload: { text?: string }) => Promise<void> | void;
};
};
type ComponentContext = Parameters<CreateDiscordComponentButton>[0];
@@ -285,22 +285,10 @@ describe("discord component interactions", () => {
resetDiscordComponentRuntimeMocks();
lastDispatchCtx = undefined;
enqueueSystemEventMock.mockClear();
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,
},
};
},
);
dispatchReplyMock.mockClear().mockImplementation(async (params: DispatchParams) => {
lastDispatchCtx = params.ctx;
await params.dispatcherOptions.deliver({ text: "ok" });
});
recordInboundSessionMock.mockClear().mockResolvedValue(undefined);
readSessionUpdatedAtMock.mockClear().mockReturnValue(undefined);
resolveStorePathMock.mockClear().mockReturnValue("/tmp/openclaw-sessions-test.json");

View File

@@ -153,7 +153,7 @@ describe("createDiscordNativeCommand option wiring", () => {
});
it("keeps static choices for non-acp string action arguments", () => {
const command = createNativeCommand("config");
const command = createNativeCommand("voice");
const action = requireOption(command, "action");
const choices = readChoices(action);

View File

@@ -21,10 +21,8 @@ const runtimeModuleMocks = vi.hoisted(() => ({
dispatchReplyWithDispatcher: vi.fn(),
}));
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",
);
vi.mock("openclaw/plugin-sdk/plugin-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/plugin-runtime")>();
return {
...actual,
matchPluginCommand: (...args: unknown[]) => runtimeModuleMocks.matchPluginCommand(...args),
@@ -32,10 +30,8 @@ vi.mock("openclaw/plugin-sdk/plugin-runtime", async () => {
};
});
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",
);
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
return {
...actual,
dispatchReplyWithDispatcher: (...args: unknown[]) =>

View File

@@ -11,62 +11,39 @@ 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<EnsureConfiguredBindingRouteReady>(async () => ({ ok: true })),
vi.fn<() => Promise<{ ok: boolean; error?: string }>>(async () => ({ ok: true })),
);
const resolveConfiguredBindingRouteMock = vi.hoisted(() =>
vi.fn<ResolveConfiguredBindingRoute>(({ route }) => ({
bindingResolution: null,
route,
})),
vi.fn<
() => {
bindingResolution: {
record: {
conversation: {
channel: string;
accountId: string;
conversationId: string;
};
};
};
boundSessionKey: string;
route: {
agentId: string;
sessionKey: string;
};
} | null
>(() => null),
);
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 () => {
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
const { createConfiguredBindingConversationRuntimeModuleMock } =
await import("../test-support/configured-binding-runtime.js");
return await createConfiguredBindingConversationRuntimeModuleMock<
typeof import("openclaw/plugin-sdk/conversation-runtime")
>(
return await createConfiguredBindingConversationRuntimeModuleMock(
{
ensureConfiguredBindingRouteReadyMock,
resolveConfiguredBindingRouteMock,
},
() =>
vi.importActual<typeof import("openclaw/plugin-sdk/conversation-runtime")>(
"openclaw/plugin-sdk/conversation-runtime",
),
importOriginal,
);
});
@@ -87,10 +64,7 @@ describe("discord native /think autocomplete", () => {
ensureConfiguredBindingRouteReadyMock.mockReset();
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({ ok: true });
resolveConfiguredBindingRouteMock.mockReset();
resolveConfiguredBindingRouteMock.mockImplementation(({ route }) => ({
bindingResolution: null,
route,
}));
resolveConfiguredBindingRouteMock.mockReturnValue(null);
fs.mkdirSync(path.dirname(STORE_PATH), { recursive: true });
fs.writeFileSync(
STORE_PATH,
@@ -175,7 +149,22 @@ describe("discord native /think autocomplete", () => {
it("falls back when a configured binding is unavailable", async () => {
const cfg = createConfig();
resolveConfiguredBindingRouteMock.mockImplementation(createConfiguredRouteResult);
resolveConfiguredBindingRouteMock.mockReturnValue({
bindingResolution: {
record: {
conversation: {
channel: "discord",
accountId: "default",
conversationId: "C1",
},
},
},
boundSessionKey: SESSION_KEY,
route: {
agentId: "main",
sessionKey: SESSION_KEY,
},
});
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({
ok: false,
error: "acpx exited",

View File

@@ -36,8 +36,8 @@ const retryAsyncMock = vi.hoisted(() =>
),
);
vi.mock("../send.js", async () => {
const actual = await vi.importActual<typeof import("../send.js")>("../send.js");
vi.mock("../send.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../send.js")>();
return {
...actual,
sendMessageDiscord: (...args: unknown[]) => sendMessageDiscordMock(...args),
@@ -50,10 +50,8 @@ vi.mock("../send.shared.js", () => ({
sendDiscordText: (...args: unknown[]) => sendDiscordTextMock(...args),
}));
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",
);
vi.mock("openclaw/plugin-sdk/retry-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/retry-runtime")>();
return {
...actual,
retryAsync: retryAsyncMock,

View File

@@ -41,8 +41,8 @@ const hoisted = vi.hoisted(() => {
};
});
vi.mock("../send.js", async () => {
const actual = await vi.importActual<typeof import("../send.js")>("../send.js");
vi.mock("../send.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../send.js")>();
return {
...actual,
addRoleDiscord: vi.fn(),

View File

@@ -6,10 +6,8 @@ const hoisted = vi.hoisted(() => {
return { updateSessionStore, resolveStorePath };
});
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",
);
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
return {
...actual,
updateSessionStore: hoisted.updateSessionStore,

View File

@@ -1,28 +1,6 @@
import { expect, vi, type Mock } from "vitest";
import { expect, vi } from "vitest";
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 {
export function createDiscordOutboundHoisted() {
const sendMessageDiscordMock = vi.fn();
const sendDiscordComponentMessageMock = vi.fn();
const sendPollDiscordMock = vi.fn();
@@ -43,94 +21,28 @@ export const DEFAULT_DISCORD_SEND_RESULT = {
channelId: "ch-1",
} as const;
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),
};
}
type DiscordOutboundHoisted = ReturnType<typeof createDiscordOutboundHoisted>;
export async function installDiscordOutboundModuleSpies(hoisted: DiscordOutboundHoisted) {
const sendModule = await import("./send.js");
const mockedSendModule = await createDiscordSendModuleMock(hoisted, async () => sendModule);
vi.spyOn(sendModule, "sendMessageDiscord").mockImplementation(
mockedSendModule.sendMessageDiscord,
vi.spyOn(sendModule, "sendMessageDiscord").mockImplementation((...args: unknown[]) =>
hoisted.sendMessageDiscordMock(...args),
);
vi.spyOn(sendModule, "sendPollDiscord").mockImplementation(mockedSendModule.sendPollDiscord);
vi.spyOn(sendModule, "sendWebhookMessageDiscord").mockImplementation(
mockedSendModule.sendWebhookMessageDiscord,
vi.spyOn(sendModule, "sendPollDiscord").mockImplementation((...args: unknown[]) =>
hoisted.sendPollDiscordMock(...args),
);
vi.spyOn(sendModule, "sendWebhookMessageDiscord").mockImplementation((...args: unknown[]) =>
hoisted.sendWebhookMessageDiscordMock(...args),
);
const sendComponentsModule = await import("./send.components.js");
const mockedSendComponentsModule = await createDiscordSendComponentsModuleMock(
hoisted,
async () => sendComponentsModule,
);
vi.spyOn(sendComponentsModule, "sendDiscordComponentMessage").mockImplementation(
mockedSendComponentsModule.sendDiscordComponentMessage,
(...args: unknown[]) => hoisted.sendDiscordComponentMessageMock(...args),
);
const threadBindingsModule = await import("./monitor/thread-bindings.js");
const mockedThreadBindingsModule = await createDiscordThreadBindingsModuleMock(
hoisted,
async () => threadBindingsModule,
);
vi.spyOn(threadBindingsModule, "getThreadBindingManager").mockImplementation(
mockedThreadBindingsModule.getThreadBindingManager,
(...args: unknown[]) => hoisted.getThreadBindingManagerMock(...args),
);
}

View File

@@ -1,235 +0,0 @@
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;
}

View File

@@ -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,16 +430,11 @@ 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: accountInfo.accountId,
accountId: opts.accountId,
})
: undefined;
const stickers = normalizeStickerIds(stickerIds);
@@ -461,16 +456,11 @@ 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: accountInfo.accountId,
accountId: opts.accountId,
})
: undefined;
if (poll.durationSeconds !== undefined) {

View File

@@ -126,40 +126,6 @@ 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.

View File

@@ -3,20 +3,16 @@ 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 () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/config-runtime")>(
"openclaw/plugin-sdk/config-runtime",
);
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
return {
...actual,
loadConfig: () => loadConfigMock(),
};
});
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",
);
vi.mock("../../../src/infra/channel-activity.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../src/infra/channel-activity.js")>();
return {
...actual,
recordChannelActivity: (...args: unknown[]) => recordChannelActivityMock(...args),

View File

@@ -4,10 +4,8 @@ import { sendWebhookMessageDiscord } from "./send.outbound.js";
const makeProxyFetchMock = vi.hoisted(() => vi.fn());
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",
);
vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
return {
...actual,
makeProxyFetch: makeProxyFetchMock,

View File

@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
import {
inspectDiscordSetupAccount,
listDiscordSetupAccountIds,
resolveDefaultDiscordSetupAccountId,
resolveDiscordSetupAccountConfig,
} from "./setup-account-state.js";
@@ -42,31 +41,6 @@ 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: {

View File

@@ -5,7 +5,6 @@ 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";
@@ -55,16 +54,14 @@ export function listDiscordSetupAccountIds(cfg: OpenClawConfig): string[] {
}
export function resolveDefaultDiscordSetupAccountId(cfg: OpenClawConfig): string {
return resolveDefaultDiscordAccountId(cfg);
return listDiscordSetupAccountIds(cfg)[0] ?? DEFAULT_ACCOUNT_ID;
}
export function resolveDiscordSetupAccountConfig(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): { accountId: string; config: DiscordAccountConfig } {
const accountId = normalizeAccountId(
params.accountId ?? resolveDefaultDiscordSetupAccountId(params.cfg),
);
const accountId = normalizeAccountId(params.accountId ?? DEFAULT_ACCOUNT_ID);
return {
accountId,
config: mergeDiscordAccountConfig(params.cfg, accountId),

View File

@@ -10,7 +10,6 @@ import {
type ResolvedDiscordAccount,
} from "./accounts.js";
import { DiscordChannelConfigSchema } from "./config-schema.js";
import { discordDoctor } from "./doctor.js";
import {
createScopedChannelConfigAdapter,
getChatChannelMeta,
@@ -48,8 +47,6 @@ export function createDiscordPluginBase(params: {
| "meta"
| "setupWizard"
| "capabilities"
| "commands"
| "doctor"
| "streaming"
| "reload"
| "configSchema"
@@ -68,13 +65,6 @@ 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 },
},
@@ -99,8 +89,6 @@ export function createDiscordPluginBase(params: {
| "meta"
| "setupWizard"
| "capabilities"
| "commands"
| "doctor"
| "streaming"
| "reload"
| "configSchema"

View File

@@ -1,9 +1,5 @@
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,
@@ -80,7 +76,6 @@ describe("resolveDiscordTarget", () => {
beforeEach(() => {
vi.restoreAllMocks();
__resetDiscordDirectoryCacheForTest();
});
it("returns a resolved user for usernames", async () => {
@@ -107,33 +102,6 @@ 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", () => {

View File

@@ -7,7 +7,6 @@ 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";
@@ -101,12 +100,8 @@ 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:/, "");
const resolvedAccountId = resolveDiscordAccount({
cfg: options.cfg,
accountId: options.accountId,
}).accountId;
rememberDiscordDirectoryUser({
accountId: resolvedAccountId,
accountId: options.accountId,
userId,
handles: [trimmed, match.name, match.handle],
});

View File

@@ -1,54 +1,32 @@
import { vi, type Mock } from "vitest";
import { vi } from "vitest";
import { parsePluginBindingApprovalCustomId } from "../../../../src/plugins/conversation-binding.js";
import { resolvePinnedMainDmOwnerFromAllowlist } from "../../../../src/security/dm-policy-shared.js";
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>;
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 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 =
export const readAllowFromStoreMock = runtimeMocks.readAllowFromStoreMock;
export const dispatchPluginInteractiveHandlerMock =
runtimeMocks.dispatchPluginInteractiveHandlerMock;
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 =
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 =
runtimeMocks.resolvePluginConversationBindingApprovalMock;
export const buildPluginBindingResolvedTextMock: UnknownMock =
runtimeMocks.buildPluginBindingResolvedTextMock;
export const buildPluginBindingResolvedTextMock = runtimeMocks.buildPluginBindingResolvedTextMock;
async function readStoreAllowFromForDmPolicy(params: {
provider: string;
@@ -107,7 +85,7 @@ vi.mock("../monitor/agent-components.runtime.js", () => {
),
dispatchPluginInteractiveHandler: (...args: unknown[]) =>
dispatchPluginInteractiveHandlerMock(...args),
dispatchReplyWithBufferedBlockDispatcher: dispatchReplyMock,
dispatchReplyWithBufferedBlockDispatcher: (...args: unknown[]) => dispatchReplyMock(...args),
finalizeInboundContext: vi.fn((ctx) => ctx),
parsePluginBindingApprovalCustomId,
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
@@ -118,13 +96,6 @@ 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),
@@ -133,17 +104,6 @@ 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,

View File

@@ -1,29 +1,19 @@
type ConfiguredBindingConversationRuntimeModule = {
ensureConfiguredBindingRouteReady: (...args: never[]) => unknown;
resolveConfiguredBindingRoute: (...args: never[]) => unknown;
};
export async function createConfiguredBindingConversationRuntimeModuleMock<
TModule extends ConfiguredBindingConversationRuntimeModule,
>(
export async function createConfiguredBindingConversationRuntimeModuleMock(
params: {
ensureConfiguredBindingRouteReadyMock: (
...args: Parameters<TModule["ensureConfiguredBindingRouteReady"]>
) => ReturnType<TModule["ensureConfiguredBindingRouteReady"]>;
resolveConfiguredBindingRouteMock: (
...args: Parameters<TModule["resolveConfiguredBindingRoute"]>
) => ReturnType<TModule["resolveConfiguredBindingRoute"]>;
ensureConfiguredBindingRouteReadyMock: (...args: unknown[]) => unknown;
resolveConfiguredBindingRouteMock: (...args: unknown[]) => unknown;
},
importOriginal: () => Promise<TModule>,
importOriginal: () => Promise<{
ensureConfiguredBindingRouteReady: (...args: unknown[]) => unknown;
resolveConfiguredBindingRoute: (...args: unknown[]) => unknown;
}>,
) {
const actual = await importOriginal();
return {
...actual,
ensureConfiguredBindingRouteReady: (
...args: Parameters<TModule["ensureConfiguredBindingRouteReady"]>
) => params.ensureConfiguredBindingRouteReadyMock(...args),
resolveConfiguredBindingRoute: (
...args: Parameters<TModule["resolveConfiguredBindingRoute"]>
) => params.resolveConfiguredBindingRouteMock(...args),
} satisfies TModule;
ensureConfiguredBindingRouteReady: (...args: unknown[]) =>
params.ensureConfiguredBindingRouteReadyMock(...args),
resolveConfiguredBindingRoute: (...args: unknown[]) =>
params.resolveConfiguredBindingRouteMock(...args),
};
}

View File

@@ -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 () => {
vi.mock("openclaw/plugin-sdk/temp-path", async (importOriginal) => {
return {
resolvePreferredOpenClawTmpDir: () => "/tmp",
};
});
vi.mock("openclaw/plugin-sdk/media-runtime", async () => {
vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => {
return {
runFfprobe: runFfprobeMock,
runFfmpeg: runFfmpegMock,

View File

@@ -87,20 +87,16 @@ vi.mock("./sdk-runtime.js", () => ({
}),
}));
vi.mock("openclaw/plugin-sdk/routing", async () => {
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/routing")>(
"openclaw/plugin-sdk/routing",
);
vi.mock("openclaw/plugin-sdk/routing", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/routing")>();
return {
...actual,
resolveAgentRoute: resolveAgentRouteMock,
};
});
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",
);
vi.mock("openclaw/plugin-sdk/agent-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/agent-runtime")>();
return {
...actual,
agentCommandFromIngress: agentCommandMock,

View File

@@ -1,4 +1,3 @@
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";

View File

@@ -1,4 +1,3 @@
export { feishuPlugin } from "./src/channel.js";
export * from "./src/conversation-id.js";
export * from "./src/setup-core.js";
export * from "./src/setup-surface.js";

View File

@@ -289,10 +289,8 @@ vi.mock("./client.js", () => ({
createFeishuClient: mockCreateFeishuClient,
}));
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",
);
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
return {
...actual,
resolveConfiguredBindingRoute: (params: unknown) =>

View File

@@ -565,9 +565,6 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
groups: {
resolveToolPolicy: resolveFeishuGroupToolPolicy,
},
conversationBindings: {
defaultTopLevelPlacement: "current",
},
mentions: {
stripPatterns: () => ['<at user_id="[^"]*">[^<]*</at>'],
},

View File

@@ -1,77 +1,31 @@
import { vi, type Mock } from "vitest";
import { vi } 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(
(): 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" })),
}),
);
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" })),
}));
export function getFeishuLifecycleTestMocks(): FeishuLifecycleTestMocks {
export function getFeishuLifecycleTestMocks() {
return feishuLifecycleTestMocks;
}

View File

@@ -3,7 +3,6 @@ import {
type ChannelSetupAdapter,
type OpenClawConfig,
} from "openclaw/plugin-sdk/setup";
import { resolveDefaultFeishuAccountId } from "./accounts.js";
import type { FeishuConfig } from "./types.js";
export function setFeishuNamedAccountEnabled(
@@ -31,7 +30,7 @@ export function setFeishuNamedAccountEnabled(
}
export const feishuSetupAdapter: ChannelSetupAdapter = {
resolveAccountId: ({ cfg, accountId }) => accountId?.trim() || resolveDefaultFeishuAccountId(cfg),
resolveAccountId: ({ accountId }) => accountId?.trim() || DEFAULT_ACCOUNT_ID,
applyAccountConfig: ({ cfg, accountId }) => {
const isDefault = !accountId || accountId === DEFAULT_ACCOUNT_ID;
if (isDefault) {

View File

@@ -5,7 +5,6 @@ import {
createPluginSetupWizardStatus,
createTestWizardPrompter,
runSetupWizardConfigure,
runSetupWizardFinalize,
type WizardPrompter,
} from "../../../test/helpers/plugins/setup-wizard.js";
@@ -72,28 +71,6 @@ 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()
@@ -174,81 +151,6 @@ 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", () => {

View File

@@ -4,6 +4,7 @@ import {
formatDocsLink,
hasConfiguredSecretInput,
mergeAllowFromEntries,
patchChannelConfigForAccount,
patchTopLevelChannelConfigSection,
promptSingleChannelSecretInput,
splitSetupEntries,
@@ -32,14 +33,15 @@ function normalizeString(value: unknown): string | undefined {
return trimmed || undefined;
}
type ScopedFeishuConfig = Partial<FeishuConfig> & Partial<FeishuAccountConfig>;
function getScopedFeishuConfig(cfg: OpenClawConfig, accountId: string): ScopedFeishuConfig {
function getScopedFeishuConfig(
cfg: OpenClawConfig,
accountId: string,
): FeishuConfig | FeishuAccountConfig {
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(
@@ -47,30 +49,11 @@ function patchFeishuConfig(
accountId: string,
patch: Record<string, unknown>,
): OpenClawConfig {
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({
return patchChannelConfigForAccount({
cfg,
channel,
enabled: true,
patch: {
accounts: {
...(feishuCfg?.accounts ?? {}),
[accountId]: nextAccountPatch,
},
},
accountId,
patch,
});
}
@@ -99,7 +82,7 @@ function setFeishuGroupAllowFrom(
}
function isFeishuConfigured(cfg: OpenClawConfig): boolean {
const feishuCfg = ((cfg.channels?.feishu as FeishuConfig | undefined) ?? {}) as FeishuConfig;
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
const isAppIdConfigured = (value: unknown): boolean => {
const asString = normalizeString(value);
@@ -122,7 +105,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;
}
@@ -292,7 +275,7 @@ export const feishuSetupWizard: ChannelSetupWizard = {
},
credentials: [],
finalize: async ({ cfg, accountId, prompter, options }) => {
const resolvedAccountId = accountId ?? resolveDefaultFeishuAccountId(cfg);
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
const resolvedAccount = resolveFeishuAccount({ cfg, accountId: resolvedAccountId });
const scopedConfig = getScopedFeishuConfig(cfg, resolvedAccountId);
const resolved =

View File

@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { expect, vi, type Mock } from "vitest";
import { expect, vi } 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,37 +9,6 @@ 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()}`;
@@ -59,7 +28,7 @@ export const FEISHU_PREFETCHED_BOT_OPEN_ID_SOURCE = {
botName: "Bot",
} as const;
export function createFeishuLifecycleReplyDispatcher(): FeishuLifecycleReplyDispatcher {
export function createFeishuLifecycleReplyDispatcher() {
return {
dispatcher: {
sendToolResult: vi.fn(() => false),
@@ -165,7 +134,16 @@ export function installFeishuLifecycleReplyRuntime(params: {
}
export function mockFeishuReplyOnceDispatch(params: {
dispatchReplyFromConfigMock: FeishuDispatchReplyMock;
dispatchReplyFromConfigMock: {
mockImplementation: (
fn: (args: {
ctx?: unknown;
dispatcher?: {
sendFinalReply?: (payload: { text: string }) => Promise<unknown>;
};
}) => Promise<unknown>,
) => void;
};
replyText: string;
shouldSendFinalReply?: (ctx: unknown) => boolean;
}) {

View File

@@ -81,7 +81,7 @@ describe("feishu tool account routing", () => {
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b");
});
test("wiki tool prefers the active contextual account over configured defaultAccount", async () => {
test("wiki tool prefers configured defaultAccount over inherited default account context", 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-a");
expect(createFeishuClientMock.mock.calls.at(-1)?.[0]?.appId).toBe("app-b");
});
test("drive tool registers when first account disables it and routes to agentAccountId", async () => {

View File

@@ -1,44 +0,0 @@
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");
});
});

View File

@@ -35,23 +35,25 @@ 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;
}
return undefined;
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;
}
export function resolveFeishuToolAccount(params: {

View File

@@ -1,13 +1,76 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import { streamSimple } from "@mariozechner/pi-ai";
import {
applyAnthropicEphemeralCacheControlMarkers,
buildCopilotDynamicHeaders,
hasCopilotVisionInput,
streamWithPayloadPatch,
} from "openclaw/plugin-sdk/provider-stream";
import { 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;
@@ -23,14 +86,11 @@ export function wrapCopilotAnthropicStream(baseStreamFn: StreamFn | undefined):
{
...options,
headers: {
...buildCopilotDynamicHeaders({
messages: context.messages as StreamContext["messages"],
hasImages: hasCopilotVisionInput(context.messages as StreamContext["messages"]),
}),
...buildCopilotDynamicHeaders({ messages: context.messages }),
...(options?.headers ?? {}),
},
},
applyAnthropicEphemeralCacheControlMarkers,
applyAnthropicPromptCacheMarkers,
);
};
}

View File

@@ -130,9 +130,7 @@ export function resolveGoogleChatAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): ResolvedGoogleChatAccount {
const accountId = normalizeAccountId(
params.accountId ?? params.cfg.channels?.["googlechat"]?.defaultAccount,
);
const accountId = normalizeAccountId(params.accountId);
const baseEnabled = params.cfg.channels?.["googlechat"]?.enabled !== false;
const merged = mergeGoogleChatAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false;

View File

@@ -32,8 +32,8 @@ vi.mock("./targets.js", () => ({
resolveGoogleChatOutboundSpace,
}));
vi.mock("../runtime-api.js", async () => {
const actual = await vi.importActual<typeof import("../runtime-api.js")>("../runtime-api.js");
vi.mock("../runtime-api.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../runtime-api.js")>();
return {
...actual,
loadOutboundMediaFromUrl: (...args: Parameters<typeof actual.loadOutboundMediaFromUrl>) =>

View File

@@ -18,7 +18,6 @@ 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,
@@ -213,12 +212,6 @@ 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[] =>
@@ -362,7 +355,6 @@ export const googlechatPlugin = createChatChannelPlugin({
chunker: chunkTextForOutbound,
chunkerMode: "markdown",
textChunkLimit: 4000,
sanitizeText: ({ text }) => sanitizeForPlainText(text),
resolveTarget: ({ to }) => {
const trimmed = to?.trim() ?? "";

View File

@@ -1,7 +1,7 @@
import {
addWildcardAllowFrom,
applySetupAccountConfigPatch,
createPromptParsedAllowFromForAccount,
createNestedChannelParsedAllowFromPrompt,
createStandardChannelSetupStatus,
DEFAULT_ACCOUNT_ID,
formatDocsLink,
@@ -25,27 +25,16 @@ const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE";
const USE_ENV_FLAG = "__googlechatUseEnv";
const AUTH_METHOD_FLAG = "__googlechatAuthMethod";
const promptAllowFrom = createPromptParsedAllowFromForAccount({
defaultAccountId: resolveDefaultGoogleChatAccountId,
const promptAllowFrom = createNestedChannelParsedAllowFromPrompt({
channel,
section: "dm",
defaultAccountId: DEFAULT_ACCOUNT_ID,
enabled: true,
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 = {
@@ -104,7 +93,13 @@ export const googlechatSetupWizard: ChannelSetupWizard = {
unconfiguredHint: "needs auth",
includeStatusLine: true,
resolveConfigured: ({ cfg, accountId }) =>
resolveGoogleChatAccount({ cfg, accountId }).credentialSource !== "none",
accountId
? resolveGoogleChatAccount({ cfg, accountId }).credentialSource !== "none"
: listGoogleChatAccountIds(cfg).some(
(resolvedAccountId) =>
resolveGoogleChatAccount({ cfg, accountId: resolvedAccountId }).credentialSource !==
"none",
),
}),
introNote: {
title: "Google Chat setup",

View File

@@ -211,28 +211,6 @@ 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",
@@ -271,41 +249,6 @@ 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(
{
@@ -511,24 +454,4 @@ 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