mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-11 08:21:38 +08:00
Compare commits
2 Commits
codex/plug
...
fix/system
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a424103dea | ||
|
|
c3ce8c7321 |
83
CHANGELOG.md
83
CHANGELOG.md
@@ -8,72 +8,43 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
### Fixes
|
||||
|
||||
## 2026.3.24-beta.1
|
||||
|
||||
### Breaking
|
||||
|
||||
### Changes
|
||||
|
||||
- Gateway/OpenAI compatibility: add `/v1/models` and `/v1/embeddings`, and forward explicit model overrides through `/v1/chat/completions` and `/v1/responses` for broader client and RAG compatibility. Thanks @vincentkoc.
|
||||
- Agents/tools: make `/tools` show the tools the current agent can actually use right now, add a compact default view with an optional detailed mode, and add a live “Available Right Now” section in the Control UI so it is easier to see what will work before you ask.
|
||||
- Microsoft Teams: migrate to the official Teams SDK and add AI-agent UX best practices including streaming 1:1 replies, welcome cards with prompt starters, feedback/reflection, informative status updates, typing indicators, and native AI labeling. (#51808)
|
||||
- Microsoft Teams: add message edit and delete support for sent messages, including in-thread fallbacks when no explicit target is provided. (#49925)
|
||||
- Control UI/markdown preview: restyle the agent workspace file preview dialog with a frosted backdrop, sized panel, and styled header, and integrate `@create-markdown/preview` v2 system theme for rich markdown rendering (headings, tables, code blocks, callouts, blockquotes) that auto-adapts to the app's light/dark design tokens. (#53411) Thanks @BunsDev.
|
||||
- Skills/install metadata: add one-click install recipes to bundled skills (coding-agent, gh-issues, openai-whisper-api, session-logs, tmux, trello, weather) so the CLI and Control UI can offer dependency installation when requirements are missing. (#53411) Thanks @BunsDev.
|
||||
- CLI/skills: soften missing-requirements label from "missing" to "needs setup" and surface API key setup guidance (where to get a key, CLI save command, storage path) in `openclaw skills info` output. (#53411) Thanks @BunsDev.
|
||||
- Control UI/skills: add status-filter tabs (All / Ready / Needs Setup / Disabled) with counts, replace inline skill cards with a click-to-detail dialog showing requirements, toggle switch, install action, API key entry, source metadata, and homepage link. (#53411) Thanks @BunsDev.
|
||||
- Slack/interactive replies: restore rich reply parity for direct deliveries, auto-render simple trailing `Options:` lines as buttons/selects, improve Slack interactive setup defaults, and isolate reply controls from plugin interactive handlers. (#53389) Thanks @vincentkoc.
|
||||
- Control UI/agents: convert agent workspace file rows to expandable `<details>` with lazy-loaded inline markdown preview, and add comprehensive `.sidebar-markdown` styles for headings, lists, code blocks, tables, blockquotes, and details/summary elements. (#53411) Thanks @BunsDev.
|
||||
- Control UI/agents: add a "Not set" placeholder to the default agent model selector dropdown. (#53411) Thanks @BunsDev.
|
||||
- macOS app/config: replace horizontal pill-based subsection navigation with a collapsible tree sidebar using disclosure chevrons and indented subsection rows. (#53411) Thanks @BunsDev.
|
||||
- macOS app/skills: add "Get your key" homepage link and storage-path hint to the API key editor dialog, and show the config path in save confirmation messages. (#53411) Thanks @BunsDev.
|
||||
- CLI/containers: add `--container` and `OPENCLAW_CONTAINER` to run `openclaw` commands inside a running Docker or Podman OpenClaw container. (#52651) Thanks @sallyom.
|
||||
- Discord/auto threads: add optional `autoThreadName: "generated"` naming so new auto-created threads can be renamed asynchronously with concise LLM-generated titles while keeping the existing message-based naming as the default. (#43366) Thanks @davidguttman.
|
||||
- Plugins/hooks: add `before_dispatch` with canonical inbound metadata and route handled replies through the normal final-delivery path, preserving TTS and routed delivery semantics. (#50444) Thanks @gfzhx.
|
||||
- Control UI/agents: convert agent workspace file rows to expandable `<details>` with lazy-loaded inline markdown preview, and add comprehensive `.sidebar-markdown` styles for headings, lists, code blocks, tables, blockquotes, and details/summary elements. (#53411) Thanks @BunsDev.
|
||||
- Control UI/markdown preview: restyle the agent workspace file preview dialog with a frosted backdrop, sized panel, and styled header, and integrate `@create-markdown/preview` v2 system theme for rich markdown rendering (headings, tables, code blocks, callouts, blockquotes) that auto-adapts to the app's light/dark design tokens. (#53411) Thanks @BunsDev.
|
||||
- macOS app/config: replace horizontal pill-based subsection navigation with a collapsible tree sidebar using disclosure chevrons and indented subsection rows. (#53411) Thanks @BunsDev.
|
||||
- CLI/skills: soften missing-requirements label from "missing" to "needs setup" and surface API key setup guidance (where to get a key, CLI save command, storage path) in `openclaw skills info` output. (#53411) Thanks @BunsDev.
|
||||
- macOS app/skills: add "Get your key" homepage link and storage-path hint to the API key editor dialog, and show the config path in save confirmation messages. (#53411) Thanks @BunsDev.
|
||||
- Control UI/agents: add a "Not set" placeholder to the default agent model selector dropdown. (#53411) Thanks @BunsDev.
|
||||
- Slack/interactive replies: restore rich reply parity for direct deliveries, auto-render simple trailing `Options:` lines as buttons/selects, improve Slack interactive setup defaults, and isolate reply controls from plugin interactive handlers. (#53389) Thanks @vincentkoc.
|
||||
- Gateway/OpenAI compatibility: add `/v1/models` and `/v1/embeddings`, and forward explicit model overrides through `/v1/chat/completions` and `/v1/responses` for broader client and RAG compatibility. Thanks @vincentkoc.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Security/sandbox media dispatch: close the `mediaUrl`/`fileUrl` alias bypass so outbound tool and message actions cannot escape media-root restrictions. (#54034)
|
||||
- Gateway/restart sentinel: wake the interrupted agent session via heartbeat after restart instead of only sending a best-effort restart note, retry outbound delivery once on transient failure, and preserve explicit thread/topic routing through the wake path so replies land in the correct Telegram topic or Slack thread. (#53940) Thanks @VACInc.
|
||||
- Docker/setup: avoid the pre-start `openclaw-cli` shared-network namespace loop by routing setup-time onboard/config writes through `openclaw-gateway`, so fresh Docker installs stop failing before the gateway comes up. (#53385) Thanks @amsminn.
|
||||
- Gateway/channels: keep channel startup sequential while isolating per-channel boot failures, so one broken channel no longer blocks later channels from starting. (#54215) Thanks @JonathanJing.
|
||||
- Embedded runs/secrets: stop unresolved `SecretRef` config from crashing embedded agent runs by falling back to the resolved runtime snapshot when needed. Fixes #45838.
|
||||
- WhatsApp/groups: track recent gateway-sent message IDs and suppress only matching group echoes, preserving owner `/status`, `/new`, and `/activation` commands from linked-account `fromMe` traffic. (#53624) Thanks @w-sss.
|
||||
- WhatsApp/reply-to-bot detection: restore implicit group reply detection by unwrapping `botInvokeMessage` payloads and reading `selfLid` from `creds.json`, so reply-based mentions reach the bot again in linked-account group chats.
|
||||
- Telegram/forum topics: recover `#General` topic `1` routing when Telegram omits forum metadata, including native commands, interactive callbacks, inbound message context, and fallback error replies. (#53699) thanks @huntharo
|
||||
- Discord/gateway supervision: centralize gateway error handling behind a lifetime-owned supervisor so early, active, and late-teardown Carbon gateway errors stay classified consistently and stop surfacing as process-killing teardown crashes.
|
||||
- Discord/timeouts: send a visible timeout reply when the inbound Discord worker times out before a final reply starts, including created auto-thread targets and queued-run ordering. (#53823) Thanks @Kimbo7870.
|
||||
- ACP/direct chats: always deliver a terminal ACP result when final TTS does not yield audio, even if block text already streamed earlier, and skip redundant empty-text final synthesis. (#53692) Thanks @w-sss.
|
||||
- Telegram/outbound errors: preserve actionable 403 membership/block/kick details and treat `bot not a member` as a permanent delivery failure so Telegram sends stop retrying doomed chats. (#53635) Thanks @w-sss.
|
||||
- Telegram/photos: preflight Telegram photo dimension and aspect-ratio rules, and fall back to document sends when image metadata is invalid or unavailable so photo uploads stop failing with `PHOTO_INVALID_DIMENSIONS`. (#52545) Thanks @hnshah.
|
||||
- Slack/runtime defaults: trim Slack DM reply overhead, restore Codex auto transport, and tighten Slack/web-search runtime defaults around DM preview threading, cache scoping, warning dedupe, and explicit web-search opt-in. (#53957) Thanks @vincentkoc.
|
||||
- Doctor/image generation: seed migrated legacy Nano Banana Google provider config with the `/v1beta` API root and an empty model list so `openclaw doctor --fix` completes and the migrated native Google image path keeps hitting the correct endpoint. (#53757) Thanks @mahopan.
|
||||
- Models/google: normalize bare Google Generative AI API roots for custom provider names, and keep built-in Google model-id rewrites working when `api` is declared only on individual models, so custom Google lanes and older configs stop missing `/v1beta` or preview-id normalization. (#44969) Thanks @Kathie-yu.
|
||||
- Feishu/startup: treat unresolved `SecretRef` app credentials as not configured during account resolution so CLI startup and read-only Feishu config surfaces stop crashing before runtime-backed secret resolution is available. (#53675) Thanks @hpt.
|
||||
- Feishu/groups: when `groupPolicy` is `open`, stop implicitly requiring @mentions for unset `requireMention`, so image, file, audio, and other non-text group messages reach the bot unless operators explicitly keep mention gating on. (#54058) Thanks @byungsker.
|
||||
- Feishu/startup: keep `requireMention` enforcement strict when bot identity startup probes fail, raise the startup bot-info timeout to 30s, and add cancellable background identity recovery so mention-gated groups recover without noisy fallback. (#43788) Thanks @lefarcen.
|
||||
- Feishu/MSTeams message tool: keep provider-native `card` payloads optional in merged tool schemas so media-only sends stop failing validation before channel runtime dispatch. (#53715) Thanks @lndyzwdxhs.
|
||||
- Feishu/docx block ordering: preserve the document tree order from `docx.document.convert` when inserting blocks, fixing heading/paragraph/list misordering in newly written Feishu documents. (#40524) Thanks @TaoXieSZ.
|
||||
- Telegram/native commands: run native slash-command execution against the resolved runtime snapshot so DM commands still reply when fresh config reads surface unresolved SecretRefs. (#53179) Thanks @nimbleenigma.
|
||||
- Gateway/ports: parse Docker Compose-style `OPENCLAW_GATEWAY_PORT` host publish values correctly without reviving the legacy `CLAWDBOT_GATEWAY_PORT` override. (#44083) Thanks @bebule.
|
||||
- Plugins/memory-lancedb: bootstrap the env-configured HTTP/HTTPS proxy dispatcher before OpenAI embeddings requests so memory capture and recall work in proxy-required environments again. (#54119) Thanks @neeravmakwana.
|
||||
- Runtime/build: stabilize long-lived lazy `dist` runtime entry paths and harden bundled plugin npm staging so local rebuilds stop breaking on missing hashed chunks or broken shell `npm` shims. (#53855) Thanks @vincentkoc.
|
||||
- Security/skills: validate skill installer metadata against strict regex allowlists per package manager, sanitize skill metadata for terminal output, add URL protocol allowlisting in markdown preview and skill homepage links, warn on non-bundled skill install sources, and remove unsafe `file://` workspace links. (#53471) Thanks @BunsDev.
|
||||
- Memory/builtin sqlite: cut redundant sync and status query churn by snapshotting file state once per source, reusing sync statements, and consolidating status aggregation reads, which reduces builtin memory overhead on sync/status/doctor-style paths. Thanks @vincentkoc.
|
||||
- TUI/chat: preserve pending user messages when a slow local run emits an empty final event, but still defer and flush the needed history reload after the newer active run finishes so silent/tool-only runs do not stay incomplete. (#53130) Thanks @joelnishanth.
|
||||
- DeepSeek/pricing: replace the zero-cost DeepSeek catalog rates with the current DeepSeek V3.2 pricing so usage totals stop showing `$0.00` for DeepSeek sessions. (#54143) Thanks @arkyu2077.
|
||||
- CLI/logging: make pretty log timestamps always include an explicit timezone offset in default UTC and `--local-time` modes, so incident triage no longer mixes ambiguous clock displays. (#38904) Thanks @sahilsatralkar.
|
||||
- Browser/default detection: recognize macOS LaunchServices Edge bundle ids so default Chromium detection stops falling back to Chrome when Edge is the system default. (#48561) Thanks @zoherghadyali.
|
||||
- CLI/Telegram topics: route `message thread create` through Telegram `topic-create` with the required topic `name` field so Telegram forum topic creation works from the CLI again. (#54336) Thanks @andyliu.
|
||||
- Telegram/pairing: render pairing codes and approval commands as Telegram-only code blocks while keeping shared pairing replies plain text for other channels. (#52784) Thanks @sumukhj1219.
|
||||
- ACP/direct chats: always deliver a terminal ACP result when final TTS does not yield audio, even if block text already streamed earlier, and skip redundant empty-text final synthesis. (#53692) Thanks @w-sss.
|
||||
- Doctor/image generation: seed migrated legacy Nano Banana Google provider config with the `/v1beta` API root and an empty model list so `openclaw doctor --fix` completes and the migrated native Google image path keeps hitting the correct endpoint. (#53757) Thanks @mahopan.
|
||||
- Security/skills: validate skill installer metadata against strict regex allowlists per package manager, sanitize skill metadata for terminal output, add URL protocol allowlisting in markdown preview and skill homepage links, warn on non-bundled skill install sources, and remove unsafe `file://` workspace links. (#53471) Thanks @BunsDev.
|
||||
- Feishu/docx block ordering: preserve the document tree order from `docx.document.convert` when inserting blocks, fixing heading/paragraph/list misordering in newly written Feishu documents. (#40524) Thanks @TaoXieSZ.
|
||||
- Agents/cron: suppress the default heartbeat system prompt for cron-triggered embedded runs even when they target non-cron session keys, so cron tasks stop reading `HEARTBEAT.md` and polluting unrelated threads. (#53152) Thanks @Protocol-zero-0.
|
||||
- Agents/cron: mark best-effort announce runs as not delivered when any payload fails, and log those partial delivery failures instead of silently reporting success. (#42535) Thanks @MoerAI.
|
||||
- Plugins: enforce terminal hook decision semantics for tool/message guards (#54241) Thanks @joshavant.
|
||||
- Marketplace/agents: correct the ClawHub skill URL in agent docs and stream marketplace archive downloads to disk so installs avoid excess memory use and fail cleanly on empty responses. (#54160) Thanks @QuinnH496.
|
||||
- Discord/config types: add missing `autoArchiveDuration` to `DiscordGuildChannelConfig` so TypeScript config definitions match the existing schema and runtime support. (#43427) Thanks @davidguttman.
|
||||
- TUI/chat: preserve pending user messages when a slow local run emits an empty final event, but still defer and flush the needed history reload after the newer active run finishes so silent/tool-only runs do not stay incomplete. (#53130) Thanks @joelnishanth.
|
||||
- Docs/IRC: fix five `json55` code-fence typos in the IRC channel examples so Mintlify applies JSON5 syntax highlighting correctly. (#50842) Thanks @Hollychou924.
|
||||
- Telegram/forum topics: recover `#General` topic `1` routing when Telegram omits forum metadata, including native commands, interactive callbacks, inbound message context, and fallback error replies. (#53699) thanks @huntharo
|
||||
- Discord/config types: add missing `autoArchiveDuration` to `DiscordGuildChannelConfig` so TypeScript config definitions match the existing schema and runtime support. (#43427) Thanks @davidguttman.
|
||||
- Feishu/startup: treat unresolved `SecretRef` app credentials as not configured during account resolution so CLI startup and read-only Feishu config surfaces stop crashing before runtime-backed secret resolution is available. (#53675) Thanks @hpt.
|
||||
- Docker/setup: avoid the pre-start `openclaw-cli` shared-network namespace loop by routing setup-time onboard/config writes through `openclaw-gateway`, so fresh Docker installs stop failing before the gateway comes up. (#53385) Thanks @amsminn.
|
||||
- WhatsApp/groups: track recent gateway-sent message IDs and suppress only matching group echoes, preserving owner `/status`, `/new`, and `/activation` commands from linked-account `fromMe` traffic. (#53624) Thanks @w-sss.
|
||||
- Runtime/build: stabilize long-lived lazy `dist` runtime entry paths and harden bundled plugin npm staging so local rebuilds stop breaking on missing hashed chunks or broken shell `npm` shims. (#53855) Thanks @vincentkoc.
|
||||
- Slack/runtime defaults: trim Slack DM reply overhead, restore Codex auto transport, and tighten Slack/web-search runtime defaults around DM preview threading, cache scoping, warning dedupe, and explicit web-search opt-in. (#53957) Thanks @vincentkoc.
|
||||
- Discord/timeouts: send a visible timeout reply when the inbound Discord worker times out before a final reply starts, including created auto-thread targets and queued-run ordering. (#53823) Thanks @Kimbo7870.
|
||||
- Models/google: normalize bare Google Generative AI API roots for custom provider names, and keep built-in Google model-id rewrites working when `api` is declared only on individual models, so custom Google lanes and older configs stop missing `/v1beta` or preview-id normalization. (#44969) Thanks @Kathie-yu.
|
||||
- Gateway/ports: parse Docker Compose-style `OPENCLAW_GATEWAY_PORT` host publish values correctly without reviving the legacy `CLAWDBOT_GATEWAY_PORT` override. (#44083) Thanks @bebule.
|
||||
- Feishu/MSTeams message tool: keep provider-native `card` payloads optional in merged tool schemas so media-only sends stop failing validation before channel runtime dispatch. (#53715) Thanks @lndyzwdxhs.
|
||||
- Feishu/startup: keep `requireMention` enforcement strict when bot identity startup probes fail, raise the startup bot-info timeout to 30s, and add cancellable background identity recovery so mention-gated groups recover without noisy fallback. (#43788) Thanks @lefarcen.
|
||||
- Gateway/systemd hints: keep generic Linux systemd-unavailable guidance broad, and only show the headless-server `loginctl enable-linger` / `XDG_RUNTIME_DIR` recovery steps when the runtime detail proves a user-bus/session failure. (#54062) Thanks @chocobo9.
|
||||
|
||||
## 2026.3.23
|
||||
|
||||
@@ -134,8 +105,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/supervision: stop lock conflicts from crash-looping under launchd and systemd by keeping the duplicate process in a retry wait instead of exiting as a failure while another healthy gateway still owns the lock. Fixes #52922. Thanks @vincentkoc.
|
||||
- Gateway/auth: require auth for canvas routes and admin scope for agent session reset, so anonymous canvas access and non-admin reset requests fail closed.
|
||||
- Release/install: keep previously released bundled plugins and Control UI assets in published openclaw npm installs, and fail release checks when those shipped artifacts are missing. Thanks @vincentkoc.
|
||||
- WhatsApp/outbound sends: keep the active Web listener on a direct process-global symbol so split runtime chunks keep sharing the connected Baileys session and `openclaw message send --channel whatsapp` stops failing after connect. Fixes #52574. Thanks @MonkeyLeeT.
|
||||
- Agents/process: fail loud when `send-keys` tries cursor-sensitive keys before a background PTY reports its cursor mode, so startup races no longer silently send the wrong arrow/Home/End sequences. (#51490) Thanks @liuy.
|
||||
|
||||
## 2026.3.22
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Shared iOS version defaults.
|
||||
// Generated overrides live in build/Version.xcconfig (git-ignored).
|
||||
|
||||
OPENCLAW_GATEWAY_VERSION = 2026.3.24-beta.1
|
||||
OPENCLAW_GATEWAY_VERSION = 2026.3.24
|
||||
OPENCLAW_MARKETING_VERSION = 2026.3.24
|
||||
OPENCLAW_BUILD_VERSION = 202603240
|
||||
|
||||
|
||||
@@ -196,7 +196,7 @@ struct OpenClawConfigFileTests {
|
||||
#expect(clobberedPath != nil)
|
||||
if let clobberedPath {
|
||||
let preserved = try String(contentsOfFile: clobberedPath, encoding: .utf8)
|
||||
#expect(preserved == clobbered)
|
||||
#expect(preserved == "\(clobbered)\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7530,16 +7530,6 @@
|
||||
"tags": [],
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "auth.profiles.*.displayName",
|
||||
"kind": "core",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "auth.profiles.*.email",
|
||||
"kind": "core",
|
||||
@@ -17117,7 +17107,8 @@
|
||||
"path": "channels.feishu.requireMention",
|
||||
"kind": "channel",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"required": true,
|
||||
"defaultValue": true,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5631}
|
||||
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5630}
|
||||
{"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true}
|
||||
{"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true}
|
||||
{"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -669,7 +669,6 @@
|
||||
{"recordType":"path","path":"auth.order.*.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"auth.profiles","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["access","auth","storage"],"label":"Auth Profiles","help":"Named auth profiles (provider + mode + optional email).","hasChildren":true}
|
||||
{"recordType":"path","path":"auth.profiles.*","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"auth.profiles.*.displayName","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"auth.profiles.*.email","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"auth.profiles.*.mode","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"auth.profiles.*.provider","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -1519,7 +1518,7 @@
|
||||
{"recordType":"path","path":"channels.feishu.reactionNotifications","kind":"channel","type":"string","required":true,"enumValues":["off","own","all"],"defaultValue":"own","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.renderMode","kind":"channel","type":"string","required":false,"enumValues":["auto","raw","card"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.replyInThread","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.requireMention","kind":"channel","type":"boolean","required":true,"defaultValue":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.resolveSenderNames","kind":"channel","type":"boolean","required":true,"defaultValue":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.streaming","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
|
||||
@@ -275,68 +275,6 @@ Triggered when the gateway starts:
|
||||
|
||||
- **`gateway:startup`**: After channels start and hooks are loaded
|
||||
|
||||
### Session Patch Events
|
||||
|
||||
Triggered when session properties are modified:
|
||||
|
||||
- **`session:patch`**: When a session is updated
|
||||
|
||||
#### Session Event Context
|
||||
|
||||
Session events include rich context about the session and changes:
|
||||
|
||||
```typescript
|
||||
{
|
||||
sessionEntry: SessionEntry, // The complete updated session entry
|
||||
patch: { // The patch object (only changed fields)
|
||||
// Session identity & labeling
|
||||
label?: string | null, // Human-readable session label
|
||||
|
||||
// AI model configuration
|
||||
model?: string | null, // Model override (e.g., "claude-opus-4-5")
|
||||
thinkingLevel?: string | null, // Thinking level ("off"|"low"|"med"|"high")
|
||||
verboseLevel?: string | null, // Verbose output level
|
||||
reasoningLevel?: string | null, // Reasoning mode override
|
||||
elevatedLevel?: string | null, // Elevated mode override
|
||||
responseUsage?: "off" | "tokens" | "full" | null, // Usage display mode
|
||||
|
||||
// Tool execution settings
|
||||
execHost?: string | null, // Exec host (sandbox|gateway|node)
|
||||
execSecurity?: string | null, // Security mode (deny|allowlist|full)
|
||||
execAsk?: string | null, // Approval mode (off|on-miss|always)
|
||||
execNode?: string | null, // Node ID for host=node
|
||||
|
||||
// Subagent coordination
|
||||
spawnedBy?: string | null, // Parent session key (for subagents)
|
||||
spawnDepth?: number | null, // Nesting depth (0 = root)
|
||||
|
||||
// Communication policies
|
||||
sendPolicy?: "allow" | "deny" | null, // Message send policy
|
||||
groupActivation?: "mention" | "always" | null, // Group chat activation
|
||||
},
|
||||
cfg: OpenClawConfig // Current gateway config
|
||||
}
|
||||
```
|
||||
|
||||
**Security note:** Only privileged clients (including the Control UI) can trigger `session:patch` events. Standard WebChat clients are blocked from patching sessions (see PR #20800), so the hook will not fire from those connections.
|
||||
|
||||
See `SessionsPatchParamsSchema` in `src/gateway/protocol/schema/sessions.ts` for the complete type definition.
|
||||
|
||||
#### Example: Session Patch Logger Hook
|
||||
|
||||
```typescript
|
||||
const handler = async (event) => {
|
||||
if (event.type !== "session" || event.action !== "patch") {
|
||||
return;
|
||||
}
|
||||
const { patch } = event.context;
|
||||
console.log(`[session-patch] Session updated: ${event.sessionKey}`);
|
||||
console.log(`[session-patch] Changes:`, patch);
|
||||
};
|
||||
|
||||
export default handler;
|
||||
```
|
||||
|
||||
### Message Events
|
||||
|
||||
Triggered when messages are received or sent:
|
||||
|
||||
@@ -316,43 +316,41 @@ After approval, you can chat normally.
|
||||
|
||||
**1. Group policy** (`channels.feishu.groupPolicy`):
|
||||
|
||||
- `"open"` = allow everyone in groups
|
||||
- `"open"` = allow everyone in groups (default)
|
||||
- `"allowlist"` = only allow `groupAllowFrom`
|
||||
- `"disabled"` = disable group messages
|
||||
|
||||
Default: `allowlist`
|
||||
**2. Mention requirement** (`channels.feishu.groups.<chat_id>.requireMention`):
|
||||
|
||||
**2. Mention requirement** (`channels.feishu.requireMention`, overridable via `channels.feishu.groups.<chat_id>.requireMention`):
|
||||
|
||||
- explicit `true` = require @mention
|
||||
- explicit `false` = respond without mentions
|
||||
- when unset and `groupPolicy: "open"` = default to `false`
|
||||
- when unset and `groupPolicy` is not `"open"` = default to `true`
|
||||
- `true` = require @mention (default)
|
||||
- `false` = respond without mentions
|
||||
|
||||
---
|
||||
|
||||
## Group configuration examples
|
||||
|
||||
### Allow all groups, no @mention required (default for open groups)
|
||||
### Allow all groups, require @mention (default)
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
feishu: {
|
||||
groupPolicy: "open",
|
||||
// Default requireMention: true
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Allow all groups, but still require @mention
|
||||
### Allow all groups, no @mention required
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
feishu: {
|
||||
groupPolicy: "open",
|
||||
requireMention: true,
|
||||
groups: {
|
||||
oc_xxx: { requireMention: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -682,10 +680,9 @@ Key options:
|
||||
| `channels.feishu.accounts.<id>.domain` | Per-account API domain override | `feishu` |
|
||||
| `channels.feishu.dmPolicy` | DM policy | `pairing` |
|
||||
| `channels.feishu.allowFrom` | DM allowlist (open_id list) | - |
|
||||
| `channels.feishu.groupPolicy` | Group policy | `allowlist` |
|
||||
| `channels.feishu.groupPolicy` | Group policy | `open` |
|
||||
| `channels.feishu.groupAllowFrom` | Group allowlist | - |
|
||||
| `channels.feishu.requireMention` | Default require @mention | conditional |
|
||||
| `channels.feishu.groups.<chat_id>.requireMention` | Per-group require @mention override | inherited |
|
||||
| `channels.feishu.groups.<chat_id>.requireMention` | Require @mention | `true` |
|
||||
| `channels.feishu.groups.<chat_id>.enabled` | Enable group | `true` |
|
||||
| `channels.feishu.textChunkLimit` | Message chunk size | `2000` |
|
||||
| `channels.feishu.mediaMaxMb` | Media size limit | `30` |
|
||||
|
||||
@@ -92,13 +92,6 @@ These run inside the agent loop or gateway pipeline:
|
||||
- **`session_start` / `session_end`**: session lifecycle boundaries.
|
||||
- **`gateway_start` / `gateway_stop`**: gateway lifecycle events.
|
||||
|
||||
Hook decision rules for outbound/tool guards:
|
||||
|
||||
- `before_tool_call`: `{ block: true }` is terminal and stops lower-priority handlers.
|
||||
- `before_tool_call`: `{ block: false }` is a no-op and does not clear a prior block.
|
||||
- `message_sending`: `{ cancel: true }` is terminal and stops lower-priority handlers.
|
||||
- `message_sending`: `{ cancel: false }` is a no-op and does not clear a prior cancel.
|
||||
|
||||
See [Plugin hooks](/plugins/architecture#provider-runtime-hooks) for the hook API and registration details.
|
||||
|
||||
## Streaming + partial replies
|
||||
|
||||
@@ -144,15 +144,6 @@ A single plugin can register any number of capabilities via the `api` object:
|
||||
|
||||
For the full registration API, see [SDK Overview](/plugins/sdk-overview#registration-api).
|
||||
|
||||
Hook guard semantics to keep in mind:
|
||||
|
||||
- `before_tool_call`: `{ block: true }` is terminal and stops lower-priority handlers.
|
||||
- `before_tool_call`: `{ block: false }` is treated as no decision.
|
||||
- `message_sending`: `{ cancel: true }` is terminal and stops lower-priority handlers.
|
||||
- `message_sending`: `{ cancel: false }` is treated as no decision.
|
||||
|
||||
See [SDK Overview hook decision semantics](/plugins/sdk-overview#hook-decision-semantics) for details.
|
||||
|
||||
## Registering agent tools
|
||||
|
||||
Tools are typed functions the LLM can call. They can be required (always
|
||||
|
||||
@@ -152,13 +152,6 @@ methods:
|
||||
| `api.on(hookName, handler, opts?)` | Typed lifecycle hook |
|
||||
| `api.onConversationBindingResolved(handler)` | Conversation binding callback |
|
||||
|
||||
### Hook decision semantics
|
||||
|
||||
- `before_tool_call`: returning `{ block: true }` is terminal. Once any handler sets it, lower-priority handlers are skipped.
|
||||
- `before_tool_call`: returning `{ block: false }` is treated as no decision (same as omitting `block`), not as an override.
|
||||
- `message_sending`: returning `{ cancel: true }` is terminal. Once any handler sets it, lower-priority handlers are skipped.
|
||||
- `message_sending`: returning `{ cancel: false }` is treated as no decision (same as omitting `cancel`), not as an override.
|
||||
|
||||
### API object fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|
||||
@@ -258,15 +258,6 @@ Common registration methods:
|
||||
| `registerContextEngine` | Context engine |
|
||||
| `registerService` | Background service |
|
||||
|
||||
Hook guard behavior for typed lifecycle hooks:
|
||||
|
||||
- `before_tool_call`: `{ block: true }` is terminal; lower-priority handlers are skipped.
|
||||
- `before_tool_call`: `{ block: false }` is a no-op and does not clear an earlier block.
|
||||
- `message_sending`: `{ cancel: true }` is terminal; lower-priority handlers are skipped.
|
||||
- `message_sending`: `{ cancel: false }` is a no-op and does not clear an earlier cancel.
|
||||
|
||||
For full typed hook behavior, see [SDK Overview](/plugins/sdk-overview#hook-decision-semantics).
|
||||
|
||||
## Related
|
||||
|
||||
- [Building Plugins](/plugins/building-plugins) — create your own plugin
|
||||
|
||||
@@ -10,7 +10,7 @@ title: "Text-to-Speech"
|
||||
# Text-to-speech (TTS)
|
||||
|
||||
OpenClaw can convert outbound replies into audio using ElevenLabs, Microsoft, or OpenAI.
|
||||
It works anywhere OpenClaw can send audio.
|
||||
It works anywhere OpenClaw can send audio; Telegram gets a round voice-note bubble.
|
||||
|
||||
## Supported services
|
||||
|
||||
@@ -170,7 +170,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
||||
}
|
||||
```
|
||||
|
||||
### Only reply with audio after an inbound voice message
|
||||
### Only reply with audio after an inbound voice note
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -203,7 +203,7 @@ Then run:
|
||||
### Notes on fields
|
||||
|
||||
- `auto`: auto‑TTS mode (`off`, `always`, `inbound`, `tagged`).
|
||||
- `inbound` only sends audio after an inbound voice message.
|
||||
- `inbound` only sends audio after an inbound voice note.
|
||||
- `tagged` only sends audio when the reply includes `[[tts]]` tags.
|
||||
- `enabled`: legacy toggle (doctor migrates this to `auto`).
|
||||
- `mode`: `"final"` (default) or `"all"` (includes tool/block replies).
|
||||
@@ -319,18 +319,18 @@ These override `messages.tts.*` for that host.
|
||||
|
||||
## Output formats (fixed)
|
||||
|
||||
- **Feishu / Matrix / Telegram / WhatsApp**: Opus voice message (`opus_48000_64` from ElevenLabs, `opus` from OpenAI).
|
||||
- 48kHz / 64kbps is a good voice message tradeoff.
|
||||
- **Telegram**: Opus voice note (`opus_48000_64` from ElevenLabs, `opus` from OpenAI).
|
||||
- 48kHz / 64kbps is a good voice-note tradeoff and required for the round bubble.
|
||||
- **Other channels**: MP3 (`mp3_44100_128` from ElevenLabs, `mp3` from OpenAI).
|
||||
- 44.1kHz / 128kbps is the default balance for speech clarity.
|
||||
- **Microsoft**: uses `microsoft.outputFormat` (default `audio-24khz-48kbitrate-mono-mp3`).
|
||||
- The bundled transport accepts an `outputFormat`, but not all formats are available from the service.
|
||||
- Output format values follow Microsoft Speech output formats (including Ogg/WebM Opus).
|
||||
- Telegram `sendVoice` accepts OGG/MP3/M4A; use OpenAI/ElevenLabs if you need
|
||||
guaranteed Opus voice messages.
|
||||
guaranteed Opus voice notes. citeturn1search1
|
||||
- If the configured Microsoft output format fails, OpenClaw retries with MP3.
|
||||
|
||||
OpenAI/ElevenLabs output formats are fixed per channel (see above).
|
||||
OpenAI/ElevenLabs formats are fixed; Telegram expects Opus for voice-note UX.
|
||||
|
||||
## Auto-TTS behavior
|
||||
|
||||
@@ -391,8 +391,8 @@ Notes:
|
||||
## Agent tool
|
||||
|
||||
The `tts` tool converts text to speech and returns an audio attachment for
|
||||
reply delivery. When the channel is Feishu, Matrix, Telegram, or WhatsApp,
|
||||
the audio is delivered as a voice message rather than a file attachment.
|
||||
reply delivery. When the result is Telegram-compatible, OpenClaw marks it for
|
||||
voice-bubble delivery.
|
||||
|
||||
## Gateway RPC
|
||||
|
||||
|
||||
18
docs/tts.md
18
docs/tts.md
@@ -10,7 +10,7 @@ title: "Text-to-Speech (legacy path)"
|
||||
# Text-to-speech (TTS)
|
||||
|
||||
OpenClaw can convert outbound replies into audio using ElevenLabs, Microsoft, or OpenAI.
|
||||
It works anywhere OpenClaw can send audio.
|
||||
It works anywhere OpenClaw can send audio; Telegram gets a round voice-note bubble.
|
||||
|
||||
## Supported services
|
||||
|
||||
@@ -170,7 +170,7 @@ Full schema is in [Gateway configuration](/gateway/configuration).
|
||||
}
|
||||
```
|
||||
|
||||
### Only reply with audio after an inbound voice message
|
||||
### Only reply with audio after an inbound voice note
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -203,7 +203,7 @@ Then run:
|
||||
### Notes on fields
|
||||
|
||||
- `auto`: auto‑TTS mode (`off`, `always`, `inbound`, `tagged`).
|
||||
- `inbound` only sends audio after an inbound voice message.
|
||||
- `inbound` only sends audio after an inbound voice note.
|
||||
- `tagged` only sends audio when the reply includes `[[tts]]` tags.
|
||||
- `enabled`: legacy toggle (doctor migrates this to `auto`).
|
||||
- `mode`: `"final"` (default) or `"all"` (includes tool/block replies).
|
||||
@@ -319,18 +319,18 @@ These override `messages.tts.*` for that host.
|
||||
|
||||
## Output formats (fixed)
|
||||
|
||||
- **Feishu / Matrix / Telegram / WhatsApp**: Opus voice message (`opus_48000_64` from ElevenLabs, `opus` from OpenAI).
|
||||
- 48kHz / 64kbps is a good voice message tradeoff.
|
||||
- **Telegram**: Opus voice note (`opus_48000_64` from ElevenLabs, `opus` from OpenAI).
|
||||
- 48kHz / 64kbps is a good voice-note tradeoff and required for the round bubble.
|
||||
- **Other channels**: MP3 (`mp3_44100_128` from ElevenLabs, `mp3` from OpenAI).
|
||||
- 44.1kHz / 128kbps is the default balance for speech clarity.
|
||||
- **Microsoft**: uses `microsoft.outputFormat` (default `audio-24khz-48kbitrate-mono-mp3`).
|
||||
- The bundled transport accepts an `outputFormat`, but not all formats are available from the service.
|
||||
- Output format values follow Microsoft Speech output formats (including Ogg/WebM Opus).
|
||||
- Telegram `sendVoice` accepts OGG/MP3/M4A; use OpenAI/ElevenLabs if you need
|
||||
guaranteed Opus voice messages.
|
||||
guaranteed Opus voice notes. citeturn1search1
|
||||
- If the configured Microsoft output format fails, OpenClaw retries with MP3.
|
||||
|
||||
OpenAI/ElevenLabs output formats are fixed per channel (see above).
|
||||
OpenAI/ElevenLabs formats are fixed; Telegram expects Opus for voice-note UX.
|
||||
|
||||
## Auto-TTS behavior
|
||||
|
||||
@@ -391,8 +391,8 @@ Notes:
|
||||
## Agent tool
|
||||
|
||||
The `tts` tool converts text to speech and returns an audio attachment for
|
||||
reply delivery. When the channel is Feishu, Matrix, Telegram, or WhatsApp,
|
||||
the audio is delivered as a voice message rather than a file attachment.
|
||||
reply delivery. When the result is Telegram-compatible, OpenClaw marks it for
|
||||
voice-bubble delivery.
|
||||
|
||||
## Gateway RPC
|
||||
|
||||
|
||||
@@ -1,557 +0,0 @@
|
||||
# Plugin SDK Namespaces Plan
|
||||
|
||||
## TL;DR
|
||||
|
||||
OpenClaw should introduce a few clear SDK namespaces like `plugin`, `channel`,
|
||||
and `provider`, instead of keeping so much of the public surface flat.
|
||||
|
||||
The safe way to do that is:
|
||||
|
||||
- add thin ESM facade entrypoints, not TypeScript `namespace`
|
||||
- keep the root `openclaw/plugin-sdk` surface small
|
||||
- replace flat registration methods on `OpenClawPluginApi` with namespace groups
|
||||
- ship the cutover in one coordinated release instead of dragging old flat APIs
|
||||
along
|
||||
- forbid leaf modules from importing back through namespace facades
|
||||
|
||||
That gives plugin authors a cleaner SDK that feels closer to VS Code, without
|
||||
turning the SDK into a giant barrel or creating circular import problems.
|
||||
|
||||
## Goal
|
||||
|
||||
Introduce public namespaces to the OpenClaw Plugin SDK so the surface feels
|
||||
closer to the VS Code extension API, while keeping the implementation tight,
|
||||
isolated, and resistant to circular imports.
|
||||
|
||||
This plan is about the public SDK shape. It is not a proposal to merge
|
||||
everything into one giant barrel.
|
||||
|
||||
## Why This Is Worth Doing
|
||||
|
||||
Today the Plugin SDK has three visible problems:
|
||||
|
||||
- The public package export surface is large and mostly flat.
|
||||
- `src/plugin-sdk/core.ts` and `src/plugin-sdk/index.ts` carry too many
|
||||
unrelated meanings.
|
||||
- `OpenClawPluginApi` is still a flat registration API even though
|
||||
`api.runtime` already proves grouped namespaces work well.
|
||||
|
||||
The result is harder docs, harder discovery, and too many helper names that
|
||||
look equally important even when they are not.
|
||||
|
||||
## Current Facts In The Repo
|
||||
|
||||
- Package exports are generated from a flat entrypoint list in
|
||||
`src/plugin-sdk/entrypoints.ts` and `scripts/lib/plugin-sdk-entrypoints.json`.
|
||||
- The root `openclaw/plugin-sdk` entry is intentionally tiny in
|
||||
`src/plugin-sdk/index.ts`.
|
||||
- `api.runtime` is already a successful namespace model. It groups behavior as
|
||||
`agent`, `subagent`, `media`, `imageGeneration`, `webSearch`, `tools`,
|
||||
`channel`, `events`, `logging`, `state`, `tts`, `mediaUnderstanding`, and
|
||||
`modelAuth` in `src/plugins/runtime/index.ts`.
|
||||
- The main plugin registration API is still flat in `OpenClawPluginApi` in
|
||||
`src/plugins/types.ts`.
|
||||
- The concrete API object is assembled in `src/plugins/registry.ts`, and a
|
||||
second partial copy exists in `src/plugins/captured-registration.ts`.
|
||||
|
||||
Those facts suggest a path that is low-risk:
|
||||
|
||||
- keep leaf modules as the source of truth
|
||||
- add namespace facades on top
|
||||
- cut docs, examples, and templates over in the same release as the namespace
|
||||
model
|
||||
|
||||
## Design Principles
|
||||
|
||||
### 1. Do Not Use TypeScript `namespace`
|
||||
|
||||
Use normal ESM modules and package exports.
|
||||
|
||||
The SDK already ships as package export subpaths. The namespace model should be
|
||||
implemented as public facade modules, not TypeScript `namespace` syntax.
|
||||
|
||||
### 2. Keep The Root Tiny
|
||||
|
||||
Do not turn `openclaw/plugin-sdk` into a giant VS Code-style monolith.
|
||||
|
||||
The closest safe equivalent is:
|
||||
|
||||
- a tiny root for shared types and a few universal values
|
||||
- a small number of explicit namespace entrypoints
|
||||
- optional ergonomic aggregation only after the namespace surfaces settle
|
||||
|
||||
### 3. Namespace Facades Must Be Thin
|
||||
|
||||
Namespace entrypoints should contain no real business logic.
|
||||
|
||||
They should only:
|
||||
|
||||
- re-export stable leaves
|
||||
- assemble small namespace objects
|
||||
|
||||
That keeps cycles and accidental coupling down.
|
||||
|
||||
### 4. Types Stay Direct And Easy To Import
|
||||
|
||||
Like VS Code, namespaces should mostly group behavior. Common types should stay
|
||||
directly importable from the root or the owning domain surface.
|
||||
|
||||
Examples:
|
||||
|
||||
- `ChannelPlugin`
|
||||
- `ProviderPlugin`
|
||||
- `OpenClawPluginApi`
|
||||
- `PluginRuntime`
|
||||
|
||||
### 5. Do Not Namespace Everything At Once
|
||||
|
||||
Only namespace areas that already have a clear public identity.
|
||||
|
||||
Phase 1 should focus on:
|
||||
|
||||
- `plugin`
|
||||
- `channel`
|
||||
- `provider`
|
||||
|
||||
`runtime` already has a good public namespace shape on `api.runtime` and should
|
||||
not be reopened as a giant package-export family in the first pass.
|
||||
|
||||
## Proposed Public Model
|
||||
|
||||
### Namespace Entry Points
|
||||
|
||||
Canonical public entrypoints:
|
||||
|
||||
- `openclaw/plugin-sdk/plugin`
|
||||
- `openclaw/plugin-sdk/channel`
|
||||
- `openclaw/plugin-sdk/provider`
|
||||
- `openclaw/plugin-sdk/runtime`
|
||||
- `openclaw/plugin-sdk/testing`
|
||||
|
||||
What each should mean:
|
||||
|
||||
- `plugin`
|
||||
- plugin entry helpers
|
||||
- shared plugin definition helpers
|
||||
- plugin-facing config schema helpers that are truly universal
|
||||
- `channel`
|
||||
- channel entry helpers
|
||||
- chat-channel builders
|
||||
- stable channel-facing contracts and helpers
|
||||
- `provider`
|
||||
- provider entry helpers
|
||||
- auth, catalog, models, onboard, stream, usage, and provider registration helpers
|
||||
- `runtime`
|
||||
- the existing `api.runtime` story and runtime-related public helpers that are
|
||||
truly stable
|
||||
- `testing`
|
||||
- plugin author testing helpers
|
||||
|
||||
### Nested Leaves
|
||||
|
||||
Under those namespaces, the long-term canonical leaves should become nested:
|
||||
|
||||
- `openclaw/plugin-sdk/channel/setup`
|
||||
- `openclaw/plugin-sdk/channel/pairing`
|
||||
- `openclaw/plugin-sdk/channel/reply-pipeline`
|
||||
- `openclaw/plugin-sdk/channel/contract`
|
||||
- `openclaw/plugin-sdk/channel/targets`
|
||||
- `openclaw/plugin-sdk/channel/actions`
|
||||
- `openclaw/plugin-sdk/channel/inbound`
|
||||
- `openclaw/plugin-sdk/channel/lifecycle`
|
||||
- `openclaw/plugin-sdk/channel/policy`
|
||||
- `openclaw/plugin-sdk/channel/feedback`
|
||||
- `openclaw/plugin-sdk/channel/config-schema`
|
||||
- `openclaw/plugin-sdk/channel/config-helpers`
|
||||
|
||||
- `openclaw/plugin-sdk/provider/auth`
|
||||
- `openclaw/plugin-sdk/provider/catalog`
|
||||
- `openclaw/plugin-sdk/provider/models`
|
||||
- `openclaw/plugin-sdk/provider/onboard`
|
||||
- `openclaw/plugin-sdk/provider/stream`
|
||||
- `openclaw/plugin-sdk/provider/usage`
|
||||
- `openclaw/plugin-sdk/provider/web-search`
|
||||
|
||||
Not every current flat subpath needs a namespaced replacement. The goal is to
|
||||
promote the stable public domains, not to preserve every current export forever.
|
||||
|
||||
## What Happens To `core`
|
||||
|
||||
`core` is overloaded today. In a namespace model it should shrink, not grow.
|
||||
|
||||
Target split:
|
||||
|
||||
- plugin-wide entry helpers move toward `plugin`
|
||||
- channel builders and channel-oriented shared helpers move toward `channel`
|
||||
- `core` stops being a first-class public destination and shrinks to the
|
||||
smallest possible remaining shared surface
|
||||
|
||||
Rule: no new public API should be added to `core` once namespace entrypoints
|
||||
exist.
|
||||
|
||||
## Proposed `OpenClawPluginApi` Shape
|
||||
|
||||
Keep context fields flat:
|
||||
|
||||
- `id`
|
||||
- `name`
|
||||
- `version`
|
||||
- `description`
|
||||
- `source`
|
||||
- `rootDir`
|
||||
- `registrationMode`
|
||||
- `config`
|
||||
- `pluginConfig`
|
||||
- `runtime`
|
||||
- `logger`
|
||||
- `resolvePath`
|
||||
|
||||
Move registration behavior behind namespaces:
|
||||
|
||||
| Current flat method | Proposed namespace location |
|
||||
| ------------------------------------ | ----------------------------------------- |
|
||||
| `registerTool` | `api.tool.register` |
|
||||
| `registerHook` | `api.hook.register` |
|
||||
| `on` | `api.hook.on` |
|
||||
| `registerHttpRoute` | `api.http.registerRoute` |
|
||||
| `registerChannel` | `api.channel.register` |
|
||||
| `registerProvider` | `api.provider.register` |
|
||||
| `registerSpeechProvider` | `api.provider.registerSpeech` |
|
||||
| `registerMediaUnderstandingProvider` | `api.provider.registerMediaUnderstanding` |
|
||||
| `registerImageGenerationProvider` | `api.provider.registerImageGeneration` |
|
||||
| `registerWebSearchProvider` | `api.provider.registerWebSearch` |
|
||||
| `registerGatewayMethod` | `api.gateway.registerMethod` |
|
||||
| `registerCli` | `api.cli.register` |
|
||||
| `registerService` | `api.service.register` |
|
||||
| `registerInteractiveHandler` | `api.interactive.register` |
|
||||
| `registerCommand` | `api.command.register` |
|
||||
| `registerContextEngine` | `api.contextEngine.register` |
|
||||
| `registerMemoryPromptSection` | `api.memory.registerPromptSection` |
|
||||
|
||||
The cutover should replace the flat methods in one coordinated change.
|
||||
|
||||
That gives plugin authors a clearer public shape and avoids carrying two public
|
||||
registration models at the same time.
|
||||
|
||||
## Example Public Usage
|
||||
|
||||
Proposed style:
|
||||
|
||||
```ts
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin";
|
||||
import { channel } from "openclaw/plugin-sdk/channel";
|
||||
import { provider } from "openclaw/plugin-sdk/provider";
|
||||
import type { ChannelPlugin, OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
|
||||
const chatPlugin: ChannelPlugin = channel.createChatPlugin({
|
||||
id: "demo",
|
||||
/* ... */
|
||||
});
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "demo",
|
||||
register(api: OpenClawPluginApi) {
|
||||
api.channel.register(chatPlugin);
|
||||
api.command.register({
|
||||
name: "status",
|
||||
description: "Show plugin status",
|
||||
run: async () => ({ text: "ok" }),
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
This is close to the VS Code mental model:
|
||||
|
||||
- grouped behavior
|
||||
- direct types
|
||||
- obvious public areas
|
||||
|
||||
without requiring a single monolithic root import.
|
||||
|
||||
## Optional Ergonomic Surface
|
||||
|
||||
If the project later wants the closest possible VS Code feel, add a dedicated
|
||||
opt-in facade such as `openclaw/plugin-sdk/sdk`.
|
||||
|
||||
That facade can assemble:
|
||||
|
||||
- `plugin`
|
||||
- `channel`
|
||||
- `provider`
|
||||
- `runtime`
|
||||
- `testing`
|
||||
|
||||
It should not be phase 1.
|
||||
|
||||
Why:
|
||||
|
||||
- it is the highest-risk barrel from a cycle and weight perspective
|
||||
- it is easier to add once the namespace surfaces already exist
|
||||
- it preserves the root `openclaw/plugin-sdk` entry as a small type-oriented
|
||||
surface
|
||||
|
||||
## Internal Implementation Rules
|
||||
|
||||
These rules are the important part. Without them, namespaces will rot into
|
||||
barrels and cycles.
|
||||
|
||||
### Rule 1: Namespace Facades Are One-Way
|
||||
|
||||
Namespace entrypoints may import leaf modules.
|
||||
|
||||
Leaf modules may not import their namespace entrypoint.
|
||||
|
||||
Examples:
|
||||
|
||||
- allowed: `src/plugin-sdk/channel.ts` importing `./channel-setup.ts`
|
||||
- forbidden: `src/plugin-sdk/channel-setup.ts` importing `./channel.ts`
|
||||
|
||||
### Rule 1A: Allowed Dependency Directions Must Be Explicit
|
||||
|
||||
The allowed directions should be:
|
||||
|
||||
- namespace facade -> leaves in the same namespace
|
||||
- leaf -> local implementation helpers
|
||||
- leaf -> dedicated shared internal leaf
|
||||
- leaf -> another leaf in the same namespace only by direct relative import,
|
||||
never through the namespace facade
|
||||
|
||||
The forbidden directions should be:
|
||||
|
||||
- leaf -> its own namespace facade
|
||||
- leaf -> another namespace facade
|
||||
- namespace facade -> another namespace facade
|
||||
- channel leaf -> provider leaf, or provider leaf -> channel leaf, unless the
|
||||
dependency is first extracted into a shared internal leaf
|
||||
|
||||
Short version:
|
||||
|
||||
- facades point downward
|
||||
- leaves never point back upward
|
||||
- cross-namespace sharing must go sideways through a shared internal leaf, not
|
||||
directly through another public namespace
|
||||
|
||||
### Rule 1B: If Two Namespaces Need Each Other, Extract A Shared Leaf
|
||||
|
||||
If `channel` and `provider` start needing each other directly, that is the sign
|
||||
that the seam is wrong.
|
||||
|
||||
Do not allow:
|
||||
|
||||
- `src/plugin-sdk/channel/*` importing from `src/plugin-sdk/provider/*`
|
||||
- `src/plugin-sdk/provider/*` importing from `src/plugin-sdk/channel/*`
|
||||
|
||||
Instead:
|
||||
|
||||
- extract the shared logic into a dedicated internal leaf
|
||||
- let both sides depend on that leaf
|
||||
- keep the public namespaces separate
|
||||
|
||||
This is the main cycle-prevention rule. Shared logic moves to a lower layer
|
||||
before it creates a back-edge.
|
||||
|
||||
### Rule 2: No Public-Specifier Self-Imports Inside The SDK
|
||||
|
||||
Files inside `src/plugin-sdk/**` should never import from
|
||||
`openclaw/plugin-sdk/...`.
|
||||
|
||||
They should import local source files directly.
|
||||
|
||||
### Rule 3: Shared Code Lives In Shared Leaves
|
||||
|
||||
If `channel` and `provider` need the same implementation detail, move that code
|
||||
to a shared leaf instead of importing one namespace from the other.
|
||||
|
||||
Good shared homes:
|
||||
|
||||
- a dedicated internal shared leaf
|
||||
- a very small shared core leaf only if it has a precise, stable reason to
|
||||
exist
|
||||
- existing domain-neutral helpers
|
||||
|
||||
Bad pattern:
|
||||
|
||||
- `provider/*` importing from `channel/index`
|
||||
- `channel/*` importing from `provider/index`
|
||||
|
||||
### Rule 4: Assemble The API Surface Once
|
||||
|
||||
`OpenClawPluginApi` should be built by one canonical factory.
|
||||
|
||||
`src/plugins/registry.ts` and `src/plugins/captured-registration.ts` should stop
|
||||
hand-building separate versions of the API object.
|
||||
|
||||
That factory can expose:
|
||||
|
||||
- the namespaced shape only
|
||||
|
||||
from the same underlying implementation.
|
||||
|
||||
### Rule 5: Namespace Entry Files Stay Small
|
||||
|
||||
Namespace facades should stay close to pure exports. If a namespace file grows
|
||||
real orchestration logic, split that logic back into leaf modules.
|
||||
|
||||
### Dependency Shape
|
||||
|
||||
The intended import graph is:
|
||||
|
||||
```text
|
||||
public facade
|
||||
-> same-namespace leaves
|
||||
-> local helpers
|
||||
-> shared internal leaves
|
||||
```
|
||||
|
||||
Not this:
|
||||
|
||||
```text
|
||||
channel facade -> provider facade
|
||||
channel leaf -> channel facade
|
||||
provider leaf -> channel leaf
|
||||
```
|
||||
|
||||
Concrete examples:
|
||||
|
||||
- allowed: `src/plugin-sdk/channel.ts` -> `./channel/setup.ts`
|
||||
- allowed: `src/plugin-sdk/channel/setup.ts` -> `./_internal/channel-shared.ts`
|
||||
- allowed: `src/plugin-sdk/provider/auth.ts` -> `../_internal/provider-shared.ts`
|
||||
- forbidden: `src/plugin-sdk/channel/setup.ts` -> `./channel.ts`
|
||||
- forbidden: `src/plugin-sdk/channel/setup.ts` -> `../provider/index.ts`
|
||||
- forbidden: `src/plugin-sdk/channel.ts` -> `./provider.ts`
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
This should be a cutover, not a long overlap period.
|
||||
|
||||
That means:
|
||||
|
||||
- one coordinated release
|
||||
- one migration guide
|
||||
- one docs/templates/test update
|
||||
- one public SDK shape after the release
|
||||
|
||||
## Phase 1: Extract The Canonical API Builder
|
||||
|
||||
Do this first, before changing the public surface.
|
||||
|
||||
Why:
|
||||
|
||||
- it removes duplicated API assembly
|
||||
- it gives one place to switch the public shape
|
||||
- it reduces cutover risk
|
||||
|
||||
Implementation:
|
||||
|
||||
- extract one canonical API builder from `src/plugins/registry.ts` and
|
||||
`src/plugins/captured-registration.ts`
|
||||
- make that builder assemble the new namespaced registration API
|
||||
|
||||
## Phase 2: Add Canonical Namespace Entrypoints
|
||||
|
||||
Add:
|
||||
|
||||
- `plugin`
|
||||
- `channel`
|
||||
- `provider`
|
||||
|
||||
as thin public facades over existing flat leaves.
|
||||
|
||||
Implementation detail:
|
||||
|
||||
- the first pass can re-export current flat files
|
||||
- do not move source layout and package exports in the same commit if it can be
|
||||
avoided
|
||||
|
||||
Examples:
|
||||
|
||||
- `src/plugin-sdk/channel/setup.ts` can initially re-export from
|
||||
`../channel-setup.js`
|
||||
- `src/plugin-sdk/provider/auth.ts` can initially re-export from
|
||||
`../provider-auth.js`
|
||||
|
||||
This lets the public namespace story land before the internal source move,
|
||||
without forcing all implementation files to move in the same commit.
|
||||
|
||||
## Phase 3: Cut Public API, Docs, And Templates Together
|
||||
|
||||
In the same release:
|
||||
|
||||
- docs prefer namespaced entrypoints
|
||||
- templates prefer namespaced imports
|
||||
- tests and examples switch to the namespaced shape
|
||||
- `OpenClawPluginApi` changes to the namespaced registration model
|
||||
- flat registration methods are removed instead of carried as aliases
|
||||
|
||||
## Phase 4: Remove The Old Public Story
|
||||
|
||||
After the cutover release lands:
|
||||
|
||||
- stop documenting superseded flat leaves as public API
|
||||
- keep only the namespace model in author-facing docs
|
||||
- remove any leftover flat registration surface that survived only as
|
||||
transitional scaffolding during implementation
|
||||
|
||||
## What Should Not Be Namespaced In Phase 1
|
||||
|
||||
To keep the refactor tight, do not force these into the first milestone:
|
||||
|
||||
- every `*-runtime` helper subpath
|
||||
- extension-branded public subpaths
|
||||
- one-off utilities that do not yet have a stable domain home
|
||||
- the root `openclaw/plugin-sdk` barrel
|
||||
|
||||
If a subpath is only public because history leaked it, namespace work should not
|
||||
promote it.
|
||||
|
||||
## Guardrails And Validation
|
||||
|
||||
The namespace rollout should ship with explicit checks.
|
||||
|
||||
### Existing Checks To Reuse
|
||||
|
||||
- `src/plugin-sdk/subpaths.test.ts`
|
||||
- `src/plugin-sdk/runtime-api-guardrails.test.ts`
|
||||
- `pnpm build` for `[CIRCULAR_REEXPORT]` warnings
|
||||
- `pnpm plugin-sdk:api:check`
|
||||
|
||||
### New Checks To Add
|
||||
|
||||
- namespace facade files may only re-export or compose approved leaves
|
||||
- leaf files under a namespace may not import their parent `index` facade
|
||||
- leaf files under one namespace may not import another namespace facade
|
||||
- cross-namespace leaf imports should fail unless the target is an approved
|
||||
shared internal leaf
|
||||
- namespace facades may not import other namespace facades
|
||||
- no new API should be added to `core` once namespace facades exist
|
||||
- `OpenClawPluginApi` must not expose both flat and namespaced registration
|
||||
methods after cutover
|
||||
|
||||
## Recommended End State
|
||||
|
||||
The elegant end state is:
|
||||
|
||||
- a tiny root
|
||||
- a few first-class namespaces
|
||||
- direct types
|
||||
- a grouped `api` registration surface
|
||||
- stable leaves under each namespace
|
||||
- no reverse imports from leaves back into namespace facades
|
||||
|
||||
That gives OpenClaw a VS Code-like feel where the public SDK has clear domains,
|
||||
but still respects the repo's existing build, lazy-loading, and package-boundary
|
||||
constraints.
|
||||
|
||||
## Short Recommendation
|
||||
|
||||
If this work starts soon, the first implementation step should be:
|
||||
|
||||
1. extract one canonical `OpenClawPluginApi` builder
|
||||
2. switch that builder to the namespaced registration shape
|
||||
3. add `plugin`, `channel`, and `provider` facade entrypoints
|
||||
4. cut docs, templates, and examples over in the same release
|
||||
5. remove the old flat registration story instead of maintaining dual public APIs
|
||||
|
||||
That sequence keeps the refactor elegant and minimizes the chance that
|
||||
namespaces become another layer of accidental coupling.
|
||||
25
extensions/bluebubbles/src/accounts.test.ts
Normal file
25
extensions/bluebubbles/src/accounts.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
|
||||
describe("resolveBlueBubblesAccount", () => {
|
||||
it("treats SecretRef passwords as configured when serverUrl exists", () => {
|
||||
const resolved = resolveBlueBubblesAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
enabled: true,
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "BLUEBUBBLES_PASSWORD",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.configured).toBe(true);
|
||||
expect(resolved.baseUrl).toBe("http://localhost:1234");
|
||||
});
|
||||
});
|
||||
67
extensions/bluebubbles/src/config-schema.test.ts
Normal file
67
extensions/bluebubbles/src/config-schema.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BlueBubblesConfigSchema } from "./config-schema.js";
|
||||
|
||||
describe("BlueBubblesConfigSchema", () => {
|
||||
it("accepts account config when serverUrl and password are both set", () => {
|
||||
const parsed = BlueBubblesConfigSchema.safeParse({
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "secret", // pragma: allowlist secret
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts SecretRef password when serverUrl is set", () => {
|
||||
const parsed = BlueBubblesConfigSchema.safeParse({
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "BLUEBUBBLES_PASSWORD",
|
||||
},
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("requires password when top-level serverUrl is configured", () => {
|
||||
const parsed = BlueBubblesConfigSchema.safeParse({
|
||||
serverUrl: "http://localhost:1234",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
if (parsed.success) {
|
||||
return;
|
||||
}
|
||||
expect(parsed.error.issues[0]?.path).toEqual(["password"]);
|
||||
expect(parsed.error.issues[0]?.message).toBe(
|
||||
"password is required when serverUrl is configured",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires password when account serverUrl is configured", () => {
|
||||
const parsed = BlueBubblesConfigSchema.safeParse({
|
||||
accounts: {
|
||||
work: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
if (parsed.success) {
|
||||
return;
|
||||
}
|
||||
expect(parsed.error.issues[0]?.path).toEqual(["accounts", "work", "password"]);
|
||||
expect(parsed.error.issues[0]?.message).toBe(
|
||||
"password is required when serverUrl is configured",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows password omission when serverUrl is not configured", () => {
|
||||
const parsed = BlueBubblesConfigSchema.safeParse({
|
||||
accounts: {
|
||||
work: {
|
||||
name: "Work iMessage",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
});
|
||||
36
extensions/bluebubbles/src/group-policy.test.ts
Normal file
36
extensions/bluebubbles/src/group-policy.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveBlueBubblesGroupRequireMention,
|
||||
resolveBlueBubblesGroupToolPolicy,
|
||||
} from "./group-policy.js";
|
||||
|
||||
describe("bluebubbles group policy", () => {
|
||||
it("uses generic channel group policy helpers", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
groups: {
|
||||
"chat:primary": {
|
||||
requireMention: false,
|
||||
tools: { deny: ["exec"] },
|
||||
},
|
||||
"*": {
|
||||
requireMention: true,
|
||||
tools: { allow: ["message.send"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
} as any;
|
||||
|
||||
expect(resolveBlueBubblesGroupRequireMention({ cfg, groupId: "chat:primary" })).toBe(false);
|
||||
expect(resolveBlueBubblesGroupRequireMention({ cfg, groupId: "chat:other" })).toBe(true);
|
||||
expect(resolveBlueBubblesGroupToolPolicy({ cfg, groupId: "chat:primary" })).toEqual({
|
||||
deny: ["exec"],
|
||||
});
|
||||
expect(resolveBlueBubblesGroupToolPolicy({ cfg, groupId: "chat:other" })).toEqual({
|
||||
allow: ["message.send"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,20 +8,6 @@ import {
|
||||
type WizardPrompter,
|
||||
} from "../../../test/helpers/extensions/setup-wizard.js";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { BlueBubblesConfigSchema } from "./config-schema.js";
|
||||
import {
|
||||
resolveBlueBubblesGroupRequireMention,
|
||||
resolveBlueBubblesGroupToolPolicy,
|
||||
} from "./group-policy.js";
|
||||
import {
|
||||
inferBlueBubblesTargetChatType,
|
||||
isAllowedBlueBubblesSender,
|
||||
looksLikeBlueBubblesExplicitTargetId,
|
||||
looksLikeBlueBubblesTargetId,
|
||||
normalizeBlueBubblesMessagingTarget,
|
||||
parseBlueBubblesAllowTarget,
|
||||
parseBlueBubblesTarget,
|
||||
} from "./targets.js";
|
||||
import { DEFAULT_WEBHOOK_PATH } from "./webhook-shared.js";
|
||||
|
||||
async function createBlueBubblesConfigureAdapter() {
|
||||
@@ -152,337 +138,3 @@ describe("bluebubbles setup surface", () => {
|
||||
expect(next?.channels?.bluebubbles?.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveBlueBubblesAccount", () => {
|
||||
it("treats SecretRef passwords as configured when serverUrl exists", () => {
|
||||
const resolved = resolveBlueBubblesAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
enabled: true,
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "BLUEBUBBLES_PASSWORD",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.configured).toBe(true);
|
||||
expect(resolved.baseUrl).toBe("http://localhost:1234");
|
||||
});
|
||||
});
|
||||
|
||||
describe("BlueBubblesConfigSchema", () => {
|
||||
it("accepts account config when serverUrl and password are both set", () => {
|
||||
const parsed = BlueBubblesConfigSchema.safeParse({
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "secret", // pragma: allowlist secret
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts SecretRef password when serverUrl is set", () => {
|
||||
const parsed = BlueBubblesConfigSchema.safeParse({
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "BLUEBUBBLES_PASSWORD",
|
||||
},
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("requires password when top-level serverUrl is configured", () => {
|
||||
const parsed = BlueBubblesConfigSchema.safeParse({
|
||||
serverUrl: "http://localhost:1234",
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
if (parsed.success) {
|
||||
return;
|
||||
}
|
||||
expect(parsed.error.issues[0]?.path).toEqual(["password"]);
|
||||
expect(parsed.error.issues[0]?.message).toBe(
|
||||
"password is required when serverUrl is configured",
|
||||
);
|
||||
});
|
||||
|
||||
it("requires password when account serverUrl is configured", () => {
|
||||
const parsed = BlueBubblesConfigSchema.safeParse({
|
||||
accounts: {
|
||||
work: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
if (parsed.success) {
|
||||
return;
|
||||
}
|
||||
expect(parsed.error.issues[0]?.path).toEqual(["accounts", "work", "password"]);
|
||||
expect(parsed.error.issues[0]?.message).toBe(
|
||||
"password is required when serverUrl is configured",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows password omission when serverUrl is not configured", () => {
|
||||
const parsed = BlueBubblesConfigSchema.safeParse({
|
||||
accounts: {
|
||||
work: {
|
||||
name: "Work iMessage",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bluebubbles group policy", () => {
|
||||
it("uses generic channel group policy helpers", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
groups: {
|
||||
"chat:primary": {
|
||||
requireMention: false,
|
||||
tools: { deny: ["exec"] },
|
||||
},
|
||||
"*": {
|
||||
requireMention: true,
|
||||
tools: { allow: ["message.send"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
} as any;
|
||||
|
||||
expect(resolveBlueBubblesGroupRequireMention({ cfg, groupId: "chat:primary" })).toBe(false);
|
||||
expect(resolveBlueBubblesGroupRequireMention({ cfg, groupId: "chat:other" })).toBe(true);
|
||||
expect(resolveBlueBubblesGroupToolPolicy({ cfg, groupId: "chat:primary" })).toEqual({
|
||||
deny: ["exec"],
|
||||
});
|
||||
expect(resolveBlueBubblesGroupToolPolicy({ cfg, groupId: "chat:other" })).toEqual({
|
||||
allow: ["message.send"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeBlueBubblesMessagingTarget", () => {
|
||||
it("normalizes chat_guid targets", () => {
|
||||
expect(normalizeBlueBubblesMessagingTarget("chat_guid:ABC-123")).toBe("chat_guid:ABC-123");
|
||||
});
|
||||
|
||||
it("normalizes group numeric targets to chat_id", () => {
|
||||
expect(normalizeBlueBubblesMessagingTarget("group:123")).toBe("chat_id:123");
|
||||
});
|
||||
|
||||
it("strips provider prefix and normalizes handles", () => {
|
||||
expect(normalizeBlueBubblesMessagingTarget("bluebubbles:imessage:User@Example.com")).toBe(
|
||||
"imessage:user@example.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("extracts handle from DM chat_guid for cross-context matching", () => {
|
||||
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;+19257864429")).toBe(
|
||||
"+19257864429",
|
||||
);
|
||||
expect(normalizeBlueBubblesMessagingTarget("chat_guid:SMS;-;+15551234567")).toBe(
|
||||
"+15551234567",
|
||||
);
|
||||
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;user@example.com")).toBe(
|
||||
"user@example.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves group chat_guid format", () => {
|
||||
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;+;chat123456789")).toBe(
|
||||
"chat_guid:iMessage;+;chat123456789",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes raw chat_guid values", () => {
|
||||
expect(normalizeBlueBubblesMessagingTarget("iMessage;+;chat660250192681427962")).toBe(
|
||||
"chat_guid:iMessage;+;chat660250192681427962",
|
||||
);
|
||||
expect(normalizeBlueBubblesMessagingTarget("iMessage;-;+19257864429")).toBe("+19257864429");
|
||||
});
|
||||
|
||||
it("normalizes chat<digits> pattern to chat_identifier format", () => {
|
||||
expect(normalizeBlueBubblesMessagingTarget("chat660250192681427962")).toBe(
|
||||
"chat_identifier:chat660250192681427962",
|
||||
);
|
||||
expect(normalizeBlueBubblesMessagingTarget("chat123")).toBe("chat_identifier:chat123");
|
||||
expect(normalizeBlueBubblesMessagingTarget("Chat456789")).toBe("chat_identifier:Chat456789");
|
||||
});
|
||||
|
||||
it("normalizes UUID/hex chat identifiers", () => {
|
||||
expect(normalizeBlueBubblesMessagingTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toBe(
|
||||
"chat_identifier:8b9c1a10536d4d86a336ea03ab7151cc",
|
||||
);
|
||||
expect(normalizeBlueBubblesMessagingTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe(
|
||||
"chat_identifier:1C2D3E4F-1234-5678-9ABC-DEF012345678",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("looksLikeBlueBubblesTargetId", () => {
|
||||
it("accepts chat targets", () => {
|
||||
expect(looksLikeBlueBubblesTargetId("chat_guid:ABC-123")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts email handles", () => {
|
||||
expect(looksLikeBlueBubblesTargetId("user@example.com")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts phone numbers with punctuation", () => {
|
||||
expect(looksLikeBlueBubblesTargetId("+1 (555) 123-4567")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts raw chat_guid values", () => {
|
||||
expect(looksLikeBlueBubblesTargetId("iMessage;+;chat660250192681427962")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts chat<digits> pattern as chat_id", () => {
|
||||
expect(looksLikeBlueBubblesTargetId("chat660250192681427962")).toBe(true);
|
||||
expect(looksLikeBlueBubblesTargetId("chat123")).toBe(true);
|
||||
expect(looksLikeBlueBubblesTargetId("Chat456789")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts UUID/hex chat identifiers", () => {
|
||||
expect(looksLikeBlueBubblesTargetId("8b9c1a10536d4d86a336ea03ab7151cc")).toBe(true);
|
||||
expect(looksLikeBlueBubblesTargetId("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects display names", () => {
|
||||
expect(looksLikeBlueBubblesTargetId("Jane Doe")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("looksLikeBlueBubblesExplicitTargetId", () => {
|
||||
it("treats explicit chat targets as immediate ids", () => {
|
||||
expect(looksLikeBlueBubblesExplicitTargetId("chat_guid:ABC-123")).toBe(true);
|
||||
expect(looksLikeBlueBubblesExplicitTargetId("imessage:+15551234567")).toBe(true);
|
||||
});
|
||||
|
||||
it("prefers directory fallback for bare handles and phone numbers", () => {
|
||||
expect(looksLikeBlueBubblesExplicitTargetId("+1 (555) 123-4567")).toBe(false);
|
||||
expect(looksLikeBlueBubblesExplicitTargetId("user@example.com")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inferBlueBubblesTargetChatType", () => {
|
||||
it("infers direct chat for handles and dm chat_guids", () => {
|
||||
expect(inferBlueBubblesTargetChatType("+15551234567")).toBe("direct");
|
||||
expect(inferBlueBubblesTargetChatType("chat_guid:iMessage;-;+15551234567")).toBe("direct");
|
||||
});
|
||||
|
||||
it("infers group chat for explicit group targets", () => {
|
||||
expect(inferBlueBubblesTargetChatType("chat_id:123")).toBe("group");
|
||||
expect(inferBlueBubblesTargetChatType("chat_guid:iMessage;+;chat123")).toBe("group");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBlueBubblesTarget", () => {
|
||||
it("parses chat<digits> pattern as chat_identifier", () => {
|
||||
expect(parseBlueBubblesTarget("chat660250192681427962")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "chat660250192681427962",
|
||||
});
|
||||
expect(parseBlueBubblesTarget("chat123")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "chat123",
|
||||
});
|
||||
expect(parseBlueBubblesTarget("Chat456789")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "Chat456789",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses UUID/hex chat identifiers as chat_identifier", () => {
|
||||
expect(parseBlueBubblesTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc",
|
||||
});
|
||||
expect(parseBlueBubblesTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses explicit chat_id: prefix", () => {
|
||||
expect(parseBlueBubblesTarget("chat_id:123")).toEqual({ kind: "chat_id", chatId: 123 });
|
||||
});
|
||||
|
||||
it("parses phone numbers as handles", () => {
|
||||
expect(parseBlueBubblesTarget("+19257864429")).toEqual({
|
||||
kind: "handle",
|
||||
to: "+19257864429",
|
||||
service: "auto",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses raw chat_guid format", () => {
|
||||
expect(parseBlueBubblesTarget("iMessage;+;chat660250192681427962")).toEqual({
|
||||
kind: "chat_guid",
|
||||
chatGuid: "iMessage;+;chat660250192681427962",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBlueBubblesAllowTarget", () => {
|
||||
it("parses chat<digits> pattern as chat_identifier", () => {
|
||||
expect(parseBlueBubblesAllowTarget("chat660250192681427962")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "chat660250192681427962",
|
||||
});
|
||||
expect(parseBlueBubblesAllowTarget("chat123")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "chat123",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses UUID/hex chat identifiers as chat_identifier", () => {
|
||||
expect(parseBlueBubblesAllowTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc",
|
||||
});
|
||||
expect(parseBlueBubblesAllowTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses explicit chat_id: prefix", () => {
|
||||
expect(parseBlueBubblesAllowTarget("chat_id:456")).toEqual({ kind: "chat_id", chatId: 456 });
|
||||
});
|
||||
|
||||
it("parses phone numbers as handles", () => {
|
||||
expect(parseBlueBubblesAllowTarget("+19257864429")).toEqual({
|
||||
kind: "handle",
|
||||
handle: "+19257864429",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAllowedBlueBubblesSender", () => {
|
||||
it("denies when allowFrom is empty", () => {
|
||||
const allowed = isAllowedBlueBubblesSender({
|
||||
allowFrom: [],
|
||||
sender: "+15551234567",
|
||||
});
|
||||
expect(allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("allows wildcard entries", () => {
|
||||
const allowed = isAllowedBlueBubblesSender({
|
||||
allowFrom: ["*"],
|
||||
sender: "+15551234567",
|
||||
});
|
||||
expect(allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
228
extensions/bluebubbles/src/targets.test.ts
Normal file
228
extensions/bluebubbles/src/targets.test.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
inferBlueBubblesTargetChatType,
|
||||
looksLikeBlueBubblesExplicitTargetId,
|
||||
isAllowedBlueBubblesSender,
|
||||
looksLikeBlueBubblesTargetId,
|
||||
normalizeBlueBubblesMessagingTarget,
|
||||
parseBlueBubblesTarget,
|
||||
parseBlueBubblesAllowTarget,
|
||||
} from "./targets.js";
|
||||
|
||||
describe("normalizeBlueBubblesMessagingTarget", () => {
|
||||
it("normalizes chat_guid targets", () => {
|
||||
expect(normalizeBlueBubblesMessagingTarget("chat_guid:ABC-123")).toBe("chat_guid:ABC-123");
|
||||
});
|
||||
|
||||
it("normalizes group numeric targets to chat_id", () => {
|
||||
expect(normalizeBlueBubblesMessagingTarget("group:123")).toBe("chat_id:123");
|
||||
});
|
||||
|
||||
it("strips provider prefix and normalizes handles", () => {
|
||||
expect(normalizeBlueBubblesMessagingTarget("bluebubbles:imessage:User@Example.com")).toBe(
|
||||
"imessage:user@example.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("extracts handle from DM chat_guid for cross-context matching", () => {
|
||||
// DM format: service;-;handle
|
||||
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;+19257864429")).toBe(
|
||||
"+19257864429",
|
||||
);
|
||||
expect(normalizeBlueBubblesMessagingTarget("chat_guid:SMS;-;+15551234567")).toBe(
|
||||
"+15551234567",
|
||||
);
|
||||
// Email handles
|
||||
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;-;user@example.com")).toBe(
|
||||
"user@example.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves group chat_guid format", () => {
|
||||
// Group format: service;+;groupId
|
||||
expect(normalizeBlueBubblesMessagingTarget("chat_guid:iMessage;+;chat123456789")).toBe(
|
||||
"chat_guid:iMessage;+;chat123456789",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes raw chat_guid values", () => {
|
||||
expect(normalizeBlueBubblesMessagingTarget("iMessage;+;chat660250192681427962")).toBe(
|
||||
"chat_guid:iMessage;+;chat660250192681427962",
|
||||
);
|
||||
expect(normalizeBlueBubblesMessagingTarget("iMessage;-;+19257864429")).toBe("+19257864429");
|
||||
});
|
||||
|
||||
it("normalizes chat<digits> pattern to chat_identifier format", () => {
|
||||
expect(normalizeBlueBubblesMessagingTarget("chat660250192681427962")).toBe(
|
||||
"chat_identifier:chat660250192681427962",
|
||||
);
|
||||
expect(normalizeBlueBubblesMessagingTarget("chat123")).toBe("chat_identifier:chat123");
|
||||
expect(normalizeBlueBubblesMessagingTarget("Chat456789")).toBe("chat_identifier:Chat456789");
|
||||
});
|
||||
|
||||
it("normalizes UUID/hex chat identifiers", () => {
|
||||
expect(normalizeBlueBubblesMessagingTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toBe(
|
||||
"chat_identifier:8b9c1a10536d4d86a336ea03ab7151cc",
|
||||
);
|
||||
expect(normalizeBlueBubblesMessagingTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe(
|
||||
"chat_identifier:1C2D3E4F-1234-5678-9ABC-DEF012345678",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("looksLikeBlueBubblesTargetId", () => {
|
||||
it("accepts chat targets", () => {
|
||||
expect(looksLikeBlueBubblesTargetId("chat_guid:ABC-123")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts email handles", () => {
|
||||
expect(looksLikeBlueBubblesTargetId("user@example.com")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts phone numbers with punctuation", () => {
|
||||
expect(looksLikeBlueBubblesTargetId("+1 (555) 123-4567")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts raw chat_guid values", () => {
|
||||
expect(looksLikeBlueBubblesTargetId("iMessage;+;chat660250192681427962")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts chat<digits> pattern as chat_id", () => {
|
||||
expect(looksLikeBlueBubblesTargetId("chat660250192681427962")).toBe(true);
|
||||
expect(looksLikeBlueBubblesTargetId("chat123")).toBe(true);
|
||||
expect(looksLikeBlueBubblesTargetId("Chat456789")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts UUID/hex chat identifiers", () => {
|
||||
expect(looksLikeBlueBubblesTargetId("8b9c1a10536d4d86a336ea03ab7151cc")).toBe(true);
|
||||
expect(looksLikeBlueBubblesTargetId("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects display names", () => {
|
||||
expect(looksLikeBlueBubblesTargetId("Jane Doe")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("looksLikeBlueBubblesExplicitTargetId", () => {
|
||||
it("treats explicit chat targets as immediate ids", () => {
|
||||
expect(looksLikeBlueBubblesExplicitTargetId("chat_guid:ABC-123")).toBe(true);
|
||||
expect(looksLikeBlueBubblesExplicitTargetId("imessage:+15551234567")).toBe(true);
|
||||
});
|
||||
|
||||
it("prefers directory fallback for bare handles and phone numbers", () => {
|
||||
expect(looksLikeBlueBubblesExplicitTargetId("+1 (555) 123-4567")).toBe(false);
|
||||
expect(looksLikeBlueBubblesExplicitTargetId("user@example.com")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inferBlueBubblesTargetChatType", () => {
|
||||
it("infers direct chat for handles and dm chat_guids", () => {
|
||||
expect(inferBlueBubblesTargetChatType("+15551234567")).toBe("direct");
|
||||
expect(inferBlueBubblesTargetChatType("chat_guid:iMessage;-;+15551234567")).toBe("direct");
|
||||
});
|
||||
|
||||
it("infers group chat for explicit group targets", () => {
|
||||
expect(inferBlueBubblesTargetChatType("chat_id:123")).toBe("group");
|
||||
expect(inferBlueBubblesTargetChatType("chat_guid:iMessage;+;chat123")).toBe("group");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBlueBubblesTarget", () => {
|
||||
it("parses chat<digits> pattern as chat_identifier", () => {
|
||||
expect(parseBlueBubblesTarget("chat660250192681427962")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "chat660250192681427962",
|
||||
});
|
||||
expect(parseBlueBubblesTarget("chat123")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "chat123",
|
||||
});
|
||||
expect(parseBlueBubblesTarget("Chat456789")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "Chat456789",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses UUID/hex chat identifiers as chat_identifier", () => {
|
||||
expect(parseBlueBubblesTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc",
|
||||
});
|
||||
expect(parseBlueBubblesTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses explicit chat_id: prefix", () => {
|
||||
expect(parseBlueBubblesTarget("chat_id:123")).toEqual({ kind: "chat_id", chatId: 123 });
|
||||
});
|
||||
|
||||
it("parses phone numbers as handles", () => {
|
||||
expect(parseBlueBubblesTarget("+19257864429")).toEqual({
|
||||
kind: "handle",
|
||||
to: "+19257864429",
|
||||
service: "auto",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses raw chat_guid format", () => {
|
||||
expect(parseBlueBubblesTarget("iMessage;+;chat660250192681427962")).toEqual({
|
||||
kind: "chat_guid",
|
||||
chatGuid: "iMessage;+;chat660250192681427962",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBlueBubblesAllowTarget", () => {
|
||||
it("parses chat<digits> pattern as chat_identifier", () => {
|
||||
expect(parseBlueBubblesAllowTarget("chat660250192681427962")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "chat660250192681427962",
|
||||
});
|
||||
expect(parseBlueBubblesAllowTarget("chat123")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "chat123",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses UUID/hex chat identifiers as chat_identifier", () => {
|
||||
expect(parseBlueBubblesAllowTarget("8b9c1a10536d4d86a336ea03ab7151cc")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "8b9c1a10536d4d86a336ea03ab7151cc",
|
||||
});
|
||||
expect(parseBlueBubblesAllowTarget("1C2D3E4F-1234-5678-9ABC-DEF012345678")).toEqual({
|
||||
kind: "chat_identifier",
|
||||
chatIdentifier: "1C2D3E4F-1234-5678-9ABC-DEF012345678",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses explicit chat_id: prefix", () => {
|
||||
expect(parseBlueBubblesAllowTarget("chat_id:456")).toEqual({ kind: "chat_id", chatId: 456 });
|
||||
});
|
||||
|
||||
it("parses phone numbers as handles", () => {
|
||||
expect(parseBlueBubblesAllowTarget("+19257864429")).toEqual({
|
||||
kind: "handle",
|
||||
handle: "+19257864429",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAllowedBlueBubblesSender", () => {
|
||||
it("denies when allowFrom is empty", () => {
|
||||
const allowed = isAllowedBlueBubblesSender({
|
||||
allowFrom: [],
|
||||
sender: "+15551234567",
|
||||
});
|
||||
expect(allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("allows wildcard entries", () => {
|
||||
const allowed = isAllowedBlueBubblesSender({
|
||||
allowFrom: ["*"],
|
||||
sender: "+15551234567",
|
||||
});
|
||||
expect(allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js";
|
||||
import type { OpenClawPluginApi } from "./api.js";
|
||||
import type { PendingPairingRequest } from "./notify.ts";
|
||||
|
||||
const pluginApiMocks = vi.hoisted(() => ({
|
||||
clearDeviceBootstrapTokens: vi.fn(async () => ({ removed: 2 })),
|
||||
@@ -386,49 +385,6 @@ describe("device-pair /pair qr", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("device-pair notify pending formatting", () => {
|
||||
it("includes role and scopes for pending requests", async () => {
|
||||
const { formatPendingRequests } =
|
||||
await vi.importActual<typeof import("./notify.ts")>("./notify.ts");
|
||||
const pending: PendingPairingRequest[] = [
|
||||
{
|
||||
requestId: "req-1",
|
||||
deviceId: "device-1",
|
||||
displayName: "dev one",
|
||||
platform: "ios",
|
||||
role: "operator",
|
||||
scopes: ["operator.admin", "operator.read"],
|
||||
remoteIp: "198.51.100.2",
|
||||
},
|
||||
];
|
||||
|
||||
const text = formatPendingRequests(pending);
|
||||
expect(text).toContain("Pending device pairing requests:");
|
||||
expect(text).toContain("name=dev one");
|
||||
expect(text).toContain("platform=ios");
|
||||
expect(text).toContain("role=operator");
|
||||
expect(text).toContain("scopes=operator.admin, operator.read");
|
||||
expect(text).toContain("ip=198.51.100.2");
|
||||
});
|
||||
|
||||
it("falls back to roles list and no scopes when role/scopes are absent", async () => {
|
||||
const { formatPendingRequests } =
|
||||
await vi.importActual<typeof import("./notify.ts")>("./notify.ts");
|
||||
const pending: PendingPairingRequest[] = [
|
||||
{
|
||||
requestId: "req-2",
|
||||
deviceId: "device-2",
|
||||
roles: ["node", "operator"],
|
||||
scopes: [],
|
||||
},
|
||||
];
|
||||
|
||||
const text = formatPendingRequests(pending);
|
||||
expect(text).toContain("role=node, operator");
|
||||
expect(text).toContain("scopes=none");
|
||||
});
|
||||
});
|
||||
|
||||
describe("device-pair /pair approve", () => {
|
||||
it("rejects internal gateway callers without operator.pairing", async () => {
|
||||
vi.mocked(listDevicePairing).mockResolvedValueOnce({
|
||||
|
||||
41
extensions/device-pair/notify.test.ts
Normal file
41
extensions/device-pair/notify.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatPendingRequests, type PendingPairingRequest } from "./notify.ts";
|
||||
|
||||
describe("device-pair notify pending formatting", () => {
|
||||
it("includes role and scopes for pending requests", () => {
|
||||
const pending: PendingPairingRequest[] = [
|
||||
{
|
||||
requestId: "req-1",
|
||||
deviceId: "device-1",
|
||||
displayName: "dev one",
|
||||
platform: "ios",
|
||||
role: "operator",
|
||||
scopes: ["operator.admin", "operator.read"],
|
||||
remoteIp: "198.51.100.2",
|
||||
},
|
||||
];
|
||||
|
||||
const text = formatPendingRequests(pending);
|
||||
expect(text).toContain("Pending device pairing requests:");
|
||||
expect(text).toContain("name=dev one");
|
||||
expect(text).toContain("platform=ios");
|
||||
expect(text).toContain("role=operator");
|
||||
expect(text).toContain("scopes=operator.admin, operator.read");
|
||||
expect(text).toContain("ip=198.51.100.2");
|
||||
});
|
||||
|
||||
it("falls back to roles list and no scopes when role/scopes are absent", () => {
|
||||
const pending: PendingPairingRequest[] = [
|
||||
{
|
||||
requestId: "req-2",
|
||||
deviceId: "device-2",
|
||||
roles: ["node", "operator"],
|
||||
scopes: [],
|
||||
},
|
||||
];
|
||||
|
||||
const text = formatPendingRequests(pending);
|
||||
expect(text).toContain("role=node, operator");
|
||||
expect(text).toContain("scopes=none");
|
||||
});
|
||||
});
|
||||
140
extensions/diffs/index.test.ts
Normal file
140
extensions/diffs/index.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createMockServerResponse } from "../../test/helpers/extensions/mock-http-response.js";
|
||||
import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js";
|
||||
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "./api.js";
|
||||
import plugin from "./index.js";
|
||||
|
||||
describe("diffs plugin registration", () => {
|
||||
it("registers the tool, http route, and system-prompt guidance hook", async () => {
|
||||
const registerTool = vi.fn();
|
||||
const registerHttpRoute = vi.fn();
|
||||
const on = vi.fn();
|
||||
|
||||
plugin.register?.(
|
||||
createTestPluginApi({
|
||||
id: "diffs",
|
||||
name: "Diffs",
|
||||
description: "Diffs",
|
||||
source: "test",
|
||||
config: {},
|
||||
runtime: {} as never,
|
||||
registerTool,
|
||||
registerHttpRoute,
|
||||
on,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(registerTool).toHaveBeenCalledTimes(1);
|
||||
expect(registerHttpRoute).toHaveBeenCalledTimes(1);
|
||||
expect(registerHttpRoute.mock.calls[0]?.[0]).toMatchObject({
|
||||
path: "/plugins/diffs",
|
||||
auth: "plugin",
|
||||
match: "prefix",
|
||||
});
|
||||
expect(on).toHaveBeenCalledTimes(1);
|
||||
expect(on.mock.calls[0]?.[0]).toBe("before_prompt_build");
|
||||
const beforePromptBuild = on.mock.calls[0]?.[1];
|
||||
const result = await beforePromptBuild?.({}, {});
|
||||
expect(result).toMatchObject({
|
||||
prependSystemContext: expect.stringContaining("prefer the `diffs` tool"),
|
||||
});
|
||||
expect(result?.prependContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it("applies plugin-config defaults through registered tool and viewer handler", async () => {
|
||||
type RegisteredTool = {
|
||||
execute?: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown>;
|
||||
};
|
||||
type RegisteredHttpRouteParams = Parameters<OpenClawPluginApi["registerHttpRoute"]>[0];
|
||||
|
||||
let registeredToolFactory:
|
||||
| ((ctx: OpenClawPluginToolContext) => RegisteredTool | RegisteredTool[] | null | undefined)
|
||||
| undefined;
|
||||
let registeredHttpRouteHandler: RegisteredHttpRouteParams["handler"] | undefined;
|
||||
|
||||
const api = createTestPluginApi({
|
||||
id: "diffs",
|
||||
name: "Diffs",
|
||||
description: "Diffs",
|
||||
source: "test",
|
||||
config: {
|
||||
gateway: {
|
||||
port: 18789,
|
||||
bind: "loopback",
|
||||
},
|
||||
},
|
||||
pluginConfig: {
|
||||
defaults: {
|
||||
mode: "view",
|
||||
theme: "light",
|
||||
background: false,
|
||||
layout: "split",
|
||||
showLineNumbers: false,
|
||||
diffIndicators: "classic",
|
||||
lineSpacing: 2,
|
||||
},
|
||||
},
|
||||
runtime: {} as never,
|
||||
registerTool(tool: Parameters<OpenClawPluginApi["registerTool"]>[0]) {
|
||||
registeredToolFactory = typeof tool === "function" ? tool : () => tool;
|
||||
},
|
||||
registerHttpRoute(params: RegisteredHttpRouteParams) {
|
||||
registeredHttpRouteHandler = params.handler;
|
||||
},
|
||||
});
|
||||
|
||||
plugin.register?.(api as unknown as OpenClawPluginApi);
|
||||
|
||||
const registeredTool = registeredToolFactory?.({
|
||||
agentId: "main",
|
||||
sessionId: "session-123",
|
||||
messageChannel: "discord",
|
||||
agentAccountId: "default",
|
||||
}) as RegisteredTool | undefined;
|
||||
const result = await registeredTool?.execute?.("tool-1", {
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
});
|
||||
const viewerPath = String(
|
||||
(result as { details?: Record<string, unknown> } | undefined)?.details?.viewerPath,
|
||||
);
|
||||
const res = createMockServerResponse();
|
||||
const handled = await registeredHttpRouteHandler?.(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: viewerPath,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(String(res.body)).toContain('body data-theme="light"');
|
||||
expect(String(res.body)).toContain('"backgroundEnabled":false');
|
||||
expect(String(res.body)).toContain('"diffStyle":"split"');
|
||||
expect(String(res.body)).toContain('"disableLineNumbers":true');
|
||||
expect(String(res.body)).toContain('"diffIndicators":"classic"');
|
||||
expect(String(res.body)).toContain("--diffs-line-height: 30px;");
|
||||
expect((result as { details?: Record<string, unknown> } | undefined)?.details?.context).toEqual(
|
||||
{
|
||||
agentId: "main",
|
||||
sessionId: "session-123",
|
||||
messageChannel: "discord",
|
||||
agentAccountId: "default",
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function localReq(input: {
|
||||
method: string;
|
||||
url: string;
|
||||
headers?: IncomingMessage["headers"];
|
||||
}): IncomingMessage {
|
||||
return {
|
||||
...input,
|
||||
headers: input.headers ?? {},
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
} as unknown as IncomingMessage;
|
||||
}
|
||||
@@ -1,12 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createMockServerResponse } from "../../../test/helpers/extensions/mock-http-response.js";
|
||||
import { createTestPluginApi } from "../../../test/helpers/extensions/plugin-api.js";
|
||||
import type { OpenClawConfig } from "../api.js";
|
||||
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../api.js";
|
||||
import plugin from "../index.js";
|
||||
import { createTempDiffRoot } from "./test-helpers.js";
|
||||
|
||||
const { launchMock } = vi.hoisted(() => ({
|
||||
@@ -192,128 +187,6 @@ describe("PlaywrightDiffScreenshotter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("diffs plugin registration", () => {
|
||||
it("registers the tool, http route, and system-prompt guidance hook", async () => {
|
||||
const registerTool = vi.fn();
|
||||
const registerHttpRoute = vi.fn();
|
||||
const on = vi.fn();
|
||||
|
||||
plugin.register?.(
|
||||
createTestPluginApi({
|
||||
id: "diffs",
|
||||
name: "Diffs",
|
||||
description: "Diffs",
|
||||
source: "test",
|
||||
config: {},
|
||||
runtime: {} as never,
|
||||
registerTool,
|
||||
registerHttpRoute,
|
||||
on,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(registerTool).toHaveBeenCalledTimes(1);
|
||||
expect(registerHttpRoute).toHaveBeenCalledTimes(1);
|
||||
expect(registerHttpRoute.mock.calls[0]?.[0]).toMatchObject({
|
||||
path: "/plugins/diffs",
|
||||
auth: "plugin",
|
||||
match: "prefix",
|
||||
});
|
||||
expect(on).toHaveBeenCalledTimes(1);
|
||||
expect(on.mock.calls[0]?.[0]).toBe("before_prompt_build");
|
||||
const beforePromptBuild = on.mock.calls[0]?.[1];
|
||||
const result = await beforePromptBuild?.({}, {});
|
||||
expect(result).toMatchObject({
|
||||
prependSystemContext: expect.stringContaining("prefer the `diffs` tool"),
|
||||
});
|
||||
expect(result?.prependContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it("applies plugin-config defaults through registered tool and viewer handler", async () => {
|
||||
type RegisteredTool = {
|
||||
execute?: (toolCallId: string, params: Record<string, unknown>) => Promise<unknown>;
|
||||
};
|
||||
type RegisteredHttpRouteParams = Parameters<OpenClawPluginApi["registerHttpRoute"]>[0];
|
||||
|
||||
let registeredToolFactory:
|
||||
| ((ctx: OpenClawPluginToolContext) => RegisteredTool | RegisteredTool[] | null | undefined)
|
||||
| undefined;
|
||||
let registeredHttpRouteHandler: RegisteredHttpRouteParams["handler"] | undefined;
|
||||
|
||||
const api = createTestPluginApi({
|
||||
id: "diffs",
|
||||
name: "Diffs",
|
||||
description: "Diffs",
|
||||
source: "test",
|
||||
config: {
|
||||
gateway: {
|
||||
port: 18789,
|
||||
bind: "loopback",
|
||||
},
|
||||
},
|
||||
pluginConfig: {
|
||||
defaults: {
|
||||
mode: "view",
|
||||
theme: "light",
|
||||
background: false,
|
||||
layout: "split",
|
||||
showLineNumbers: false,
|
||||
diffIndicators: "classic",
|
||||
lineSpacing: 2,
|
||||
},
|
||||
},
|
||||
runtime: {} as never,
|
||||
registerTool(tool: Parameters<OpenClawPluginApi["registerTool"]>[0]) {
|
||||
registeredToolFactory = typeof tool === "function" ? tool : () => tool;
|
||||
},
|
||||
registerHttpRoute(params: RegisteredHttpRouteParams) {
|
||||
registeredHttpRouteHandler = params.handler;
|
||||
},
|
||||
});
|
||||
|
||||
plugin.register?.(api as unknown as OpenClawPluginApi);
|
||||
|
||||
const registeredTool = registeredToolFactory?.({
|
||||
agentId: "main",
|
||||
sessionId: "session-123",
|
||||
messageChannel: "discord",
|
||||
agentAccountId: "default",
|
||||
}) as RegisteredTool | undefined;
|
||||
const result = await registeredTool?.execute?.("tool-1", {
|
||||
before: "one\n",
|
||||
after: "two\n",
|
||||
});
|
||||
const viewerPath = String(
|
||||
(result as { details?: Record<string, unknown> } | undefined)?.details?.viewerPath,
|
||||
);
|
||||
const res = createMockServerResponse();
|
||||
const handled = await registeredHttpRouteHandler?.(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: viewerPath,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(String(res.body)).toContain('body data-theme="light"');
|
||||
expect(String(res.body)).toContain('"backgroundEnabled":false');
|
||||
expect(String(res.body)).toContain('"diffStyle":"split"');
|
||||
expect(String(res.body)).toContain('"disableLineNumbers":true');
|
||||
expect(String(res.body)).toContain('"diffIndicators":"classic"');
|
||||
expect(String(res.body)).toContain("--diffs-line-height: 30px;");
|
||||
expect((result as { details?: Record<string, unknown> } | undefined)?.details?.context).toEqual(
|
||||
{
|
||||
agentId: "main",
|
||||
sessionId: "session-123",
|
||||
messageChannel: "discord",
|
||||
agentAccountId: "default",
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function createConfig(): OpenClawConfig {
|
||||
return {
|
||||
browser: {
|
||||
@@ -322,18 +195,6 @@ function createConfig(): OpenClawConfig {
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
function localReq(input: {
|
||||
method: string;
|
||||
url: string;
|
||||
headers?: IncomingMessage["headers"];
|
||||
}): IncomingMessage {
|
||||
return {
|
||||
...input,
|
||||
headers: input.headers ?? {},
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
} as unknown as IncomingMessage;
|
||||
}
|
||||
|
||||
async function createScreenshotterHarness(options?: {
|
||||
boundingBox?: { x: number; y: number; width: number; height: number };
|
||||
}) {
|
||||
|
||||
@@ -8,10 +8,6 @@ import {
|
||||
resolveDiffsPluginDefaults,
|
||||
resolveDiffsPluginSecurity,
|
||||
} from "./config.js";
|
||||
import { renderDiffDocument } from "./render.js";
|
||||
import { buildViewerUrl, normalizeViewerBaseUrl } from "./url.js";
|
||||
import { getServedViewerAsset, VIEWER_LOADER_PATH, VIEWER_RUNTIME_PATH } from "./viewer-assets.js";
|
||||
import { parseViewerPayloadJson } from "./viewer-payload.js";
|
||||
|
||||
const FULL_DEFAULTS = {
|
||||
fontFamily: "JetBrains Mono",
|
||||
@@ -181,232 +177,3 @@ describe("diffs plugin schema surfaces", () => {
|
||||
expect(diffsPluginConfigSchema.jsonSchema).toEqual(manifest.configSchema);
|
||||
});
|
||||
});
|
||||
|
||||
describe("diffs viewer URL helpers", () => {
|
||||
it("defaults to loopback for lan/tailnet bind modes", () => {
|
||||
expect(
|
||||
buildViewerUrl({
|
||||
config: { gateway: { bind: "lan", port: 18789 } },
|
||||
viewerPath: "/plugins/diffs/view/id/token",
|
||||
}),
|
||||
).toBe("http://127.0.0.1:18789/plugins/diffs/view/id/token");
|
||||
|
||||
expect(
|
||||
buildViewerUrl({
|
||||
config: { gateway: { bind: "tailnet", port: 24444 } },
|
||||
viewerPath: "/plugins/diffs/view/id/token",
|
||||
}),
|
||||
).toBe("http://127.0.0.1:24444/plugins/diffs/view/id/token");
|
||||
});
|
||||
|
||||
it("uses custom bind host when provided", () => {
|
||||
expect(
|
||||
buildViewerUrl({
|
||||
config: {
|
||||
gateway: {
|
||||
bind: "custom",
|
||||
customBindHost: "gateway.example.com",
|
||||
port: 443,
|
||||
tls: { enabled: true },
|
||||
},
|
||||
},
|
||||
viewerPath: "/plugins/diffs/view/id/token",
|
||||
}),
|
||||
).toBe("https://gateway.example.com/plugins/diffs/view/id/token");
|
||||
});
|
||||
|
||||
it("joins viewer path under baseUrl pathname", () => {
|
||||
expect(
|
||||
buildViewerUrl({
|
||||
config: {},
|
||||
baseUrl: "https://example.com/openclaw",
|
||||
viewerPath: "/plugins/diffs/view/id/token",
|
||||
}),
|
||||
).toBe("https://example.com/openclaw/plugins/diffs/view/id/token");
|
||||
});
|
||||
|
||||
it("rejects base URLs with query/hash", () => {
|
||||
expect(() => normalizeViewerBaseUrl("https://example.com?a=1")).toThrow(
|
||||
"baseUrl must not include query/hash",
|
||||
);
|
||||
expect(() => normalizeViewerBaseUrl("https://example.com#frag")).toThrow(
|
||||
"baseUrl must not include query/hash",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderDiffDocument", () => {
|
||||
it("renders before/after input into a complete viewer document", async () => {
|
||||
const rendered = await renderDiffDocument(
|
||||
{
|
||||
kind: "before_after",
|
||||
before: "const value = 1;\n",
|
||||
after: "const value = 2;\n",
|
||||
path: "src/example.ts",
|
||||
},
|
||||
{
|
||||
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
|
||||
expandUnchanged: false,
|
||||
},
|
||||
);
|
||||
|
||||
expect(rendered.title).toBe("src/example.ts");
|
||||
expect(rendered.fileCount).toBe(1);
|
||||
expect(rendered.html).toContain("data-openclaw-diff-root");
|
||||
expect(rendered.html).toContain("src/example.ts");
|
||||
expect(rendered.html).toContain("/plugins/diffs/assets/viewer.js");
|
||||
expect(rendered.imageHtml).toContain("/plugins/diffs/assets/viewer.js");
|
||||
expect(rendered.imageHtml).toContain("max-width: 960px;");
|
||||
expect(rendered.imageHtml).toContain("--diffs-font-size: 16px;");
|
||||
expect(rendered.html).toContain("min-height: 100vh;");
|
||||
expect(rendered.html).toContain('"diffIndicators":"bars"');
|
||||
expect(rendered.html).toContain('"disableLineNumbers":false');
|
||||
expect(rendered.html).toContain("--diffs-line-height: 24px;");
|
||||
expect(rendered.html).toContain("--diffs-font-size: 15px;");
|
||||
expect(rendered.html).not.toContain("fonts.googleapis.com");
|
||||
});
|
||||
|
||||
it("renders multi-file patch input", async () => {
|
||||
const patch = [
|
||||
"diff --git a/a.ts b/a.ts",
|
||||
"--- a/a.ts",
|
||||
"+++ b/a.ts",
|
||||
"@@ -1 +1 @@",
|
||||
"-const a = 1;",
|
||||
"+const a = 2;",
|
||||
"diff --git a/b.ts b/b.ts",
|
||||
"--- a/b.ts",
|
||||
"+++ b/b.ts",
|
||||
"@@ -1 +1 @@",
|
||||
"-const b = 1;",
|
||||
"+const b = 2;",
|
||||
].join("\n");
|
||||
|
||||
const rendered = await renderDiffDocument(
|
||||
{
|
||||
kind: "patch",
|
||||
patch,
|
||||
title: "Workspace patch",
|
||||
},
|
||||
{
|
||||
presentation: {
|
||||
...DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
layout: "split",
|
||||
theme: "dark",
|
||||
},
|
||||
image: resolveDiffImageRenderOptions({
|
||||
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
fileQuality: "hq",
|
||||
fileMaxWidth: 1180,
|
||||
}),
|
||||
expandUnchanged: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(rendered.title).toBe("Workspace patch");
|
||||
expect(rendered.fileCount).toBe(2);
|
||||
expect(rendered.html).toContain("Workspace patch");
|
||||
expect(rendered.imageHtml).toContain("max-width: 1180px;");
|
||||
});
|
||||
|
||||
it("rejects patches that exceed file-count limits", async () => {
|
||||
const patch = Array.from({ length: 129 }, (_, i) => {
|
||||
return [
|
||||
`diff --git a/f${i}.ts b/f${i}.ts`,
|
||||
`--- a/f${i}.ts`,
|
||||
`+++ b/f${i}.ts`,
|
||||
"@@ -1 +1 @@",
|
||||
"-const x = 1;",
|
||||
"+const x = 2;",
|
||||
].join("\n");
|
||||
}).join("\n");
|
||||
|
||||
await expect(
|
||||
renderDiffDocument(
|
||||
{
|
||||
kind: "patch",
|
||||
patch,
|
||||
},
|
||||
{
|
||||
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
|
||||
expandUnchanged: false,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("too many files");
|
||||
});
|
||||
});
|
||||
|
||||
describe("viewer assets", () => {
|
||||
it("serves a stable loader that points at the current runtime bundle", async () => {
|
||||
const loader = await getServedViewerAsset(VIEWER_LOADER_PATH);
|
||||
|
||||
expect(loader?.contentType).toBe("text/javascript; charset=utf-8");
|
||||
expect(String(loader?.body)).toContain(`${VIEWER_RUNTIME_PATH}?v=`);
|
||||
});
|
||||
|
||||
it("serves the runtime bundle body", async () => {
|
||||
const runtime = await getServedViewerAsset(VIEWER_RUNTIME_PATH);
|
||||
|
||||
expect(runtime?.contentType).toBe("text/javascript; charset=utf-8");
|
||||
expect(String(runtime?.body)).toContain("openclawDiffsReady");
|
||||
});
|
||||
|
||||
it("returns null for unknown asset paths", async () => {
|
||||
await expect(getServedViewerAsset("/plugins/diffs/assets/not-real.js")).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseViewerPayloadJson", () => {
|
||||
function buildValidPayload(): Record<string, unknown> {
|
||||
return {
|
||||
prerenderedHTML: "<div>ok</div>",
|
||||
langs: ["text"],
|
||||
oldFile: {
|
||||
name: "README.md",
|
||||
contents: "before",
|
||||
},
|
||||
newFile: {
|
||||
name: "README.md",
|
||||
contents: "after",
|
||||
},
|
||||
options: {
|
||||
theme: {
|
||||
light: "pierre-light",
|
||||
dark: "pierre-dark",
|
||||
},
|
||||
diffStyle: "unified",
|
||||
diffIndicators: "bars",
|
||||
disableLineNumbers: false,
|
||||
expandUnchanged: false,
|
||||
themeType: "dark",
|
||||
backgroundEnabled: true,
|
||||
overflow: "wrap",
|
||||
unsafeCSS: ":host{}",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("accepts valid payload JSON", () => {
|
||||
const parsed = parseViewerPayloadJson(JSON.stringify(buildValidPayload()));
|
||||
expect(parsed.options.diffStyle).toBe("unified");
|
||||
expect(parsed.options.diffIndicators).toBe("bars");
|
||||
});
|
||||
|
||||
it("rejects payloads with invalid shape", () => {
|
||||
const broken = buildValidPayload();
|
||||
broken.options = {
|
||||
...(broken.options as Record<string, unknown>),
|
||||
diffIndicators: "invalid",
|
||||
};
|
||||
|
||||
expect(() => parseViewerPayloadJson(JSON.stringify(broken))).toThrow(
|
||||
"Diff payload has invalid shape.",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid JSON", () => {
|
||||
expect(() => parseViewerPayloadJson("{not-json")).toThrow("Diff payload is not valid JSON.");
|
||||
});
|
||||
});
|
||||
|
||||
206
extensions/diffs/src/http.test.ts
Normal file
206
extensions/diffs/src/http.test.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createMockServerResponse } from "../../../test/helpers/extensions/mock-http-response.js";
|
||||
import { createDiffsHttpHandler } from "./http.js";
|
||||
import { DiffArtifactStore } from "./store.js";
|
||||
import { createDiffStoreHarness } from "./test-helpers.js";
|
||||
|
||||
describe("createDiffsHttpHandler", () => {
|
||||
let store: DiffArtifactStore;
|
||||
let cleanupRootDir: () => Promise<void>;
|
||||
|
||||
async function handleLocalGet(url: string) {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
return { handled, res };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
({ store, cleanup: cleanupRootDir } = await createDiffStoreHarness("openclaw-diffs-http-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupRootDir();
|
||||
});
|
||||
|
||||
it("serves a stored diff document", async () => {
|
||||
const artifact = await createViewerArtifact(store);
|
||||
const { handled, res } = await handleLocalGet(artifact.viewerPath);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe("<html>viewer</html>");
|
||||
expect(res.getHeader("content-security-policy")).toContain("default-src 'none'");
|
||||
});
|
||||
|
||||
it("rejects invalid tokens", async () => {
|
||||
const artifact = await createViewerArtifact(store);
|
||||
const { handled, res } = await handleLocalGet(
|
||||
artifact.viewerPath.replace(artifact.token, "bad-token"),
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects malformed artifact ids before reading from disk", async () => {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/view/not-a-real-id/not-a-real-token",
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("serves the shared viewer asset", async () => {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/assets/viewer.js",
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(String(res.body)).toContain("/plugins/diffs/assets/viewer-runtime.js?v=");
|
||||
});
|
||||
|
||||
it("serves the shared viewer runtime asset", async () => {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/assets/viewer-runtime.js",
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(String(res.body)).toContain("openclawDiffsReady");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "blocks non-loopback viewer access by default",
|
||||
request: remoteReq,
|
||||
allowRemoteViewer: false,
|
||||
expectedStatusCode: 404,
|
||||
},
|
||||
{
|
||||
name: "blocks loopback requests that carry proxy forwarding headers by default",
|
||||
request: localReq,
|
||||
headers: { "x-forwarded-for": "203.0.113.10" },
|
||||
allowRemoteViewer: false,
|
||||
expectedStatusCode: 404,
|
||||
},
|
||||
{
|
||||
name: "allows remote access when allowRemoteViewer is enabled",
|
||||
request: remoteReq,
|
||||
allowRemoteViewer: true,
|
||||
expectedStatusCode: 200,
|
||||
},
|
||||
{
|
||||
name: "allows proxied loopback requests when allowRemoteViewer is enabled",
|
||||
request: localReq,
|
||||
headers: { "x-forwarded-for": "203.0.113.10" },
|
||||
allowRemoteViewer: true,
|
||||
expectedStatusCode: 200,
|
||||
},
|
||||
])("$name", async ({ request, headers, allowRemoteViewer, expectedStatusCode }) => {
|
||||
const artifact = await createViewerArtifact(store);
|
||||
|
||||
const handler = createDiffsHttpHandler({ store, allowRemoteViewer });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
request({
|
||||
method: "GET",
|
||||
url: artifact.viewerPath,
|
||||
headers,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(expectedStatusCode);
|
||||
if (expectedStatusCode === 200) {
|
||||
expect(res.body).toBe("<html>viewer</html>");
|
||||
}
|
||||
});
|
||||
|
||||
it("rate-limits repeated remote misses", async () => {
|
||||
const handler = createDiffsHttpHandler({ store, allowRemoteViewer: true });
|
||||
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const miss = createMockServerResponse();
|
||||
await handler(
|
||||
remoteReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/view/aaaaaaaaaaaaaaaaaaaa/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
}),
|
||||
miss,
|
||||
);
|
||||
expect(miss.statusCode).toBe(404);
|
||||
}
|
||||
|
||||
const limited = createMockServerResponse();
|
||||
await handler(
|
||||
remoteReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/view/aaaaaaaaaaaaaaaaaaaa/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
}),
|
||||
limited,
|
||||
);
|
||||
expect(limited.statusCode).toBe(429);
|
||||
});
|
||||
});
|
||||
|
||||
async function createViewerArtifact(store: DiffArtifactStore) {
|
||||
return await store.createArtifact({
|
||||
html: "<html>viewer</html>",
|
||||
title: "Demo",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
});
|
||||
}
|
||||
|
||||
function localReq(input: {
|
||||
method: string;
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}): IncomingMessage {
|
||||
return {
|
||||
...input,
|
||||
headers: input.headers ?? {},
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
} as unknown as IncomingMessage;
|
||||
}
|
||||
|
||||
function remoteReq(input: {
|
||||
method: string;
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}): IncomingMessage {
|
||||
return {
|
||||
...input,
|
||||
headers: input.headers ?? {},
|
||||
socket: { remoteAddress: "203.0.113.10" },
|
||||
} as unknown as IncomingMessage;
|
||||
}
|
||||
106
extensions/diffs/src/render.test.ts
Normal file
106
extensions/diffs/src/render.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_DIFFS_TOOL_DEFAULTS, resolveDiffImageRenderOptions } from "./config.js";
|
||||
import { renderDiffDocument } from "./render.js";
|
||||
|
||||
describe("renderDiffDocument", () => {
|
||||
it("renders before/after input into a complete viewer document", async () => {
|
||||
const rendered = await renderDiffDocument(
|
||||
{
|
||||
kind: "before_after",
|
||||
before: "const value = 1;\n",
|
||||
after: "const value = 2;\n",
|
||||
path: "src/example.ts",
|
||||
},
|
||||
{
|
||||
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
|
||||
expandUnchanged: false,
|
||||
},
|
||||
);
|
||||
|
||||
expect(rendered.title).toBe("src/example.ts");
|
||||
expect(rendered.fileCount).toBe(1);
|
||||
expect(rendered.html).toContain("data-openclaw-diff-root");
|
||||
expect(rendered.html).toContain("src/example.ts");
|
||||
expect(rendered.html).toContain("/plugins/diffs/assets/viewer.js");
|
||||
expect(rendered.imageHtml).toContain("/plugins/diffs/assets/viewer.js");
|
||||
expect(rendered.imageHtml).toContain("max-width: 960px;");
|
||||
expect(rendered.imageHtml).toContain("--diffs-font-size: 16px;");
|
||||
expect(rendered.html).toContain("min-height: 100vh;");
|
||||
expect(rendered.html).toContain('"diffIndicators":"bars"');
|
||||
expect(rendered.html).toContain('"disableLineNumbers":false');
|
||||
expect(rendered.html).toContain("--diffs-line-height: 24px;");
|
||||
expect(rendered.html).toContain("--diffs-font-size: 15px;");
|
||||
expect(rendered.html).not.toContain("fonts.googleapis.com");
|
||||
});
|
||||
|
||||
it("renders multi-file patch input", async () => {
|
||||
const patch = [
|
||||
"diff --git a/a.ts b/a.ts",
|
||||
"--- a/a.ts",
|
||||
"+++ b/a.ts",
|
||||
"@@ -1 +1 @@",
|
||||
"-const a = 1;",
|
||||
"+const a = 2;",
|
||||
"diff --git a/b.ts b/b.ts",
|
||||
"--- a/b.ts",
|
||||
"+++ b/b.ts",
|
||||
"@@ -1 +1 @@",
|
||||
"-const b = 1;",
|
||||
"+const b = 2;",
|
||||
].join("\n");
|
||||
|
||||
const rendered = await renderDiffDocument(
|
||||
{
|
||||
kind: "patch",
|
||||
patch,
|
||||
title: "Workspace patch",
|
||||
},
|
||||
{
|
||||
presentation: {
|
||||
...DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
layout: "split",
|
||||
theme: "dark",
|
||||
},
|
||||
image: resolveDiffImageRenderOptions({
|
||||
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
fileQuality: "hq",
|
||||
fileMaxWidth: 1180,
|
||||
}),
|
||||
expandUnchanged: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(rendered.title).toBe("Workspace patch");
|
||||
expect(rendered.fileCount).toBe(2);
|
||||
expect(rendered.html).toContain("Workspace patch");
|
||||
expect(rendered.imageHtml).toContain("max-width: 1180px;");
|
||||
});
|
||||
|
||||
it("rejects patches that exceed file-count limits", async () => {
|
||||
const patch = Array.from({ length: 129 }, (_, i) => {
|
||||
return [
|
||||
`diff --git a/f${i}.ts b/f${i}.ts`,
|
||||
`--- a/f${i}.ts`,
|
||||
`+++ b/f${i}.ts`,
|
||||
"@@ -1 +1 @@",
|
||||
"-const x = 1;",
|
||||
"+const x = 2;",
|
||||
].join("\n");
|
||||
}).join("\n");
|
||||
|
||||
await expect(
|
||||
renderDiffDocument(
|
||||
{
|
||||
kind: "patch",
|
||||
patch,
|
||||
},
|
||||
{
|
||||
presentation: DEFAULT_DIFFS_TOOL_DEFAULTS,
|
||||
image: resolveDiffImageRenderOptions({ defaults: DEFAULT_DIFFS_TOOL_DEFAULTS }),
|
||||
expandUnchanged: false,
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("too many files");
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createMockServerResponse } from "../../../test/helpers/extensions/mock-http-response.js";
|
||||
import { createDiffsHttpHandler } from "./http.js";
|
||||
import { DiffArtifactStore } from "./store.js";
|
||||
import { createDiffStoreHarness } from "./test-helpers.js";
|
||||
|
||||
@@ -214,203 +211,3 @@ describe("DiffArtifactStore", () => {
|
||||
expect(cleanupSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDiffsHttpHandler", () => {
|
||||
let store: DiffArtifactStore;
|
||||
let cleanupRootDir: () => Promise<void>;
|
||||
|
||||
async function handleLocalGet(url: string) {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
return { handled, res };
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
({ store, cleanup: cleanupRootDir } = await createDiffStoreHarness("openclaw-diffs-http-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupRootDir();
|
||||
});
|
||||
|
||||
it("serves a stored diff document", async () => {
|
||||
const artifact = await createViewerArtifact(store);
|
||||
const { handled, res } = await handleLocalGet(artifact.viewerPath);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe("<html>viewer</html>");
|
||||
expect(res.getHeader("content-security-policy")).toContain("default-src 'none'");
|
||||
});
|
||||
|
||||
it("rejects invalid tokens", async () => {
|
||||
const artifact = await createViewerArtifact(store);
|
||||
const { handled, res } = await handleLocalGet(
|
||||
artifact.viewerPath.replace(artifact.token, "bad-token"),
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects malformed artifact ids before reading from disk", async () => {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/view/not-a-real-id/not-a-real-token",
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("serves the shared viewer asset", async () => {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/assets/viewer.js",
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(String(res.body)).toContain("/plugins/diffs/assets/viewer-runtime.js?v=");
|
||||
});
|
||||
|
||||
it("serves the shared viewer runtime asset", async () => {
|
||||
const handler = createDiffsHttpHandler({ store });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
localReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/assets/viewer-runtime.js",
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(String(res.body)).toContain("openclawDiffsReady");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "blocks non-loopback viewer access by default",
|
||||
request: remoteReq,
|
||||
allowRemoteViewer: false,
|
||||
expectedStatusCode: 404,
|
||||
},
|
||||
{
|
||||
name: "blocks loopback requests that carry proxy forwarding headers by default",
|
||||
request: localReq,
|
||||
headers: { "x-forwarded-for": "203.0.113.10" },
|
||||
allowRemoteViewer: false,
|
||||
expectedStatusCode: 404,
|
||||
},
|
||||
{
|
||||
name: "allows remote access when allowRemoteViewer is enabled",
|
||||
request: remoteReq,
|
||||
allowRemoteViewer: true,
|
||||
expectedStatusCode: 200,
|
||||
},
|
||||
{
|
||||
name: "allows proxied loopback requests when allowRemoteViewer is enabled",
|
||||
request: localReq,
|
||||
headers: { "x-forwarded-for": "203.0.113.10" },
|
||||
allowRemoteViewer: true,
|
||||
expectedStatusCode: 200,
|
||||
},
|
||||
])("$name", async ({ request, headers, allowRemoteViewer, expectedStatusCode }) => {
|
||||
const artifact = await createViewerArtifact(store);
|
||||
|
||||
const handler = createDiffsHttpHandler({ store, allowRemoteViewer });
|
||||
const res = createMockServerResponse();
|
||||
const handled = await handler(
|
||||
request({
|
||||
method: "GET",
|
||||
url: artifact.viewerPath,
|
||||
headers,
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(expectedStatusCode);
|
||||
if (expectedStatusCode === 200) {
|
||||
expect(res.body).toBe("<html>viewer</html>");
|
||||
}
|
||||
});
|
||||
|
||||
it("rate-limits repeated remote misses", async () => {
|
||||
const handler = createDiffsHttpHandler({ store, allowRemoteViewer: true });
|
||||
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const miss = createMockServerResponse();
|
||||
await handler(
|
||||
remoteReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/view/aaaaaaaaaaaaaaaaaaaa/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
}),
|
||||
miss,
|
||||
);
|
||||
expect(miss.statusCode).toBe(404);
|
||||
}
|
||||
|
||||
const limited = createMockServerResponse();
|
||||
await handler(
|
||||
remoteReq({
|
||||
method: "GET",
|
||||
url: "/plugins/diffs/view/aaaaaaaaaaaaaaaaaaaa/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
}),
|
||||
limited,
|
||||
);
|
||||
expect(limited.statusCode).toBe(429);
|
||||
});
|
||||
});
|
||||
|
||||
async function createViewerArtifact(store: DiffArtifactStore) {
|
||||
return await store.createArtifact({
|
||||
html: "<html>viewer</html>",
|
||||
title: "Demo",
|
||||
inputKind: "before_after",
|
||||
fileCount: 1,
|
||||
});
|
||||
}
|
||||
|
||||
function localReq(input: {
|
||||
method: string;
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}): IncomingMessage {
|
||||
return {
|
||||
...input,
|
||||
headers: input.headers ?? {},
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
} as unknown as IncomingMessage;
|
||||
}
|
||||
|
||||
function remoteReq(input: {
|
||||
method: string;
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
}): IncomingMessage {
|
||||
return {
|
||||
...input,
|
||||
headers: input.headers ?? {},
|
||||
socket: { remoteAddress: "203.0.113.10" },
|
||||
} as unknown as IncomingMessage;
|
||||
}
|
||||
|
||||
55
extensions/diffs/src/url.test.ts
Normal file
55
extensions/diffs/src/url.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildViewerUrl, normalizeViewerBaseUrl } from "./url.js";
|
||||
|
||||
describe("diffs viewer URL helpers", () => {
|
||||
it("defaults to loopback for lan/tailnet bind modes", () => {
|
||||
expect(
|
||||
buildViewerUrl({
|
||||
config: { gateway: { bind: "lan", port: 18789 } },
|
||||
viewerPath: "/plugins/diffs/view/id/token",
|
||||
}),
|
||||
).toBe("http://127.0.0.1:18789/plugins/diffs/view/id/token");
|
||||
|
||||
expect(
|
||||
buildViewerUrl({
|
||||
config: { gateway: { bind: "tailnet", port: 24444 } },
|
||||
viewerPath: "/plugins/diffs/view/id/token",
|
||||
}),
|
||||
).toBe("http://127.0.0.1:24444/plugins/diffs/view/id/token");
|
||||
});
|
||||
|
||||
it("uses custom bind host when provided", () => {
|
||||
expect(
|
||||
buildViewerUrl({
|
||||
config: {
|
||||
gateway: {
|
||||
bind: "custom",
|
||||
customBindHost: "gateway.example.com",
|
||||
port: 443,
|
||||
tls: { enabled: true },
|
||||
},
|
||||
},
|
||||
viewerPath: "/plugins/diffs/view/id/token",
|
||||
}),
|
||||
).toBe("https://gateway.example.com/plugins/diffs/view/id/token");
|
||||
});
|
||||
|
||||
it("joins viewer path under baseUrl pathname", () => {
|
||||
expect(
|
||||
buildViewerUrl({
|
||||
config: {},
|
||||
baseUrl: "https://example.com/openclaw",
|
||||
viewerPath: "/plugins/diffs/view/id/token",
|
||||
}),
|
||||
).toBe("https://example.com/openclaw/plugins/diffs/view/id/token");
|
||||
});
|
||||
|
||||
it("rejects base URLs with query/hash", () => {
|
||||
expect(() => normalizeViewerBaseUrl("https://example.com?a=1")).toThrow(
|
||||
"baseUrl must not include query/hash",
|
||||
);
|
||||
expect(() => normalizeViewerBaseUrl("https://example.com#frag")).toThrow(
|
||||
"baseUrl must not include query/hash",
|
||||
);
|
||||
});
|
||||
});
|
||||
22
extensions/diffs/src/viewer-assets.test.ts
Normal file
22
extensions/diffs/src/viewer-assets.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getServedViewerAsset, VIEWER_LOADER_PATH, VIEWER_RUNTIME_PATH } from "./viewer-assets.js";
|
||||
|
||||
describe("viewer assets", () => {
|
||||
it("serves a stable loader that points at the current runtime bundle", async () => {
|
||||
const loader = await getServedViewerAsset(VIEWER_LOADER_PATH);
|
||||
|
||||
expect(loader?.contentType).toBe("text/javascript; charset=utf-8");
|
||||
expect(String(loader?.body)).toContain(`${VIEWER_RUNTIME_PATH}?v=`);
|
||||
});
|
||||
|
||||
it("serves the runtime bundle body", async () => {
|
||||
const runtime = await getServedViewerAsset(VIEWER_RUNTIME_PATH);
|
||||
|
||||
expect(runtime?.contentType).toBe("text/javascript; charset=utf-8");
|
||||
expect(String(runtime?.body)).toContain("openclawDiffsReady");
|
||||
});
|
||||
|
||||
it("returns null for unknown asset paths", async () => {
|
||||
await expect(getServedViewerAsset("/plugins/diffs/assets/not-real.js")).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
55
extensions/diffs/src/viewer-payload.test.ts
Normal file
55
extensions/diffs/src/viewer-payload.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseViewerPayloadJson } from "./viewer-payload.js";
|
||||
|
||||
function buildValidPayload(): Record<string, unknown> {
|
||||
return {
|
||||
prerenderedHTML: "<div>ok</div>",
|
||||
langs: ["text"],
|
||||
oldFile: {
|
||||
name: "README.md",
|
||||
contents: "before",
|
||||
},
|
||||
newFile: {
|
||||
name: "README.md",
|
||||
contents: "after",
|
||||
},
|
||||
options: {
|
||||
theme: {
|
||||
light: "pierre-light",
|
||||
dark: "pierre-dark",
|
||||
},
|
||||
diffStyle: "unified",
|
||||
diffIndicators: "bars",
|
||||
disableLineNumbers: false,
|
||||
expandUnchanged: false,
|
||||
themeType: "dark",
|
||||
backgroundEnabled: true,
|
||||
overflow: "wrap",
|
||||
unsafeCSS: ":host{}",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("parseViewerPayloadJson", () => {
|
||||
it("accepts valid payload JSON", () => {
|
||||
const parsed = parseViewerPayloadJson(JSON.stringify(buildValidPayload()));
|
||||
expect(parsed.options.diffStyle).toBe("unified");
|
||||
expect(parsed.options.diffIndicators).toBe("bars");
|
||||
});
|
||||
|
||||
it("rejects payloads with invalid shape", () => {
|
||||
const broken = buildValidPayload();
|
||||
broken.options = {
|
||||
...(broken.options as Record<string, unknown>),
|
||||
diffIndicators: "invalid",
|
||||
};
|
||||
|
||||
expect(() => parseViewerPayloadJson(JSON.stringify(broken))).toThrow(
|
||||
"Diff payload has invalid shape.",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid JSON", () => {
|
||||
expect(() => parseViewerPayloadJson("{not-json")).toThrow("Diff payload is not valid JSON.");
|
||||
});
|
||||
});
|
||||
79
extensions/discord/src/group-policy.test.ts
Normal file
79
extensions/discord/src/group-policy.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveDiscordGroupRequireMention,
|
||||
resolveDiscordGroupToolPolicy,
|
||||
} from "./group-policy.js";
|
||||
|
||||
describe("discord group policy", () => {
|
||||
it("prefers channel policy, then guild policy, with sender-specific overrides", () => {
|
||||
const discordCfg = {
|
||||
channels: {
|
||||
discord: {
|
||||
token: "discord-test",
|
||||
guilds: {
|
||||
guild1: {
|
||||
requireMention: false,
|
||||
tools: { allow: ["message.guild"] },
|
||||
toolsBySender: {
|
||||
"id:user:guild-admin": { allow: ["sessions.list"] },
|
||||
},
|
||||
channels: {
|
||||
"123": {
|
||||
requireMention: true,
|
||||
tools: { allow: ["message.channel"] },
|
||||
toolsBySender: {
|
||||
"id:user:channel-admin": { deny: ["exec"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
} as any;
|
||||
|
||||
expect(
|
||||
resolveDiscordGroupRequireMention({ cfg: discordCfg, groupSpace: "guild1", groupId: "123" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolveDiscordGroupRequireMention({
|
||||
cfg: discordCfg,
|
||||
groupSpace: "guild1",
|
||||
groupId: "missing",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
resolveDiscordGroupToolPolicy({
|
||||
cfg: discordCfg,
|
||||
groupSpace: "guild1",
|
||||
groupId: "123",
|
||||
senderId: "user:channel-admin",
|
||||
}),
|
||||
).toEqual({ deny: ["exec"] });
|
||||
expect(
|
||||
resolveDiscordGroupToolPolicy({
|
||||
cfg: discordCfg,
|
||||
groupSpace: "guild1",
|
||||
groupId: "123",
|
||||
senderId: "user:someone",
|
||||
}),
|
||||
).toEqual({ allow: ["message.channel"] });
|
||||
expect(
|
||||
resolveDiscordGroupToolPolicy({
|
||||
cfg: discordCfg,
|
||||
groupSpace: "guild1",
|
||||
groupId: "missing",
|
||||
senderId: "user:guild-admin",
|
||||
}),
|
||||
).toEqual({ allow: ["sessions.list"] });
|
||||
expect(
|
||||
resolveDiscordGroupToolPolicy({
|
||||
cfg: discordCfg,
|
||||
groupSpace: "guild1",
|
||||
groupId: "missing",
|
||||
senderId: "user:someone",
|
||||
}),
|
||||
).toEqual({ allow: ["message.guild"] });
|
||||
});
|
||||
});
|
||||
@@ -1,114 +1,87 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { waitForDiscordGatewayStop } from "./monitor.gateway.js";
|
||||
import type { DiscordGatewayEvent } from "./monitor/gateway-supervisor.js";
|
||||
|
||||
function createGatewayEvent(
|
||||
type: DiscordGatewayEvent["type"],
|
||||
message: string,
|
||||
): DiscordGatewayEvent {
|
||||
const err = new Error(message);
|
||||
return {
|
||||
type,
|
||||
err,
|
||||
message: String(err),
|
||||
shouldStopLifecycle: type !== "other",
|
||||
};
|
||||
}
|
||||
|
||||
function createGatewayWaitHarness() {
|
||||
let lifecycleHandler: ((event: DiscordGatewayEvent) => void) | undefined;
|
||||
const emitter = new EventEmitter();
|
||||
const disconnect = vi.fn();
|
||||
const abort = new AbortController();
|
||||
const attachLifecycle = vi.fn((handler: (event: DiscordGatewayEvent) => void) => {
|
||||
lifecycleHandler = handler;
|
||||
});
|
||||
const detachLifecycle = vi.fn(() => {
|
||||
lifecycleHandler = undefined;
|
||||
});
|
||||
return {
|
||||
abort,
|
||||
attachLifecycle,
|
||||
detachLifecycle,
|
||||
disconnect,
|
||||
emitGatewayEvent: (event: DiscordGatewayEvent) => {
|
||||
lifecycleHandler?.(event);
|
||||
},
|
||||
gatewaySupervisor: {
|
||||
attachLifecycle,
|
||||
detachLifecycle,
|
||||
},
|
||||
};
|
||||
return { emitter, disconnect, abort };
|
||||
}
|
||||
|
||||
function startGatewayWait(params?: {
|
||||
disconnect?: () => void;
|
||||
onGatewayEvent?: (event: DiscordGatewayEvent) => "continue" | "stop";
|
||||
onGatewayError?: (error: unknown) => void;
|
||||
shouldStopOnError?: (error: unknown) => boolean;
|
||||
registerForceStop?: (fn: (error: unknown) => void) => void;
|
||||
}) {
|
||||
const harness = createGatewayWaitHarness();
|
||||
if (params?.disconnect) {
|
||||
harness.disconnect.mockImplementation(params.disconnect);
|
||||
}
|
||||
const promise = waitForDiscordGatewayStop({
|
||||
gateway: { disconnect: harness.disconnect },
|
||||
gateway: { emitter: harness.emitter, disconnect: harness.disconnect },
|
||||
abortSignal: harness.abort.signal,
|
||||
gatewaySupervisor: harness.gatewaySupervisor,
|
||||
...(params?.onGatewayEvent ? { onGatewayEvent: params.onGatewayEvent } : {}),
|
||||
...(params?.onGatewayError ? { onGatewayError: params.onGatewayError } : {}),
|
||||
...(params?.shouldStopOnError ? { shouldStopOnError: params.shouldStopOnError } : {}),
|
||||
...(params?.registerForceStop ? { registerForceStop: params.registerForceStop } : {}),
|
||||
});
|
||||
return { ...harness, promise };
|
||||
}
|
||||
|
||||
async function expectAbortToResolve(params: {
|
||||
abort: AbortController;
|
||||
attachLifecycle: ReturnType<typeof vi.fn>;
|
||||
detachLifecycle: ReturnType<typeof vi.fn>;
|
||||
emitter: EventEmitter;
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
abort: AbortController;
|
||||
promise: Promise<void>;
|
||||
expectedDisconnectBeforeAbort?: number;
|
||||
}) {
|
||||
if (params.expectedDisconnectBeforeAbort !== undefined) {
|
||||
expect(params.disconnect).toHaveBeenCalledTimes(params.expectedDisconnectBeforeAbort);
|
||||
}
|
||||
expect(params.attachLifecycle).toHaveBeenCalledTimes(1);
|
||||
expect(params.emitter.listenerCount("error")).toBe(1);
|
||||
params.abort.abort();
|
||||
await expect(params.promise).resolves.toBeUndefined();
|
||||
expect(params.disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(params.detachLifecycle).toHaveBeenCalledTimes(1);
|
||||
expect(params.emitter.listenerCount("error")).toBe(0);
|
||||
}
|
||||
|
||||
describe("waitForDiscordGatewayStop", () => {
|
||||
it("resolves on abort and disconnects gateway", async () => {
|
||||
const { abort, attachLifecycle, detachLifecycle, disconnect, promise } = startGatewayWait();
|
||||
await expectAbortToResolve({ abort, attachLifecycle, detachLifecycle, disconnect, promise });
|
||||
const { emitter, disconnect, abort, promise } = startGatewayWait();
|
||||
await expectAbortToResolve({ emitter, disconnect, abort, promise });
|
||||
});
|
||||
|
||||
it("rejects on lifecycle stop events and disconnects", async () => {
|
||||
const fatalEvent = createGatewayEvent("fatal", "boom");
|
||||
const { detachLifecycle, disconnect, emitGatewayEvent, promise } = startGatewayWait();
|
||||
it("rejects on gateway error and disconnects", async () => {
|
||||
const onGatewayError = vi.fn();
|
||||
const err = new Error("boom");
|
||||
|
||||
emitGatewayEvent(fatalEvent);
|
||||
const { emitter, disconnect, abort, promise } = startGatewayWait({
|
||||
onGatewayError,
|
||||
});
|
||||
|
||||
emitter.emit("error", err);
|
||||
|
||||
await expect(promise).rejects.toThrow("boom");
|
||||
expect(onGatewayError).toHaveBeenCalledWith(err);
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(emitter.listenerCount("error")).toBe(0);
|
||||
|
||||
abort.abort();
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(detachLifecycle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ignores transient gateway events when instructed", async () => {
|
||||
const transientEvent = createGatewayEvent("other", "transient");
|
||||
const onGatewayEvent = vi.fn(() => "continue" as const);
|
||||
const { abort, attachLifecycle, detachLifecycle, disconnect, emitGatewayEvent, promise } =
|
||||
startGatewayWait({
|
||||
onGatewayEvent,
|
||||
});
|
||||
it("ignores gateway errors when instructed", async () => {
|
||||
const onGatewayError = vi.fn();
|
||||
const err = new Error("transient");
|
||||
|
||||
emitGatewayEvent(transientEvent);
|
||||
expect(onGatewayEvent).toHaveBeenCalledWith(transientEvent);
|
||||
const { emitter, disconnect, abort, promise } = startGatewayWait({
|
||||
onGatewayError,
|
||||
shouldStopOnError: () => false,
|
||||
});
|
||||
|
||||
emitter.emit("error", err);
|
||||
expect(onGatewayError).toHaveBeenCalledWith(err);
|
||||
await expectAbortToResolve({
|
||||
abort,
|
||||
attachLifecycle,
|
||||
detachLifecycle,
|
||||
emitter,
|
||||
disconnect,
|
||||
abort,
|
||||
promise,
|
||||
expectedDisconnectBeforeAbort: 0,
|
||||
});
|
||||
@@ -116,6 +89,7 @@ describe("waitForDiscordGatewayStop", () => {
|
||||
|
||||
it("resolves on abort without a gateway", async () => {
|
||||
const abort = new AbortController();
|
||||
|
||||
const promise = waitForDiscordGatewayStop({
|
||||
abortSignal: abort.signal,
|
||||
});
|
||||
@@ -128,7 +102,7 @@ describe("waitForDiscordGatewayStop", () => {
|
||||
it("rejects via registerForceStop and disconnects gateway", async () => {
|
||||
let forceStop: ((err: unknown) => void) | undefined;
|
||||
|
||||
const { detachLifecycle, disconnect, promise } = startGatewayWait({
|
||||
const { emitter, disconnect, promise } = startGatewayWait({
|
||||
registerForceStop: (fn) => {
|
||||
forceStop = fn;
|
||||
},
|
||||
@@ -141,7 +115,7 @@ describe("waitForDiscordGatewayStop", () => {
|
||||
|
||||
await expect(promise).rejects.toThrow("reconnect watchdog timeout");
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(detachLifecycle).toHaveBeenCalledTimes(1);
|
||||
expect(emitter.listenerCount("error")).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores forceStop after promise already settled", async () => {
|
||||
@@ -159,49 +133,4 @@ describe("waitForDiscordGatewayStop", () => {
|
||||
forceStop?.(new Error("too late"));
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps the lifecycle handler active until disconnect returns on abort", async () => {
|
||||
const onGatewayEvent = vi.fn(() => "stop" as const);
|
||||
const fatalEvent = createGatewayEvent("fatal", "disconnect emitted error");
|
||||
let emitFromDisconnect: ((event: DiscordGatewayEvent) => void) | undefined;
|
||||
const { abort, detachLifecycle, disconnect, emitGatewayEvent, promise } = startGatewayWait({
|
||||
onGatewayEvent,
|
||||
disconnect: () => {
|
||||
emitFromDisconnect?.(fatalEvent);
|
||||
},
|
||||
});
|
||||
emitFromDisconnect = emitGatewayEvent;
|
||||
|
||||
abort.abort();
|
||||
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
expect(onGatewayEvent).toHaveBeenCalledWith(fatalEvent);
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(detachLifecycle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps the original rejection when disconnect emits another stop event", async () => {
|
||||
const firstEvent = createGatewayEvent("fatal", "first failure");
|
||||
const secondEvent = createGatewayEvent("fatal", "second failure");
|
||||
const seenEvents: DiscordGatewayEvent[] = [];
|
||||
let emitFromDisconnect: ((event: DiscordGatewayEvent) => void) | undefined;
|
||||
const { emitGatewayEvent, promise } = startGatewayWait({
|
||||
onGatewayEvent: (event) => {
|
||||
seenEvents.push(event);
|
||||
return "stop";
|
||||
},
|
||||
disconnect: () => {
|
||||
emitFromDisconnect?.(secondEvent);
|
||||
},
|
||||
});
|
||||
emitFromDisconnect = emitGatewayEvent;
|
||||
|
||||
emitGatewayEvent(firstEvent);
|
||||
|
||||
await expect(promise).rejects.toThrow("first failure");
|
||||
expect(seenEvents.map((event) => event.message)).toEqual([
|
||||
firstEvent.message,
|
||||
secondEvent.message,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import type { EventEmitter } from "node:events";
|
||||
import type {
|
||||
DiscordGatewayEvent,
|
||||
DiscordGatewaySupervisor,
|
||||
} from "./monitor/gateway-supervisor.js";
|
||||
|
||||
export type DiscordGatewayHandle = {
|
||||
emitter?: Pick<EventEmitter, "on" | "removeListener">;
|
||||
disconnect?: () => void;
|
||||
};
|
||||
|
||||
export type WaitForDiscordGatewayStopParams = {
|
||||
gateway?: DiscordGatewayHandle;
|
||||
abortSignal?: AbortSignal;
|
||||
gatewaySupervisor?: Pick<DiscordGatewaySupervisor, "attachLifecycle" | "detachLifecycle">;
|
||||
onGatewayEvent?: (event: DiscordGatewayEvent) => "continue" | "stop";
|
||||
onGatewayError?: (err: unknown) => void;
|
||||
shouldStopOnError?: (err: unknown) => boolean;
|
||||
registerForceStop?: (forceStop: (err: unknown) => void) => void;
|
||||
};
|
||||
|
||||
@@ -23,24 +20,23 @@ export function getDiscordGatewayEmitter(gateway?: unknown): EventEmitter | unde
|
||||
export async function waitForDiscordGatewayStop(
|
||||
params: WaitForDiscordGatewayStopParams,
|
||||
): Promise<void> {
|
||||
const { gateway, abortSignal } = params;
|
||||
const { gateway, abortSignal, onGatewayError, shouldStopOnError } = params;
|
||||
const emitter = gateway?.emitter;
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const cleanup = () => {
|
||||
abortSignal?.removeEventListener("abort", onAbort);
|
||||
params.gatewaySupervisor?.detachLifecycle();
|
||||
emitter?.removeListener("error", onGatewayErrorEvent);
|
||||
};
|
||||
const finishResolve = () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
try {
|
||||
gateway?.disconnect?.();
|
||||
} finally {
|
||||
// remove listeners after disconnect so late "error" events emitted
|
||||
// during disconnect are still handled instead of becoming uncaught
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
@@ -49,20 +45,21 @@ export async function waitForDiscordGatewayStop(
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
try {
|
||||
gateway?.disconnect?.();
|
||||
} finally {
|
||||
cleanup();
|
||||
reject(err);
|
||||
}
|
||||
};
|
||||
const onAbort = () => {
|
||||
finishResolve();
|
||||
};
|
||||
const onGatewayEvent = (event: DiscordGatewayEvent) => {
|
||||
const shouldStop = (params.onGatewayEvent?.(event) ?? "stop") === "stop";
|
||||
const onGatewayErrorEvent = (err: unknown) => {
|
||||
onGatewayError?.(err);
|
||||
const shouldStop = shouldStopOnError?.(err) ?? true;
|
||||
if (shouldStop) {
|
||||
finishReject(event.err);
|
||||
finishReject(err);
|
||||
}
|
||||
};
|
||||
const onForceStop = (err: unknown) => {
|
||||
@@ -75,7 +72,7 @@ export async function waitForDiscordGatewayStop(
|
||||
}
|
||||
|
||||
abortSignal?.addEventListener("abort", onAbort, { once: true });
|
||||
params.gatewaySupervisor?.attachLifecycle(onGatewayEvent);
|
||||
emitter?.on("error", onGatewayErrorEvent);
|
||||
params.registerForceStop?.(onForceStop);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Client } from "@buape/carbon";
|
||||
import { MessageType } from "@buape/carbon";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { expectPairingReplyText } from "../../../test/helpers/pairing-reply.js";
|
||||
import {
|
||||
dispatchMock,
|
||||
loadConfigMock,
|
||||
@@ -237,10 +236,7 @@ describe("discord tool result dispatch", () => {
|
||||
expect(dispatchMock).not.toHaveBeenCalled();
|
||||
expect(upsertPairingRequestMock).toHaveBeenCalled();
|
||||
expect(sendMock).toHaveBeenCalledTimes(1);
|
||||
expectPairingReplyText(String(sendMock.mock.calls[0]?.[1] ?? ""), {
|
||||
channel: "discord",
|
||||
idLine: "Your Discord user id: u2",
|
||||
code: "PAIRCODE",
|
||||
});
|
||||
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain("Your Discord user id: u2");
|
||||
expect(String(sendMock.mock.calls[0]?.[1] ?? "")).toContain("Pairing code: PAIRCODE");
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
33
extensions/discord/src/monitor/gateway-error-guard.test.ts
Normal file
33
extensions/discord/src/monitor/gateway-error-guard.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { attachEarlyGatewayErrorGuard } from "./gateway-error-guard.js";
|
||||
|
||||
describe("attachEarlyGatewayErrorGuard", () => {
|
||||
it("captures gateway errors until released", () => {
|
||||
const emitter = new EventEmitter();
|
||||
const fallbackErrorListener = vi.fn();
|
||||
emitter.on("error", fallbackErrorListener);
|
||||
const client = {
|
||||
getPlugin: vi.fn(() => ({ emitter })),
|
||||
};
|
||||
|
||||
const guard = attachEarlyGatewayErrorGuard(client as never);
|
||||
emitter.emit("error", new Error("Fatal Gateway error: 4014"));
|
||||
expect(guard.pendingErrors).toHaveLength(1);
|
||||
|
||||
guard.release();
|
||||
emitter.emit("error", new Error("Fatal Gateway error: 4000"));
|
||||
expect(guard.pendingErrors).toHaveLength(1);
|
||||
expect(fallbackErrorListener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("returns noop guard when gateway emitter is unavailable", () => {
|
||||
const client = {
|
||||
getPlugin: vi.fn(() => undefined),
|
||||
};
|
||||
|
||||
const guard = attachEarlyGatewayErrorGuard(client as never);
|
||||
expect(guard.pendingErrors).toEqual([]);
|
||||
expect(() => guard.release()).not.toThrow();
|
||||
});
|
||||
});
|
||||
36
extensions/discord/src/monitor/gateway-error-guard.ts
Normal file
36
extensions/discord/src/monitor/gateway-error-guard.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Client } from "@buape/carbon";
|
||||
import { getDiscordGatewayEmitter } from "../monitor.gateway.js";
|
||||
|
||||
export type EarlyGatewayErrorGuard = {
|
||||
pendingErrors: unknown[];
|
||||
release: () => void;
|
||||
};
|
||||
|
||||
export function attachEarlyGatewayErrorGuard(client: Client): EarlyGatewayErrorGuard {
|
||||
const pendingErrors: unknown[] = [];
|
||||
const gateway = client.getPlugin("gateway");
|
||||
const emitter = getDiscordGatewayEmitter(gateway);
|
||||
if (!emitter) {
|
||||
return {
|
||||
pendingErrors,
|
||||
release: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
let released = false;
|
||||
const onGatewayError = (err: unknown) => {
|
||||
pendingErrors.push(err);
|
||||
};
|
||||
emitter.on("error", onGatewayError);
|
||||
|
||||
return {
|
||||
pendingErrors,
|
||||
release: () => {
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
released = true;
|
||||
emitter.removeListener("error", onGatewayError);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
classifyDiscordGatewayEvent,
|
||||
createDiscordGatewaySupervisor,
|
||||
} from "./gateway-supervisor.js";
|
||||
|
||||
describe("classifyDiscordGatewayEvent", () => {
|
||||
it("maps raw gateway errors onto domain events", () => {
|
||||
const reconnectEvent = classifyDiscordGatewayEvent({
|
||||
err: new Error("Max reconnect attempts (0) reached after code 1006"),
|
||||
isDisallowedIntentsError: () => false,
|
||||
});
|
||||
const fatalEvent = classifyDiscordGatewayEvent({
|
||||
err: new Error("Fatal Gateway error: 4000"),
|
||||
isDisallowedIntentsError: () => false,
|
||||
});
|
||||
const disallowedEvent = classifyDiscordGatewayEvent({
|
||||
err: new Error("Fatal Gateway error: 4014"),
|
||||
isDisallowedIntentsError: (err) => String(err).includes("4014"),
|
||||
});
|
||||
const transientEvent = classifyDiscordGatewayEvent({
|
||||
err: new Error("transient"),
|
||||
isDisallowedIntentsError: () => false,
|
||||
});
|
||||
|
||||
expect(reconnectEvent.type).toBe("reconnect-exhausted");
|
||||
expect(reconnectEvent.shouldStopLifecycle).toBe(true);
|
||||
expect(fatalEvent.type).toBe("fatal");
|
||||
expect(disallowedEvent.type).toBe("disallowed-intents");
|
||||
expect(transientEvent.type).toBe("other");
|
||||
expect(transientEvent.shouldStopLifecycle).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDiscordGatewaySupervisor", () => {
|
||||
it("buffers early errors, routes active ones, and logs late teardown errors", () => {
|
||||
const emitter = new EventEmitter();
|
||||
const runtime = {
|
||||
error: vi.fn(),
|
||||
};
|
||||
const supervisor = createDiscordGatewaySupervisor({
|
||||
client: {
|
||||
getPlugin: vi.fn(() => ({ emitter })),
|
||||
} as never,
|
||||
isDisallowedIntentsError: (err) => String(err).includes("4014"),
|
||||
runtime: runtime as never,
|
||||
});
|
||||
const seen: string[] = [];
|
||||
|
||||
emitter.emit("error", new Error("Fatal Gateway error: 4014"));
|
||||
expect(
|
||||
supervisor.drainPending((event) => {
|
||||
seen.push(event.type);
|
||||
return "continue";
|
||||
}),
|
||||
).toBe("continue");
|
||||
|
||||
supervisor.attachLifecycle((event) => {
|
||||
seen.push(event.type);
|
||||
});
|
||||
emitter.emit("error", new Error("Fatal Gateway error: 4000"));
|
||||
|
||||
supervisor.detachLifecycle();
|
||||
emitter.emit("error", new Error("Max reconnect attempts (0) reached after code 1006"));
|
||||
|
||||
expect(seen).toEqual(["disallowed-intents", "fatal"]);
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("suppressed late gateway reconnect-exhausted error during teardown"),
|
||||
);
|
||||
});
|
||||
|
||||
it("is idempotent on dispose and noops without an emitter", () => {
|
||||
const supervisor = createDiscordGatewaySupervisor({
|
||||
client: {
|
||||
getPlugin: vi.fn(() => undefined),
|
||||
} as never,
|
||||
isDisallowedIntentsError: () => false,
|
||||
runtime: { error: vi.fn() } as never,
|
||||
});
|
||||
|
||||
expect(supervisor.drainPending(() => "continue")).toBe("continue");
|
||||
expect(() => supervisor.attachLifecycle(() => {})).not.toThrow();
|
||||
expect(() => supervisor.detachLifecycle()).not.toThrow();
|
||||
expect(() => supervisor.dispose()).not.toThrow();
|
||||
expect(() => supervisor.dispose()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,151 +0,0 @@
|
||||
import type { EventEmitter } from "node:events";
|
||||
import type { Client } from "@buape/carbon";
|
||||
import { danger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { getDiscordGatewayEmitter } from "../monitor.gateway.js";
|
||||
|
||||
export type DiscordGatewayEventType =
|
||||
| "disallowed-intents"
|
||||
| "fatal"
|
||||
| "other"
|
||||
| "reconnect-exhausted";
|
||||
|
||||
export type DiscordGatewayEvent = {
|
||||
type: DiscordGatewayEventType;
|
||||
err: unknown;
|
||||
message: string;
|
||||
shouldStopLifecycle: boolean;
|
||||
};
|
||||
|
||||
export type DiscordGatewaySupervisor = {
|
||||
emitter?: EventEmitter;
|
||||
attachLifecycle: (handler: (event: DiscordGatewayEvent) => void) => void;
|
||||
detachLifecycle: () => void;
|
||||
drainPending: (
|
||||
handler: (event: DiscordGatewayEvent) => "continue" | "stop",
|
||||
) => "continue" | "stop";
|
||||
dispose: () => void;
|
||||
};
|
||||
|
||||
type GatewaySupervisorPhase = "active" | "buffering" | "disposed" | "teardown";
|
||||
|
||||
export function classifyDiscordGatewayEvent(params: {
|
||||
err: unknown;
|
||||
isDisallowedIntentsError: (err: unknown) => boolean;
|
||||
}): DiscordGatewayEvent {
|
||||
const message = String(params.err);
|
||||
if (params.isDisallowedIntentsError(params.err)) {
|
||||
return {
|
||||
type: "disallowed-intents",
|
||||
err: params.err,
|
||||
message,
|
||||
shouldStopLifecycle: true,
|
||||
};
|
||||
}
|
||||
if (message.includes("Max reconnect attempts")) {
|
||||
return {
|
||||
type: "reconnect-exhausted",
|
||||
err: params.err,
|
||||
message,
|
||||
shouldStopLifecycle: true,
|
||||
};
|
||||
}
|
||||
if (message.includes("Fatal Gateway error")) {
|
||||
return {
|
||||
type: "fatal",
|
||||
err: params.err,
|
||||
message,
|
||||
shouldStopLifecycle: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "other",
|
||||
err: params.err,
|
||||
message,
|
||||
shouldStopLifecycle: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function createDiscordGatewaySupervisor(params: {
|
||||
client: Client;
|
||||
isDisallowedIntentsError: (err: unknown) => boolean;
|
||||
runtime: RuntimeEnv;
|
||||
}): DiscordGatewaySupervisor {
|
||||
const gateway = params.client.getPlugin("gateway");
|
||||
const emitter = getDiscordGatewayEmitter(gateway);
|
||||
const pending: DiscordGatewayEvent[] = [];
|
||||
if (!emitter) {
|
||||
return {
|
||||
attachLifecycle: () => {},
|
||||
detachLifecycle: () => {},
|
||||
drainPending: () => "continue",
|
||||
dispose: () => {},
|
||||
emitter,
|
||||
};
|
||||
}
|
||||
|
||||
let lifecycleHandler: ((event: DiscordGatewayEvent) => void) | undefined;
|
||||
let phase: GatewaySupervisorPhase = "buffering";
|
||||
let disposed = false;
|
||||
const logLateTeardownEvent = (event: DiscordGatewayEvent) => {
|
||||
params.runtime.error?.(
|
||||
danger(
|
||||
`discord: suppressed late gateway ${event.type} error during teardown: ${event.message}`,
|
||||
),
|
||||
);
|
||||
};
|
||||
const onGatewayError = (err: unknown) => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
const event = classifyDiscordGatewayEvent({
|
||||
err,
|
||||
isDisallowedIntentsError: params.isDisallowedIntentsError,
|
||||
});
|
||||
if (phase === "active" && lifecycleHandler) {
|
||||
lifecycleHandler(event);
|
||||
return;
|
||||
}
|
||||
if (phase === "teardown") {
|
||||
logLateTeardownEvent(event);
|
||||
return;
|
||||
}
|
||||
pending.push(event);
|
||||
};
|
||||
emitter.on("error", onGatewayError);
|
||||
|
||||
return {
|
||||
emitter,
|
||||
attachLifecycle: (handler) => {
|
||||
lifecycleHandler = handler;
|
||||
phase = "active";
|
||||
},
|
||||
detachLifecycle: () => {
|
||||
lifecycleHandler = undefined;
|
||||
phase = "teardown";
|
||||
},
|
||||
drainPending: (handler) => {
|
||||
if (pending.length === 0) {
|
||||
return "continue";
|
||||
}
|
||||
const queued = [...pending];
|
||||
pending.length = 0;
|
||||
for (const event of queued) {
|
||||
if (handler(event) === "stop") {
|
||||
return "stop";
|
||||
}
|
||||
}
|
||||
return "continue";
|
||||
},
|
||||
dispose: () => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
disposed = true;
|
||||
lifecycleHandler = undefined;
|
||||
phase = "disposed";
|
||||
pending.length = 0;
|
||||
emitter.removeListener("error", onGatewayError);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
resetDiscordComponentRuntimeMocks,
|
||||
upsertPairingRequestMock,
|
||||
} from "../../../../test/helpers/extensions/discord-component-runtime.js";
|
||||
import { expectPairingReplyText } from "../../../../test/helpers/pairing-reply.js";
|
||||
import { createAgentComponentButton, createAgentSelectMenu } from "./agent-components.js";
|
||||
|
||||
describe("agent components", () => {
|
||||
@@ -76,10 +75,9 @@ describe("agent components", () => {
|
||||
expect(defer).not.toHaveBeenCalled();
|
||||
expect(reply).toHaveBeenCalledTimes(1);
|
||||
const pairingText = String(reply.mock.calls[0]?.[0]?.content ?? "");
|
||||
const code = expectPairingReplyText(pairingText, {
|
||||
channel: "discord",
|
||||
idLine: "Your Discord user id: 123456789",
|
||||
});
|
||||
expect(pairingText).toContain("Pairing code:");
|
||||
const code = pairingText.match(/Pairing code:\s*([A-Z2-9]{8})/)?.[1];
|
||||
expect(code).toBeDefined();
|
||||
expect(pairingText).toContain(`openclaw pairing approve discord ${code}`);
|
||||
expect(peekSystemEvents(defaultDmSessionKey)).toEqual([]);
|
||||
expect(readAllowFromStoreMock).toHaveBeenCalledWith("discord", "default");
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { Client } from "@buape/carbon";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RuntimeEnv } from "../../../../src/runtime.js";
|
||||
import type { WaitForDiscordGatewayStopParams } from "../monitor.gateway.js";
|
||||
import type { DiscordGatewayEvent } from "./gateway-supervisor.js";
|
||||
|
||||
const {
|
||||
attachDiscordGatewayLoggingMock,
|
||||
@@ -56,7 +55,7 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
start?: () => Promise<void>;
|
||||
stop?: () => Promise<void>;
|
||||
isDisallowedIntentsError?: (err: unknown) => boolean;
|
||||
pendingGatewayEvents?: DiscordGatewayEvent[];
|
||||
pendingGatewayErrors?: unknown[];
|
||||
gateway?: {
|
||||
isConnected?: boolean;
|
||||
options?: Record<string, unknown>;
|
||||
@@ -77,26 +76,7 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
const runtimeLog = vi.fn();
|
||||
const runtimeError = vi.fn();
|
||||
const runtimeExit = vi.fn();
|
||||
const pendingGatewayEvents = params?.pendingGatewayEvents ?? [];
|
||||
const gatewaySupervisor = {
|
||||
attachLifecycle: vi.fn(),
|
||||
detachLifecycle: vi.fn(),
|
||||
drainPending: vi.fn((handler: (event: DiscordGatewayEvent) => "continue" | "stop") => {
|
||||
if (pendingGatewayEvents.length === 0) {
|
||||
return "continue";
|
||||
}
|
||||
const queued = [...pendingGatewayEvents];
|
||||
pendingGatewayEvents.length = 0;
|
||||
for (const event of queued) {
|
||||
if (handler(event) === "stop") {
|
||||
return "stop";
|
||||
}
|
||||
}
|
||||
return "continue";
|
||||
}),
|
||||
dispose: vi.fn(),
|
||||
emitter: params?.gateway?.emitter,
|
||||
};
|
||||
const releaseEarlyGatewayErrorGuard = vi.fn();
|
||||
const statusSink = vi.fn();
|
||||
const runtime: RuntimeEnv = {
|
||||
log: runtimeLog,
|
||||
@@ -109,7 +89,7 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
threadStop,
|
||||
runtimeLog,
|
||||
runtimeError,
|
||||
gatewaySupervisor,
|
||||
releaseEarlyGatewayErrorGuard,
|
||||
statusSink,
|
||||
lifecycleParams: {
|
||||
accountId: params?.accountId ?? "default",
|
||||
@@ -122,7 +102,8 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
voiceManagerRef: { current: null },
|
||||
execApprovalsHandler: { start, stop },
|
||||
threadBindings: { stop: threadStop },
|
||||
gatewaySupervisor,
|
||||
pendingGatewayErrors: params?.pendingGatewayErrors,
|
||||
releaseEarlyGatewayErrorGuard,
|
||||
statusSink,
|
||||
abortSignal: undefined as AbortSignal | undefined,
|
||||
},
|
||||
@@ -134,7 +115,7 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
stop: ReturnType<typeof vi.fn>;
|
||||
threadStop: ReturnType<typeof vi.fn>;
|
||||
waitCalls: number;
|
||||
gatewaySupervisor: { detachLifecycle: ReturnType<typeof vi.fn> };
|
||||
releaseEarlyGatewayErrorGuard: ReturnType<typeof vi.fn>;
|
||||
}) {
|
||||
expect(params.start).toHaveBeenCalledTimes(1);
|
||||
expect(params.stop).toHaveBeenCalledTimes(1);
|
||||
@@ -142,7 +123,7 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
expect(unregisterGatewayMock).toHaveBeenCalledWith("default");
|
||||
expect(stopGatewayLoggingMock).toHaveBeenCalledTimes(1);
|
||||
expect(params.threadStop).toHaveBeenCalledTimes(1);
|
||||
expect(params.gatewaySupervisor.detachLifecycle).toHaveBeenCalledTimes(1);
|
||||
expect(params.releaseEarlyGatewayErrorGuard).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
|
||||
function createGatewayHarness(params?: {
|
||||
@@ -171,26 +152,14 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
await vi.advanceTimersByTimeAsync(delayMs);
|
||||
}
|
||||
|
||||
function createGatewayEvent(
|
||||
type: DiscordGatewayEvent["type"],
|
||||
message: string,
|
||||
): DiscordGatewayEvent {
|
||||
const err = new Error(message);
|
||||
return {
|
||||
type,
|
||||
err,
|
||||
message: String(err),
|
||||
shouldStopLifecycle: type !== "other",
|
||||
};
|
||||
}
|
||||
|
||||
it("cleans up thread bindings when exec approvals startup fails", async () => {
|
||||
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
|
||||
const { lifecycleParams, start, stop, threadStop, gatewaySupervisor } = createLifecycleHarness({
|
||||
start: async () => {
|
||||
throw new Error("startup failed");
|
||||
},
|
||||
});
|
||||
const { lifecycleParams, start, stop, threadStop, releaseEarlyGatewayErrorGuard } =
|
||||
createLifecycleHarness({
|
||||
start: async () => {
|
||||
throw new Error("startup failed");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(runDiscordGatewayLifecycle(lifecycleParams)).rejects.toThrow("startup failed");
|
||||
|
||||
@@ -199,14 +168,14 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
stop,
|
||||
threadStop,
|
||||
waitCalls: 0,
|
||||
gatewaySupervisor,
|
||||
releaseEarlyGatewayErrorGuard,
|
||||
});
|
||||
});
|
||||
|
||||
it("cleans up when gateway wait fails after startup", async () => {
|
||||
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
|
||||
waitForDiscordGatewayStopMock.mockRejectedValueOnce(new Error("gateway wait failed"));
|
||||
const { lifecycleParams, start, stop, threadStop, gatewaySupervisor } =
|
||||
const { lifecycleParams, start, stop, threadStop, releaseEarlyGatewayErrorGuard } =
|
||||
createLifecycleHarness();
|
||||
|
||||
await expect(runDiscordGatewayLifecycle(lifecycleParams)).rejects.toThrow(
|
||||
@@ -218,13 +187,13 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
stop,
|
||||
threadStop,
|
||||
waitCalls: 1,
|
||||
gatewaySupervisor,
|
||||
releaseEarlyGatewayErrorGuard,
|
||||
});
|
||||
});
|
||||
|
||||
it("cleans up after successful gateway wait", async () => {
|
||||
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
|
||||
const { lifecycleParams, start, stop, threadStop, gatewaySupervisor } =
|
||||
const { lifecycleParams, start, stop, threadStop, releaseEarlyGatewayErrorGuard } =
|
||||
createLifecycleHarness();
|
||||
|
||||
await expect(runDiscordGatewayLifecycle(lifecycleParams)).resolves.toBeUndefined();
|
||||
@@ -234,7 +203,7 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
stop,
|
||||
threadStop,
|
||||
waitCalls: 1,
|
||||
gatewaySupervisor,
|
||||
releaseEarlyGatewayErrorGuard,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -295,7 +264,7 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
|
||||
const { emitter, gateway } = createGatewayHarness();
|
||||
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
|
||||
const { lifecycleParams, start, stop, threadStop, gatewaySupervisor } =
|
||||
const { lifecycleParams, start, stop, threadStop, releaseEarlyGatewayErrorGuard } =
|
||||
createLifecycleHarness({ gateway });
|
||||
|
||||
const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams);
|
||||
@@ -313,7 +282,7 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
stop,
|
||||
threadStop,
|
||||
waitCalls: 0,
|
||||
gatewaySupervisor,
|
||||
releaseEarlyGatewayErrorGuard,
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
@@ -322,13 +291,17 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
|
||||
it("handles queued disallowed intents errors without waiting for gateway events", async () => {
|
||||
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
|
||||
const { lifecycleParams, start, stop, threadStop, runtimeError, gatewaySupervisor } =
|
||||
createLifecycleHarness({
|
||||
pendingGatewayEvents: [
|
||||
createGatewayEvent("disallowed-intents", "Fatal Gateway error: 4014"),
|
||||
],
|
||||
isDisallowedIntentsError: (err) => String(err).includes("4014"),
|
||||
});
|
||||
const {
|
||||
lifecycleParams,
|
||||
start,
|
||||
stop,
|
||||
threadStop,
|
||||
runtimeError,
|
||||
releaseEarlyGatewayErrorGuard,
|
||||
} = createLifecycleHarness({
|
||||
pendingGatewayErrors: [new Error("Fatal Gateway error: 4014")],
|
||||
isDisallowedIntentsError: (err) => String(err).includes("4014"),
|
||||
});
|
||||
|
||||
await expect(runDiscordGatewayLifecycle(lifecycleParams)).resolves.toBeUndefined();
|
||||
|
||||
@@ -340,36 +313,16 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
stop,
|
||||
threadStop,
|
||||
waitCalls: 0,
|
||||
gatewaySupervisor,
|
||||
});
|
||||
});
|
||||
|
||||
it("logs queued non-fatal startup gateway errors and continues", async () => {
|
||||
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
|
||||
const { lifecycleParams, start, stop, threadStop, runtimeError, gatewaySupervisor } =
|
||||
createLifecycleHarness({
|
||||
pendingGatewayEvents: [createGatewayEvent("other", "transient startup error")],
|
||||
});
|
||||
|
||||
await expect(runDiscordGatewayLifecycle(lifecycleParams)).resolves.toBeUndefined();
|
||||
|
||||
expect(runtimeError).toHaveBeenCalledWith(
|
||||
expect.stringContaining("discord gateway error: Error: transient startup error"),
|
||||
);
|
||||
expectLifecycleCleanup({
|
||||
start,
|
||||
stop,
|
||||
threadStop,
|
||||
waitCalls: 1,
|
||||
gatewaySupervisor,
|
||||
releaseEarlyGatewayErrorGuard,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws queued non-disallowed fatal gateway errors", async () => {
|
||||
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
|
||||
const { lifecycleParams, start, stop, threadStop, gatewaySupervisor } = createLifecycleHarness({
|
||||
pendingGatewayEvents: [createGatewayEvent("fatal", "Fatal Gateway error: 4000")],
|
||||
});
|
||||
const { lifecycleParams, start, stop, threadStop, releaseEarlyGatewayErrorGuard } =
|
||||
createLifecycleHarness({
|
||||
pendingGatewayErrors: [new Error("Fatal Gateway error: 4000")],
|
||||
});
|
||||
|
||||
await expect(runDiscordGatewayLifecycle(lifecycleParams)).rejects.toThrow(
|
||||
"Fatal Gateway error: 4000",
|
||||
@@ -380,7 +333,7 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
stop,
|
||||
threadStop,
|
||||
waitCalls: 0,
|
||||
gatewaySupervisor,
|
||||
releaseEarlyGatewayErrorGuard,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -388,17 +341,23 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
|
||||
const pendingGatewayEvents: DiscordGatewayEvent[] = [];
|
||||
const pendingGatewayErrors: unknown[] = [];
|
||||
const { emitter, gateway } = createGatewayHarness();
|
||||
getDiscordGatewayEmitterMock.mockReturnValueOnce(emitter);
|
||||
const { lifecycleParams, start, stop, threadStop, runtimeError, gatewaySupervisor } =
|
||||
createLifecycleHarness({
|
||||
gateway,
|
||||
pendingGatewayEvents,
|
||||
});
|
||||
const {
|
||||
lifecycleParams,
|
||||
start,
|
||||
stop,
|
||||
threadStop,
|
||||
runtimeError,
|
||||
releaseEarlyGatewayErrorGuard,
|
||||
} = createLifecycleHarness({
|
||||
gateway,
|
||||
pendingGatewayErrors,
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
pendingGatewayEvents.push(createGatewayEvent("fatal", "Fatal Gateway error: 4001"));
|
||||
pendingGatewayErrors.push(new Error("Fatal Gateway error: 4001"));
|
||||
}, 1_000);
|
||||
|
||||
const lifecyclePromise = runDiscordGatewayLifecycle(lifecycleParams);
|
||||
@@ -416,7 +375,7 @@ describe("runDiscordGatewayLifecycle", () => {
|
||||
stop,
|
||||
threadStop,
|
||||
waitCalls: 0,
|
||||
gatewaySupervisor,
|
||||
releaseEarlyGatewayErrorGuard,
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
|
||||
@@ -8,7 +8,6 @@ import { attachDiscordGatewayLogging } from "../gateway-logging.js";
|
||||
import { getDiscordGatewayEmitter, waitForDiscordGatewayStop } from "../monitor.gateway.js";
|
||||
import type { DiscordVoiceManager } from "../voice/manager.js";
|
||||
import { registerGateway, unregisterGateway } from "./gateway-registry.js";
|
||||
import type { DiscordGatewayEvent, DiscordGatewaySupervisor } from "./gateway-supervisor.js";
|
||||
import type { DiscordMonitorStatusSink } from "./status.js";
|
||||
|
||||
type ExecApprovalsHandler = {
|
||||
@@ -57,7 +56,8 @@ export async function runDiscordGatewayLifecycle(params: {
|
||||
voiceManagerRef: { current: DiscordVoiceManager | null };
|
||||
execApprovalsHandler: ExecApprovalsHandler | null;
|
||||
threadBindings: { stop: () => void };
|
||||
gatewaySupervisor: DiscordGatewaySupervisor;
|
||||
pendingGatewayErrors?: unknown[];
|
||||
releaseEarlyGatewayErrorGuard?: () => void;
|
||||
statusSink?: DiscordMonitorStatusSink;
|
||||
}) {
|
||||
const HELLO_TIMEOUT_MS = 30000;
|
||||
@@ -68,7 +68,7 @@ export async function runDiscordGatewayLifecycle(params: {
|
||||
if (gateway) {
|
||||
registerGateway(params.accountId, gateway);
|
||||
}
|
||||
const gatewayEmitter = params.gatewaySupervisor.emitter ?? getDiscordGatewayEmitter(gateway);
|
||||
const gatewayEmitter = getDiscordGatewayEmitter(gateway);
|
||||
const stopGatewayLogging = attachDiscordGatewayLogging({
|
||||
emitter: gatewayEmitter,
|
||||
runtime: params.runtime,
|
||||
@@ -128,6 +128,7 @@ export async function runDiscordGatewayLifecycle(params: {
|
||||
if (!gateway) {
|
||||
return;
|
||||
}
|
||||
gatewayEmitter?.once("error", () => {});
|
||||
gateway.options.reconnect = { maxAttempts: 0 };
|
||||
gateway.disconnect();
|
||||
};
|
||||
@@ -273,30 +274,45 @@ export async function runDiscordGatewayLifecycle(params: {
|
||||
gatewayEmitter?.on("debug", onGatewayDebug);
|
||||
|
||||
let sawDisallowedIntents = false;
|
||||
const handleGatewayEvent = (event: DiscordGatewayEvent): "continue" | "stop" => {
|
||||
if (event.type === "disallowed-intents") {
|
||||
const logGatewayError = (err: unknown) => {
|
||||
if (params.isDisallowedIntentsError(err)) {
|
||||
sawDisallowedIntents = true;
|
||||
params.runtime.error?.(
|
||||
danger(
|
||||
"discord: gateway closed with code 4014 (missing privileged gateway intents). Enable the required intents in the Discord Developer Portal or disable them in config.",
|
||||
),
|
||||
);
|
||||
return "stop";
|
||||
return;
|
||||
}
|
||||
params.runtime.error?.(danger(`discord gateway error: ${event.message}`));
|
||||
return event.shouldStopLifecycle ? "stop" : "continue";
|
||||
params.runtime.error?.(danger(`discord gateway error: ${String(err)}`));
|
||||
};
|
||||
const drainPendingGatewayErrors = (): "continue" | "stop" =>
|
||||
params.gatewaySupervisor.drainPending((event) => {
|
||||
const decision = handleGatewayEvent(event);
|
||||
if (decision !== "stop") {
|
||||
return "continue";
|
||||
const shouldStopOnGatewayError = (err: unknown) => {
|
||||
const message = String(err);
|
||||
return (
|
||||
message.includes("Max reconnect attempts") ||
|
||||
message.includes("Fatal Gateway error") ||
|
||||
params.isDisallowedIntentsError(err)
|
||||
);
|
||||
};
|
||||
const drainPendingGatewayErrors = (): "continue" | "stop" => {
|
||||
const pendingGatewayErrors = params.pendingGatewayErrors ?? [];
|
||||
if (pendingGatewayErrors.length === 0) {
|
||||
return "continue";
|
||||
}
|
||||
const queuedErrors = [...pendingGatewayErrors];
|
||||
pendingGatewayErrors.length = 0;
|
||||
for (const err of queuedErrors) {
|
||||
logGatewayError(err);
|
||||
if (!shouldStopOnGatewayError(err)) {
|
||||
continue;
|
||||
}
|
||||
if (event.type === "disallowed-intents") {
|
||||
if (params.isDisallowedIntentsError(err)) {
|
||||
return "stop";
|
||||
}
|
||||
throw event.err;
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
return "continue";
|
||||
};
|
||||
try {
|
||||
if (params.execApprovalsHandler) {
|
||||
await params.execApprovalsHandler.start();
|
||||
@@ -379,19 +395,16 @@ export async function runDiscordGatewayLifecycle(params: {
|
||||
});
|
||||
}
|
||||
|
||||
if (drainPendingGatewayErrors() === "stop") {
|
||||
return;
|
||||
}
|
||||
|
||||
await waitForDiscordGatewayStop({
|
||||
gateway: gateway
|
||||
? {
|
||||
emitter: gatewayEmitter,
|
||||
disconnect: () => gateway.disconnect(),
|
||||
}
|
||||
: undefined,
|
||||
abortSignal: params.abortSignal,
|
||||
gatewaySupervisor: params.gatewaySupervisor,
|
||||
onGatewayEvent: handleGatewayEvent,
|
||||
onGatewayError: logGatewayError,
|
||||
shouldStopOnError: shouldStopOnGatewayError,
|
||||
registerForceStop: (forceStop) => {
|
||||
forceStopHandler = forceStop;
|
||||
if (queuedForceStopError !== undefined) {
|
||||
@@ -407,7 +420,7 @@ export async function runDiscordGatewayLifecycle(params: {
|
||||
}
|
||||
} finally {
|
||||
lifecycleStopping = true;
|
||||
params.gatewaySupervisor.detachLifecycle();
|
||||
params.releaseEarlyGatewayErrorGuard?.();
|
||||
unregisterGateway(params.accountId);
|
||||
stopGatewayLogging();
|
||||
reconnectStallWatchdog.stop();
|
||||
|
||||
@@ -354,26 +354,9 @@ describe("monitorDiscordProvider", () => {
|
||||
|
||||
it("captures gateway errors emitted before lifecycle wait starts", async () => {
|
||||
const emitter = new EventEmitter();
|
||||
const drained: Array<{ message: string; type: string }> = [];
|
||||
clientGetPluginMock.mockImplementation((name: string) =>
|
||||
name === "gateway" ? { emitter, disconnect: vi.fn() } : undefined,
|
||||
);
|
||||
monitorLifecycleMock.mockImplementationOnce(async (params) => {
|
||||
(
|
||||
params as {
|
||||
gatewaySupervisor?: {
|
||||
drainPending: (
|
||||
handler: (event: { message: string; type: string }) => "continue" | "stop",
|
||||
) => "continue" | "stop";
|
||||
};
|
||||
threadBindings: { stop: () => void };
|
||||
}
|
||||
).gatewaySupervisor?.drainPending((event) => {
|
||||
drained.push(event);
|
||||
return "continue";
|
||||
});
|
||||
params.threadBindings.stop();
|
||||
});
|
||||
clientFetchUserMock.mockImplementationOnce(async () => {
|
||||
emitter.emit("error", new Error("Fatal Gateway error: 4014"));
|
||||
return { id: "bot-1" };
|
||||
@@ -385,9 +368,11 @@ describe("monitorDiscordProvider", () => {
|
||||
});
|
||||
|
||||
expect(monitorLifecycleMock).toHaveBeenCalledTimes(1);
|
||||
expect(drained).toHaveLength(1);
|
||||
expect(drained[0]?.type).toBe("disallowed-intents");
|
||||
expect(drained[0]?.message).toContain("4014");
|
||||
const lifecycleArgs = monitorLifecycleMock.mock.calls[0]?.[0] as {
|
||||
pendingGatewayErrors?: unknown[];
|
||||
};
|
||||
expect(lifecycleArgs.pendingGatewayErrors).toHaveLength(1);
|
||||
expect(String(lifecycleArgs.pendingGatewayErrors?.[0])).toContain("4014");
|
||||
});
|
||||
|
||||
it("passes default eventQueue.listenerTimeout of 120s to Carbon Client", async () => {
|
||||
|
||||
@@ -45,6 +45,7 @@ import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { createNonExitingRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { summarizeStringEntries } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveDiscordAccount } from "../accounts.js";
|
||||
import { getDiscordGatewayEmitter } from "../monitor.gateway.js";
|
||||
import { fetchDiscordApplicationId } from "../probe.js";
|
||||
import { normalizeDiscordToken } from "../token.js";
|
||||
import { createDiscordVoiceCommand } from "../voice/command.js";
|
||||
@@ -62,8 +63,8 @@ import {
|
||||
import { createDiscordAutoPresenceController } from "./auto-presence.js";
|
||||
import { resolveDiscordSlashCommandConfig } from "./commands.js";
|
||||
import { createExecApprovalButton, DiscordExecApprovalHandler } from "./exec-approvals.js";
|
||||
import { attachEarlyGatewayErrorGuard } from "./gateway-error-guard.js";
|
||||
import { createDiscordGatewayPlugin } from "./gateway-plugin.js";
|
||||
import { createDiscordGatewaySupervisor } from "./gateway-supervisor.js";
|
||||
import {
|
||||
DiscordMessageListener,
|
||||
DiscordPresenceListener,
|
||||
@@ -648,10 +649,10 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
}
|
||||
}
|
||||
let lifecycleStarted = false;
|
||||
let gatewaySupervisor: ReturnType<typeof createDiscordGatewaySupervisor> | undefined;
|
||||
let releaseEarlyGatewayErrorGuard = () => {};
|
||||
let deactivateMessageHandler: (() => void) | undefined;
|
||||
let autoPresenceController: ReturnType<typeof createDiscordAutoPresenceController> | null = null;
|
||||
let earlyGatewayEmitter = gatewaySupervisor?.emitter;
|
||||
let earlyGatewayEmitter: ReturnType<typeof getDiscordGatewayEmitter> | undefined;
|
||||
let onEarlyGatewayDebug: ((msg: unknown) => void) | undefined;
|
||||
try {
|
||||
const commands: BaseCommand[] = commandSpecs.map((spec) =>
|
||||
@@ -797,14 +798,11 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
},
|
||||
clientPlugins,
|
||||
);
|
||||
gatewaySupervisor = createDiscordGatewaySupervisor({
|
||||
client,
|
||||
isDisallowedIntentsError: isDiscordDisallowedIntentsError,
|
||||
runtime,
|
||||
});
|
||||
const earlyGatewayErrorGuard = attachEarlyGatewayErrorGuard(client);
|
||||
releaseEarlyGatewayErrorGuard = earlyGatewayErrorGuard.release;
|
||||
|
||||
const lifecycleGateway = client.getPlugin<GatewayPlugin>("gateway");
|
||||
earlyGatewayEmitter = gatewaySupervisor.emitter;
|
||||
earlyGatewayEmitter = getDiscordGatewayEmitter(lifecycleGateway);
|
||||
onEarlyGatewayDebug = (msg: unknown) => {
|
||||
if (!isVerbose()) {
|
||||
return;
|
||||
@@ -1021,7 +1019,8 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
voiceManagerRef,
|
||||
execApprovalsHandler,
|
||||
threadBindings,
|
||||
gatewaySupervisor,
|
||||
pendingGatewayErrors: earlyGatewayErrorGuard.pendingErrors,
|
||||
releaseEarlyGatewayErrorGuard,
|
||||
});
|
||||
} finally {
|
||||
deactivateMessageHandler?.();
|
||||
@@ -1030,7 +1029,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
if (onEarlyGatewayDebug) {
|
||||
earlyGatewayEmitter?.removeListener("debug", onEarlyGatewayDebug);
|
||||
}
|
||||
gatewaySupervisor?.dispose();
|
||||
releaseEarlyGatewayErrorGuard();
|
||||
if (!lifecycleStarted) {
|
||||
threadBindings.stop();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import * as directoryLive from "./directory-live.js";
|
||||
import {
|
||||
resolveDiscordGroupRequireMention,
|
||||
resolveDiscordGroupToolPolicy,
|
||||
} from "./group-policy.js";
|
||||
import { normalizeDiscordMessagingTarget } from "./normalize.js";
|
||||
import { parseDiscordTarget, resolveDiscordChannelId, resolveDiscordTarget } from "./targets.js";
|
||||
|
||||
@@ -109,77 +105,3 @@ describe("normalizeDiscordMessagingTarget", () => {
|
||||
expect(normalizeDiscordMessagingTarget("123")).toBe("channel:123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("discord group policy", () => {
|
||||
it("prefers channel policy, then guild policy, with sender-specific overrides", () => {
|
||||
const discordCfg = {
|
||||
channels: {
|
||||
discord: {
|
||||
token: "discord-test",
|
||||
guilds: {
|
||||
guild1: {
|
||||
requireMention: false,
|
||||
tools: { allow: ["message.guild"] },
|
||||
toolsBySender: {
|
||||
"id:user:guild-admin": { allow: ["sessions.list"] },
|
||||
},
|
||||
channels: {
|
||||
"123": {
|
||||
requireMention: true,
|
||||
tools: { allow: ["message.channel"] },
|
||||
toolsBySender: {
|
||||
"id:user:channel-admin": { deny: ["exec"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
} as any;
|
||||
|
||||
expect(
|
||||
resolveDiscordGroupRequireMention({ cfg: discordCfg, groupSpace: "guild1", groupId: "123" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolveDiscordGroupRequireMention({
|
||||
cfg: discordCfg,
|
||||
groupSpace: "guild1",
|
||||
groupId: "missing",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
resolveDiscordGroupToolPolicy({
|
||||
cfg: discordCfg,
|
||||
groupSpace: "guild1",
|
||||
groupId: "123",
|
||||
senderId: "user:channel-admin",
|
||||
}),
|
||||
).toEqual({ deny: ["exec"] });
|
||||
expect(
|
||||
resolveDiscordGroupToolPolicy({
|
||||
cfg: discordCfg,
|
||||
groupSpace: "guild1",
|
||||
groupId: "123",
|
||||
senderId: "user:someone",
|
||||
}),
|
||||
).toEqual({ allow: ["message.channel"] });
|
||||
expect(
|
||||
resolveDiscordGroupToolPolicy({
|
||||
cfg: discordCfg,
|
||||
groupSpace: "guild1",
|
||||
groupId: "missing",
|
||||
senderId: "user:guild-admin",
|
||||
}),
|
||||
).toEqual({ allow: ["sessions.list"] });
|
||||
expect(
|
||||
resolveDiscordGroupToolPolicy({
|
||||
cfg: discordCfg,
|
||||
groupSpace: "guild1",
|
||||
groupId: "missing",
|
||||
senderId: "user:someone",
|
||||
}),
|
||||
).toEqual({ allow: ["message.guild"] });
|
||||
});
|
||||
});
|
||||
|
||||
75
extensions/feishu/index.test.ts
Normal file
75
extensions/feishu/index.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawPluginApi } from "./runtime-api.js";
|
||||
|
||||
const registerFeishuDocToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuChatToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuWikiToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuDriveToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuPermToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuBitableToolsMock = vi.hoisted(() => vi.fn());
|
||||
const feishuPluginMock = vi.hoisted(() => ({ id: "feishu-test-plugin" }));
|
||||
const setFeishuRuntimeMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuSubagentHooksMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./src/channel.js", () => ({
|
||||
feishuPlugin: feishuPluginMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/docx.js", () => ({
|
||||
registerFeishuDocTools: registerFeishuDocToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/chat.js", () => ({
|
||||
registerFeishuChatTools: registerFeishuChatToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/wiki.js", () => ({
|
||||
registerFeishuWikiTools: registerFeishuWikiToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/drive.js", () => ({
|
||||
registerFeishuDriveTools: registerFeishuDriveToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/perm.js", () => ({
|
||||
registerFeishuPermTools: registerFeishuPermToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/bitable.js", () => ({
|
||||
registerFeishuBitableTools: registerFeishuBitableToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/runtime.js", () => ({
|
||||
setFeishuRuntime: setFeishuRuntimeMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/subagent-hooks.js", () => ({
|
||||
registerFeishuSubagentHooks: registerFeishuSubagentHooksMock,
|
||||
}));
|
||||
|
||||
describe("feishu plugin register", () => {
|
||||
it("registers the Feishu channel, tools, and subagent hooks", async () => {
|
||||
const { default: plugin } = await import("./index.js");
|
||||
const registerChannel = vi.fn();
|
||||
const api = {
|
||||
runtime: { log: vi.fn() },
|
||||
registerChannel,
|
||||
on: vi.fn(),
|
||||
config: {},
|
||||
registrationMode: "full",
|
||||
} as unknown as OpenClawPluginApi;
|
||||
|
||||
plugin.register(api);
|
||||
|
||||
expect(setFeishuRuntimeMock).toHaveBeenCalledWith(api.runtime);
|
||||
expect(registerChannel).toHaveBeenCalledTimes(1);
|
||||
expect(registerChannel).toHaveBeenCalledWith({ plugin: feishuPluginMock });
|
||||
expect(registerFeishuSubagentHooksMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuDocToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuChatToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuWikiToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuDriveToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuPermToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuBitableToolsMock).toHaveBeenCalledWith(api);
|
||||
});
|
||||
});
|
||||
@@ -815,14 +815,7 @@ describe("handleFeishuMessage command authorization", () => {
|
||||
expect(mockSendMessageFeishu).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "chat:oc-dm",
|
||||
text: expect.stringContaining("Pairing code:"),
|
||||
accountId: "default",
|
||||
}),
|
||||
);
|
||||
expect(mockSendMessageFeishu).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "chat:oc-dm",
|
||||
text: expect.stringContaining("ABCDEFGH"),
|
||||
text: expect.stringContaining("Pairing code: ABCDEFGH"),
|
||||
accountId: "default",
|
||||
}),
|
||||
);
|
||||
@@ -1081,103 +1074,6 @@ describe("handleFeishuMessage command authorization", () => {
|
||||
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dispatches group image message when groupPolicy is open (requireMention defaults to false)", async () => {
|
||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
groupPolicy: "open",
|
||||
// requireMention is NOT set — should default to false for open policy
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const event: FeishuMessageEvent = {
|
||||
sender: {
|
||||
sender_id: { open_id: "ou-sender" },
|
||||
},
|
||||
message: {
|
||||
message_id: "msg-group-image-open",
|
||||
chat_id: "oc-group-open",
|
||||
chat_type: "group",
|
||||
message_type: "image",
|
||||
content: JSON.stringify({ image_key: "img_v3_test" }),
|
||||
},
|
||||
};
|
||||
|
||||
await dispatchMessage({ cfg, event });
|
||||
|
||||
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("drops group image message when groupPolicy is open but requireMention is explicitly true", async () => {
|
||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
groupPolicy: "open",
|
||||
requireMention: true, // explicit override — user opts into mention-required even for open
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const event: FeishuMessageEvent = {
|
||||
sender: {
|
||||
sender_id: { open_id: "ou-sender" },
|
||||
},
|
||||
message: {
|
||||
message_id: "msg-group-image-open-explicit-mention",
|
||||
chat_id: "oc-group-open",
|
||||
chat_type: "group",
|
||||
message_type: "image",
|
||||
content: JSON.stringify({ image_key: "img_v3_test" }),
|
||||
},
|
||||
};
|
||||
|
||||
await dispatchMessage({ cfg, event });
|
||||
|
||||
expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
|
||||
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops group image message when groupPolicy is allowlist and requireMention is not set (defaults to true)", async () => {
|
||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
groupPolicy: "allowlist",
|
||||
// requireMention not set — for non-open policy defaults to true
|
||||
groups: {
|
||||
"oc-allowlist-group": {
|
||||
allow: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const event: FeishuMessageEvent = {
|
||||
sender: {
|
||||
sender_id: { open_id: "ou-sender" },
|
||||
},
|
||||
message: {
|
||||
message_id: "msg-group-image-allowlist",
|
||||
chat_id: "oc-allowlist-group",
|
||||
chat_type: "group",
|
||||
message_type: "image",
|
||||
content: JSON.stringify({ image_key: "img_v3_test" }),
|
||||
},
|
||||
};
|
||||
|
||||
await dispatchMessage({ cfg, event });
|
||||
|
||||
expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
|
||||
expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops message when groupConfig.enabled is false", async () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
|
||||
@@ -414,10 +414,8 @@ export async function handleFeishuMessage(params: {
|
||||
|
||||
({ requireMention } = resolveFeishuReplyPolicy({
|
||||
isDirectMessage: false,
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
groupId: ctx.chatId,
|
||||
groupPolicy,
|
||||
globalConfig: feishuCfg,
|
||||
groupConfig,
|
||||
}));
|
||||
|
||||
if (requireMention && !ctx.mentionedBot) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import { looksLikeFeishuId, normalizeFeishuTarget, resolveReceiveIdType } from "./targets.js";
|
||||
|
||||
const probeFeishuMock = vi.hoisted(() => vi.fn());
|
||||
const createFeishuClientMock = vi.hoisted(() => vi.fn());
|
||||
@@ -616,71 +615,3 @@ describe("feishuPlugin actions", () => {
|
||||
).rejects.toThrow('Unsupported Feishu action: "search"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveReceiveIdType", () => {
|
||||
it("resolves chat IDs by oc_ prefix", () => {
|
||||
expect(resolveReceiveIdType("oc_123")).toBe("chat_id");
|
||||
});
|
||||
|
||||
it("resolves open IDs by ou_ prefix", () => {
|
||||
expect(resolveReceiveIdType("ou_123")).toBe("open_id");
|
||||
});
|
||||
|
||||
it("defaults unprefixed IDs to user_id", () => {
|
||||
expect(resolveReceiveIdType("u_123")).toBe("user_id");
|
||||
});
|
||||
|
||||
it("treats explicit group targets as chat_id", () => {
|
||||
expect(resolveReceiveIdType("group:oc_123")).toBe("chat_id");
|
||||
});
|
||||
|
||||
it("treats explicit channel targets as chat_id", () => {
|
||||
expect(resolveReceiveIdType("channel:oc_123")).toBe("chat_id");
|
||||
});
|
||||
|
||||
it("treats dm-prefixed open IDs as open_id", () => {
|
||||
expect(resolveReceiveIdType("dm:ou_123")).toBe("open_id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeFeishuTarget", () => {
|
||||
it("strips provider and user prefixes", () => {
|
||||
expect(normalizeFeishuTarget("feishu:user:ou_123")).toBe("ou_123");
|
||||
expect(normalizeFeishuTarget("lark:user:ou_123")).toBe("ou_123");
|
||||
});
|
||||
|
||||
it("strips provider and chat prefixes", () => {
|
||||
expect(normalizeFeishuTarget("feishu:chat:oc_123")).toBe("oc_123");
|
||||
});
|
||||
|
||||
it("normalizes group/channel prefixes to chat ids", () => {
|
||||
expect(normalizeFeishuTarget("group:oc_123")).toBe("oc_123");
|
||||
expect(normalizeFeishuTarget("feishu:group:oc_123")).toBe("oc_123");
|
||||
expect(normalizeFeishuTarget("channel:oc_456")).toBe("oc_456");
|
||||
expect(normalizeFeishuTarget("lark:channel:oc_456")).toBe("oc_456");
|
||||
});
|
||||
|
||||
it("accepts provider-prefixed raw ids", () => {
|
||||
expect(normalizeFeishuTarget("feishu:ou_123")).toBe("ou_123");
|
||||
});
|
||||
|
||||
it("strips provider and dm prefixes", () => {
|
||||
expect(normalizeFeishuTarget("lark:dm:ou_123")).toBe("ou_123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("looksLikeFeishuId", () => {
|
||||
it("accepts provider-prefixed user targets", () => {
|
||||
expect(looksLikeFeishuId("feishu:user:ou_123")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts provider-prefixed chat targets", () => {
|
||||
expect(looksLikeFeishuId("lark:chat:oc_123")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts group/channel targets", () => {
|
||||
expect(looksLikeFeishuId("feishu:group:oc_123")).toBe(true);
|
||||
expect(looksLikeFeishuId("group:oc_123")).toBe(true);
|
||||
expect(looksLikeFeishuId("channel:oc_456")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawPluginApi } from "../runtime-api.js";
|
||||
import type { FeishuConfig, ResolvedFeishuAccount } from "./types.js";
|
||||
|
||||
type CreateFeishuClient = typeof import("./client.js").createFeishuClient;
|
||||
@@ -34,15 +33,6 @@ const mockBaseHttpInstance = vi.hoisted(() => ({
|
||||
}));
|
||||
const proxyEnvKeys = ["https_proxy", "HTTPS_PROXY", "http_proxy", "HTTP_PROXY"] as const;
|
||||
type ProxyEnvKey = (typeof proxyEnvKeys)[number];
|
||||
const registerFeishuDocToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuChatToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuWikiToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuDriveToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuPermToolsMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuBitableToolsMock = vi.hoisted(() => vi.fn());
|
||||
const feishuPluginMock = vi.hoisted(() => ({ id: "feishu-test-plugin" }));
|
||||
const setFeishuRuntimeMock = vi.hoisted(() => vi.fn());
|
||||
const registerFeishuSubagentHooksMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
let createFeishuClient: CreateFeishuClient;
|
||||
let createFeishuWSClient: CreateFeishuWSClient;
|
||||
@@ -55,42 +45,6 @@ let FEISHU_HTTP_TIMEOUT_ENV_VAR: string;
|
||||
let priorProxyEnv: Partial<Record<ProxyEnvKey, string | undefined>> = {};
|
||||
let priorFeishuTimeoutEnv: string | undefined;
|
||||
|
||||
vi.mock("./channel.js", () => ({
|
||||
feishuPlugin: feishuPluginMock,
|
||||
}));
|
||||
|
||||
vi.mock("./docx.js", () => ({
|
||||
registerFeishuDocTools: registerFeishuDocToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./chat.js", () => ({
|
||||
registerFeishuChatTools: registerFeishuChatToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./wiki.js", () => ({
|
||||
registerFeishuWikiTools: registerFeishuWikiToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./drive.js", () => ({
|
||||
registerFeishuDriveTools: registerFeishuDriveToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./perm.js", () => ({
|
||||
registerFeishuPermTools: registerFeishuPermToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./bitable.js", () => ({
|
||||
registerFeishuBitableTools: registerFeishuBitableToolsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
setFeishuRuntime: setFeishuRuntimeMock,
|
||||
}));
|
||||
|
||||
vi.mock("./subagent-hooks.js", () => ({
|
||||
registerFeishuSubagentHooks: registerFeishuSubagentHooksMock,
|
||||
}));
|
||||
|
||||
const baseAccount: ResolvedFeishuAccount = {
|
||||
accountId: "main",
|
||||
selectionSource: "explicit",
|
||||
@@ -336,33 +290,6 @@ describe("createFeishuClient HTTP timeout", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("feishu plugin register", () => {
|
||||
it("registers the Feishu channel, tools, and subagent hooks", async () => {
|
||||
const { default: plugin } = await import("../index.js");
|
||||
const registerChannel = vi.fn();
|
||||
const api = {
|
||||
runtime: { log: vi.fn() },
|
||||
registerChannel,
|
||||
on: vi.fn(),
|
||||
config: {},
|
||||
registrationMode: "full",
|
||||
} as unknown as OpenClawPluginApi;
|
||||
|
||||
plugin.register(api);
|
||||
|
||||
expect(setFeishuRuntimeMock).toHaveBeenCalledWith(api.runtime);
|
||||
expect(registerChannel).toHaveBeenCalledTimes(1);
|
||||
expect(registerChannel).toHaveBeenCalledWith({ plugin: feishuPluginMock });
|
||||
expect(registerFeishuSubagentHooksMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuDocToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuChatToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuWikiToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuDriveToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuPermToolsMock).toHaveBeenCalledWith(api);
|
||||
expect(registerFeishuBitableToolsMock).toHaveBeenCalledWith(api);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFeishuWSClient proxy handling", () => {
|
||||
it("does not set a ws proxy agent when proxy env is absent", () => {
|
||||
createFeishuWSClient(baseAccount);
|
||||
|
||||
@@ -19,10 +19,7 @@ describe("FeishuConfigSchema webhook validation", () => {
|
||||
expect(result.webhookPath).toBe("/feishu/events");
|
||||
expect(result.dmPolicy).toBe("pairing");
|
||||
expect(result.groupPolicy).toBe("allowlist");
|
||||
// requireMention has no schema-level default now — it is resolved at runtime
|
||||
// through shared channel group-policy resolution, with an open-group override
|
||||
// that defaults to false only when requireMention is otherwise unset.
|
||||
expect(result.requireMention).toBeUndefined();
|
||||
expect(result.requireMention).toBe(true);
|
||||
});
|
||||
|
||||
it("does not force top-level policy defaults into account config", () => {
|
||||
|
||||
@@ -221,7 +221,7 @@ export const FeishuConfigSchema = z
|
||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||
reactionNotifications: ReactionNotificationModeSchema.optional().default("own"),
|
||||
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||
requireMention: z.boolean().optional(),
|
||||
requireMention: z.boolean().optional().default(true),
|
||||
groupSessionScope: GroupSessionScopeSchema,
|
||||
topicSessionMode: TopicSessionModeSchema,
|
||||
// Dynamic agent creation for DM users
|
||||
|
||||
@@ -1,232 +1,154 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isFeishuGroupAllowed,
|
||||
resolveFeishuAllowlistMatch,
|
||||
resolveFeishuGroupConfig,
|
||||
resolveFeishuReplyPolicy,
|
||||
} from "./policy.js";
|
||||
import type { FeishuConfig } from "./types.js";
|
||||
|
||||
function createCfg(feishu: Record<string, unknown>): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
feishu,
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
describe("feishu policy", () => {
|
||||
describe("resolveFeishuGroupConfig", () => {
|
||||
it("falls back to wildcard group config when direct match is missing", () => {
|
||||
const cfg = {
|
||||
groups: {
|
||||
"*": { requireMention: false },
|
||||
"oc-explicit": { requireMention: true },
|
||||
},
|
||||
} as unknown as FeishuConfig;
|
||||
|
||||
describe("resolveFeishuReplyPolicy", () => {
|
||||
it("defaults open groups to no mention when unset", () => {
|
||||
expect(
|
||||
resolveFeishuReplyPolicy({
|
||||
isDirectMessage: false,
|
||||
cfg: createCfg({ groupPolicy: "open" }),
|
||||
groupPolicy: "open",
|
||||
groupId: "oc_1",
|
||||
}),
|
||||
).toEqual({ requireMention: false });
|
||||
const resolved = resolveFeishuGroupConfig({
|
||||
cfg,
|
||||
groupId: "oc-missing",
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({ requireMention: false });
|
||||
});
|
||||
|
||||
it("prefers exact group config over wildcard", () => {
|
||||
const cfg = {
|
||||
groups: {
|
||||
"*": { requireMention: false },
|
||||
"oc-explicit": { requireMention: true },
|
||||
},
|
||||
} as unknown as FeishuConfig;
|
||||
|
||||
const resolved = resolveFeishuGroupConfig({
|
||||
cfg,
|
||||
groupId: "oc-explicit",
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({ requireMention: true });
|
||||
});
|
||||
|
||||
it("keeps case-insensitive matching for explicit group ids", () => {
|
||||
const cfg = {
|
||||
groups: {
|
||||
"*": { requireMention: false },
|
||||
OC_UPPER: { requireMention: true },
|
||||
},
|
||||
} as unknown as FeishuConfig;
|
||||
|
||||
const resolved = resolveFeishuGroupConfig({
|
||||
cfg,
|
||||
groupId: "oc_upper",
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({ requireMention: true });
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps explicit top-level mention gating in open groups", () => {
|
||||
expect(
|
||||
resolveFeishuReplyPolicy({
|
||||
isDirectMessage: false,
|
||||
cfg: createCfg({ groupPolicy: "open", requireMention: true }),
|
||||
groupPolicy: "open",
|
||||
groupId: "oc_1",
|
||||
}),
|
||||
).toEqual({ requireMention: true });
|
||||
describe("resolveFeishuAllowlistMatch", () => {
|
||||
it("allows wildcard", () => {
|
||||
expect(
|
||||
resolveFeishuAllowlistMatch({
|
||||
allowFrom: ["*"],
|
||||
senderId: "ou-attacker",
|
||||
}),
|
||||
).toEqual({ allowed: true, matchKey: "*", matchSource: "wildcard" });
|
||||
});
|
||||
|
||||
it("matches normalized ID entries", () => {
|
||||
expect(
|
||||
resolveFeishuAllowlistMatch({
|
||||
allowFrom: ["feishu:user:OU_ALLOWED"],
|
||||
senderId: "ou_allowed",
|
||||
}),
|
||||
).toEqual({ allowed: true, matchKey: "ou_allowed", matchSource: "id" });
|
||||
});
|
||||
|
||||
it("supports user_id as an additional immutable sender candidate", () => {
|
||||
expect(
|
||||
resolveFeishuAllowlistMatch({
|
||||
allowFrom: ["on_user_123"],
|
||||
senderId: "ou_other",
|
||||
senderIds: ["on_user_123"],
|
||||
}),
|
||||
).toEqual({ allowed: true, matchKey: "on_user_123", matchSource: "id" });
|
||||
});
|
||||
|
||||
it("does not authorize based on display-name collision", () => {
|
||||
const victimOpenId = "ou_4f4ec5aa111122223333444455556666";
|
||||
|
||||
expect(
|
||||
resolveFeishuAllowlistMatch({
|
||||
allowFrom: [victimOpenId],
|
||||
senderId: "ou_attacker_real_open_id",
|
||||
senderIds: ["on_attacker_user_id"],
|
||||
senderName: victimOpenId,
|
||||
}),
|
||||
).toEqual({ allowed: false });
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps explicit account mention gating in open groups", () => {
|
||||
expect(
|
||||
resolveFeishuReplyPolicy({
|
||||
isDirectMessage: false,
|
||||
cfg: createCfg({
|
||||
describe("isFeishuGroupAllowed", () => {
|
||||
it("matches group IDs with chat: prefix", () => {
|
||||
expect(
|
||||
isFeishuGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
requireMention: false,
|
||||
accounts: {
|
||||
work: {
|
||||
groupPolicy: "open",
|
||||
requireMention: true,
|
||||
},
|
||||
},
|
||||
allowFrom: ["chat:oc_group_123"],
|
||||
senderId: "oc_group_123",
|
||||
}),
|
||||
accountId: "work",
|
||||
groupPolicy: "open",
|
||||
groupId: "oc_1",
|
||||
}),
|
||||
).toEqual({ requireMention: true });
|
||||
});
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps explicit per-group mention gating in open groups", () => {
|
||||
expect(
|
||||
resolveFeishuReplyPolicy({
|
||||
isDirectMessage: false,
|
||||
cfg: createCfg({
|
||||
it("allows group when groupPolicy is 'open'", () => {
|
||||
expect(
|
||||
isFeishuGroupAllowed({
|
||||
groupPolicy: "open",
|
||||
groups: { oc_1: { requireMention: true } },
|
||||
allowFrom: [],
|
||||
senderId: "oc_group_999",
|
||||
}),
|
||||
groupPolicy: "open",
|
||||
groupId: "oc_1",
|
||||
}),
|
||||
).toEqual({ requireMention: true });
|
||||
});
|
||||
|
||||
it("defaults allowlist groups to require mentions", () => {
|
||||
expect(
|
||||
resolveFeishuReplyPolicy({
|
||||
isDirectMessage: false,
|
||||
cfg: createCfg({ groupPolicy: "allowlist" }),
|
||||
groupPolicy: "allowlist",
|
||||
groupId: "oc_1",
|
||||
}),
|
||||
).toEqual({ requireMention: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveFeishuGroupConfig", () => {
|
||||
it("falls back to wildcard group config when direct match is missing", () => {
|
||||
const cfg = {
|
||||
groups: {
|
||||
"*": { requireMention: false },
|
||||
"oc-explicit": { requireMention: true },
|
||||
},
|
||||
} as unknown as FeishuConfig;
|
||||
|
||||
const resolved = resolveFeishuGroupConfig({
|
||||
cfg,
|
||||
groupId: "oc-missing",
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({ requireMention: false });
|
||||
});
|
||||
|
||||
it("prefers exact group config over wildcard", () => {
|
||||
const cfg = {
|
||||
groups: {
|
||||
"*": { requireMention: false },
|
||||
"oc-explicit": { requireMention: true },
|
||||
},
|
||||
} as unknown as FeishuConfig;
|
||||
|
||||
const resolved = resolveFeishuGroupConfig({
|
||||
cfg,
|
||||
groupId: "oc-explicit",
|
||||
it("treats 'allowall' as equivalent to 'open'", () => {
|
||||
expect(
|
||||
isFeishuGroupAllowed({
|
||||
groupPolicy: "allowall",
|
||||
allowFrom: [],
|
||||
senderId: "oc_group_999",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({ requireMention: true });
|
||||
});
|
||||
|
||||
it("keeps case-insensitive matching for explicit group ids", () => {
|
||||
const cfg = {
|
||||
groups: {
|
||||
"*": { requireMention: false },
|
||||
OC_UPPER: { requireMention: true },
|
||||
},
|
||||
} as unknown as FeishuConfig;
|
||||
|
||||
const resolved = resolveFeishuGroupConfig({
|
||||
cfg,
|
||||
groupId: "oc_upper",
|
||||
it("rejects group when groupPolicy is 'disabled'", () => {
|
||||
expect(
|
||||
isFeishuGroupAllowed({
|
||||
groupPolicy: "disabled",
|
||||
allowFrom: ["oc_group_999"],
|
||||
senderId: "oc_group_999",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({ requireMention: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveFeishuAllowlistMatch", () => {
|
||||
it("allows wildcard", () => {
|
||||
expect(
|
||||
resolveFeishuAllowlistMatch({
|
||||
allowFrom: ["*"],
|
||||
senderId: "ou-attacker",
|
||||
}),
|
||||
).toEqual({ allowed: true, matchKey: "*", matchSource: "wildcard" });
|
||||
});
|
||||
|
||||
it("matches normalized ID entries", () => {
|
||||
expect(
|
||||
resolveFeishuAllowlistMatch({
|
||||
allowFrom: ["feishu:user:OU_ALLOWED"],
|
||||
senderId: "ou_allowed",
|
||||
}),
|
||||
).toEqual({ allowed: true, matchKey: "ou_allowed", matchSource: "id" });
|
||||
});
|
||||
|
||||
it("supports user_id as an additional immutable sender candidate", () => {
|
||||
expect(
|
||||
resolveFeishuAllowlistMatch({
|
||||
allowFrom: ["on_user_123"],
|
||||
senderId: "ou_other",
|
||||
senderIds: ["on_user_123"],
|
||||
}),
|
||||
).toEqual({ allowed: true, matchKey: "on_user_123", matchSource: "id" });
|
||||
});
|
||||
|
||||
it("does not authorize based on display-name collision", () => {
|
||||
const victimOpenId = "ou_4f4ec5aa111122223333444455556666";
|
||||
|
||||
expect(
|
||||
resolveFeishuAllowlistMatch({
|
||||
allowFrom: [victimOpenId],
|
||||
senderId: "ou_attacker_real_open_id",
|
||||
senderIds: ["on_attacker_user_id"],
|
||||
senderName: victimOpenId,
|
||||
}),
|
||||
).toEqual({ allowed: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("isFeishuGroupAllowed", () => {
|
||||
it("matches group IDs with chat: prefix", () => {
|
||||
expect(
|
||||
isFeishuGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["chat:oc_group_123"],
|
||||
senderId: "oc_group_123",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows group when groupPolicy is 'open'", () => {
|
||||
expect(
|
||||
isFeishuGroupAllowed({
|
||||
groupPolicy: "open",
|
||||
allowFrom: [],
|
||||
senderId: "oc_group_999",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("treats 'allowall' as equivalent to 'open'", () => {
|
||||
expect(
|
||||
isFeishuGroupAllowed({
|
||||
groupPolicy: "allowall",
|
||||
allowFrom: [],
|
||||
senderId: "oc_group_999",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects group when groupPolicy is 'disabled'", () => {
|
||||
expect(
|
||||
isFeishuGroupAllowed({
|
||||
groupPolicy: "disabled",
|
||||
allowFrom: ["oc_group_999"],
|
||||
senderId: "oc_group_999",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects group when groupPolicy is 'allowlist' and allowFrom is empty", () => {
|
||||
expect(
|
||||
isFeishuGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: [],
|
||||
senderId: "oc_group_999",
|
||||
}),
|
||||
).toBe(false);
|
||||
it("rejects group when groupPolicy is 'allowlist' and allowFrom is empty", () => {
|
||||
expect(
|
||||
isFeishuGroupAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: [],
|
||||
senderId: "oc_group_999",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
import {
|
||||
normalizeAccountId,
|
||||
resolveMergedAccountConfig,
|
||||
} from "openclaw/plugin-sdk/account-resolution";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
||||
import type { AllowlistMatch, ChannelGroupContext, GroupToolPolicyConfig } from "../runtime-api.js";
|
||||
import { evaluateSenderGroupAccessForPolicy } from "../runtime-api.js";
|
||||
import { normalizeFeishuTarget } from "./targets.js";
|
||||
@@ -110,41 +105,15 @@ export function isFeishuGroupAllowed(params: {
|
||||
|
||||
export function resolveFeishuReplyPolicy(params: {
|
||||
isDirectMessage: boolean;
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
groupId?: string | null;
|
||||
/**
|
||||
* Effective group policy resolved for this chat. When "open", requireMention
|
||||
* defaults to false so that non-text messages (e.g. images) that cannot carry
|
||||
* @-mentions are still delivered to the agent.
|
||||
*/
|
||||
groupPolicy?: "open" | "allowlist" | "disabled" | "allowall";
|
||||
globalConfig?: FeishuConfig;
|
||||
groupConfig?: FeishuGroupConfig;
|
||||
}): { requireMention: boolean } {
|
||||
if (params.isDirectMessage) {
|
||||
return { requireMention: false };
|
||||
}
|
||||
|
||||
const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
const resolvedCfg = resolveMergedAccountConfig<FeishuConfig>({
|
||||
channelConfig: feishuCfg,
|
||||
accounts: feishuCfg?.accounts as Record<string, Partial<FeishuConfig>> | undefined,
|
||||
accountId: normalizeAccountId(params.accountId),
|
||||
normalizeAccountId,
|
||||
omitKeys: ["defaultAccount"],
|
||||
});
|
||||
const groupRequireMention = resolveFeishuGroupConfig({
|
||||
cfg: resolvedCfg,
|
||||
groupId: params.groupId,
|
||||
})?.requireMention;
|
||||
const requireMention =
|
||||
params.groupConfig?.requireMention ?? params.globalConfig?.requireMention ?? true;
|
||||
|
||||
return {
|
||||
requireMention:
|
||||
typeof groupRequireMention === "boolean"
|
||||
? groupRequireMention
|
||||
: typeof resolvedCfg.requireMention === "boolean"
|
||||
? resolvedCfg.requireMention
|
||||
: params.groupPolicy === "open"
|
||||
? false
|
||||
: true,
|
||||
};
|
||||
return { requireMention };
|
||||
}
|
||||
|
||||
28
extensions/feishu/src/setup-status.test.ts
Normal file
28
extensions/feishu/src/setup-status.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createPluginSetupWizardStatus } from "../../../test/helpers/extensions/setup-wizard.js";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import { feishuPlugin } from "./channel.js";
|
||||
|
||||
const feishuGetStatus = createPluginSetupWizardStatus(feishuPlugin);
|
||||
|
||||
describe("feishu setup wizard status", () => {
|
||||
it("treats SecretRef appSecret as configured when appId is present", async () => {
|
||||
const status = await feishuGetStatus({
|
||||
cfg: {
|
||||
channels: {
|
||||
feishu: {
|
||||
appId: "cli_a123456",
|
||||
appSecret: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "FEISHU_APP_SECRET",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
accountOverrides: {},
|
||||
});
|
||||
|
||||
expect(status.configured).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -93,26 +93,6 @@ describe("feishu setup wizard", () => {
|
||||
});
|
||||
|
||||
describe("feishu setup wizard status", () => {
|
||||
it("treats SecretRef appSecret as configured when appId is present", async () => {
|
||||
const status = await feishuGetStatus({
|
||||
cfg: {
|
||||
channels: {
|
||||
feishu: {
|
||||
appId: "cli_a123456",
|
||||
appSecret: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "FEISHU_APP_SECRET",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
accountOverrides: {},
|
||||
});
|
||||
|
||||
expect(status.configured).toBe(true);
|
||||
});
|
||||
|
||||
it("does not fallback to top-level appId when account explicitly sets empty appId", async () => {
|
||||
const status = await feishuGetStatus({
|
||||
cfg: {
|
||||
|
||||
70
extensions/feishu/src/targets.test.ts
Normal file
70
extensions/feishu/src/targets.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { looksLikeFeishuId, normalizeFeishuTarget, resolveReceiveIdType } from "./targets.js";
|
||||
|
||||
describe("resolveReceiveIdType", () => {
|
||||
it("resolves chat IDs by oc_ prefix", () => {
|
||||
expect(resolveReceiveIdType("oc_123")).toBe("chat_id");
|
||||
});
|
||||
|
||||
it("resolves open IDs by ou_ prefix", () => {
|
||||
expect(resolveReceiveIdType("ou_123")).toBe("open_id");
|
||||
});
|
||||
|
||||
it("defaults unprefixed IDs to user_id", () => {
|
||||
expect(resolveReceiveIdType("u_123")).toBe("user_id");
|
||||
});
|
||||
|
||||
it("treats explicit group targets as chat_id", () => {
|
||||
expect(resolveReceiveIdType("group:oc_123")).toBe("chat_id");
|
||||
});
|
||||
|
||||
it("treats explicit channel targets as chat_id", () => {
|
||||
expect(resolveReceiveIdType("channel:oc_123")).toBe("chat_id");
|
||||
});
|
||||
|
||||
it("treats dm-prefixed open IDs as open_id", () => {
|
||||
expect(resolveReceiveIdType("dm:ou_123")).toBe("open_id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeFeishuTarget", () => {
|
||||
it("strips provider and user prefixes", () => {
|
||||
expect(normalizeFeishuTarget("feishu:user:ou_123")).toBe("ou_123");
|
||||
expect(normalizeFeishuTarget("lark:user:ou_123")).toBe("ou_123");
|
||||
});
|
||||
|
||||
it("strips provider and chat prefixes", () => {
|
||||
expect(normalizeFeishuTarget("feishu:chat:oc_123")).toBe("oc_123");
|
||||
});
|
||||
|
||||
it("normalizes group/channel prefixes to chat ids", () => {
|
||||
expect(normalizeFeishuTarget("group:oc_123")).toBe("oc_123");
|
||||
expect(normalizeFeishuTarget("feishu:group:oc_123")).toBe("oc_123");
|
||||
expect(normalizeFeishuTarget("channel:oc_456")).toBe("oc_456");
|
||||
expect(normalizeFeishuTarget("lark:channel:oc_456")).toBe("oc_456");
|
||||
});
|
||||
|
||||
it("accepts provider-prefixed raw ids", () => {
|
||||
expect(normalizeFeishuTarget("feishu:ou_123")).toBe("ou_123");
|
||||
});
|
||||
|
||||
it("strips provider and dm prefixes", () => {
|
||||
expect(normalizeFeishuTarget("lark:dm:ou_123")).toBe("ou_123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("looksLikeFeishuId", () => {
|
||||
it("accepts provider-prefixed user targets", () => {
|
||||
expect(looksLikeFeishuId("feishu:user:ou_123")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts provider-prefixed chat targets", () => {
|
||||
expect(looksLikeFeishuId("lark:chat:oc_123")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts group/channel targets", () => {
|
||||
expect(looksLikeFeishuId("feishu:group:oc_123")).toBe(true);
|
||||
expect(looksLikeFeishuId("group:oc_123")).toBe(true);
|
||||
expect(looksLikeFeishuId("channel:oc_456")).toBe(true);
|
||||
});
|
||||
});
|
||||
84
extensions/firecrawl/index.test.ts
Normal file
84
extensions/firecrawl/index.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import plugin from "./index.js";
|
||||
import { __testing as firecrawlClientTesting } from "./src/firecrawl-client.js";
|
||||
|
||||
describe("firecrawl plugin", () => {
|
||||
it("parses scrape payloads into wrapped external-content results", () => {
|
||||
const result = firecrawlClientTesting.parseFirecrawlScrapePayload({
|
||||
payload: {
|
||||
success: true,
|
||||
data: {
|
||||
markdown: "# Hello\n\nWorld",
|
||||
metadata: {
|
||||
title: "Example page",
|
||||
sourceURL: "https://example.com/final",
|
||||
statusCode: 200,
|
||||
},
|
||||
},
|
||||
},
|
||||
url: "https://example.com/start",
|
||||
extractMode: "text",
|
||||
maxChars: 1000,
|
||||
});
|
||||
|
||||
expect(result.finalUrl).toBe("https://example.com/final");
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.extractor).toBe("firecrawl");
|
||||
expect(String(result.text)).toContain("Hello");
|
||||
expect(String(result.text)).toContain("World");
|
||||
expect(result.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it("extracts search items from flexible Firecrawl payload shapes", () => {
|
||||
const items = firecrawlClientTesting.resolveSearchItems({
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
title: "Docs",
|
||||
url: "https://docs.example.com/path",
|
||||
description: "Reference docs",
|
||||
markdown: "Body",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(items).toEqual([
|
||||
{
|
||||
title: "Docs",
|
||||
url: "https://docs.example.com/path",
|
||||
description: "Reference docs",
|
||||
content: "Body",
|
||||
published: undefined,
|
||||
siteName: "docs.example.com",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("extracts search items from Firecrawl v2 data.web payloads", () => {
|
||||
const items = firecrawlClientTesting.resolveSearchItems({
|
||||
success: true,
|
||||
data: {
|
||||
web: [
|
||||
{
|
||||
title: "API Platform - OpenAI",
|
||||
url: "https://openai.com/api/",
|
||||
description: "Build on the OpenAI API platform.",
|
||||
markdown: "# API Platform",
|
||||
position: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(items).toEqual([
|
||||
{
|
||||
title: "API Platform - OpenAI",
|
||||
url: "https://openai.com/api/",
|
||||
description: "Build on the OpenAI API platform.",
|
||||
content: "# API Platform",
|
||||
published: undefined,
|
||||
siteName: "openai.com",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -65,85 +65,6 @@ describe("firecrawl tools", () => {
|
||||
expect(applied.plugins?.entries?.firecrawl?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("parses scrape payloads into wrapped external-content results", () => {
|
||||
const result = firecrawlClientTesting.parseFirecrawlScrapePayload({
|
||||
payload: {
|
||||
success: true,
|
||||
data: {
|
||||
markdown: "# Hello\n\nWorld",
|
||||
metadata: {
|
||||
title: "Example page",
|
||||
sourceURL: "https://example.com/final",
|
||||
statusCode: 200,
|
||||
},
|
||||
},
|
||||
},
|
||||
url: "https://example.com/start",
|
||||
extractMode: "text",
|
||||
maxChars: 1000,
|
||||
});
|
||||
|
||||
expect(result.finalUrl).toBe("https://example.com/final");
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.extractor).toBe("firecrawl");
|
||||
expect(String(result.text)).toContain("Hello");
|
||||
expect(String(result.text)).toContain("World");
|
||||
expect(result.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it("extracts search items from flexible Firecrawl payload shapes", () => {
|
||||
const items = firecrawlClientTesting.resolveSearchItems({
|
||||
success: true,
|
||||
data: [
|
||||
{
|
||||
title: "Docs",
|
||||
url: "https://docs.example.com/path",
|
||||
description: "Reference docs",
|
||||
markdown: "Body",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(items).toEqual([
|
||||
{
|
||||
title: "Docs",
|
||||
url: "https://docs.example.com/path",
|
||||
description: "Reference docs",
|
||||
content: "Body",
|
||||
published: undefined,
|
||||
siteName: "docs.example.com",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("extracts search items from Firecrawl v2 data.web payloads", () => {
|
||||
const items = firecrawlClientTesting.resolveSearchItems({
|
||||
success: true,
|
||||
data: {
|
||||
web: [
|
||||
{
|
||||
title: "API Platform - OpenAI",
|
||||
url: "https://openai.com/api/",
|
||||
description: "Build on the OpenAI API platform.",
|
||||
markdown: "# API Platform",
|
||||
position: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(items).toEqual([
|
||||
{
|
||||
title: "API Platform - OpenAI",
|
||||
url: "https://openai.com/api/",
|
||||
description: "Build on the OpenAI API platform.",
|
||||
content: "# API Platform",
|
||||
published: undefined,
|
||||
siteName: "openai.com",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps generic provider args into firecrawl search params", async () => {
|
||||
const provider = createFirecrawlWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
|
||||
39
extensions/github-copilot/models-defaults.test.ts
Normal file
39
extensions/github-copilot/models-defaults.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildCopilotModelDefinition, getDefaultCopilotModelIds } from "./models-defaults.js";
|
||||
|
||||
describe("github-copilot model defaults", () => {
|
||||
describe("getDefaultCopilotModelIds", () => {
|
||||
it("includes claude-sonnet-4.6", () => {
|
||||
expect(getDefaultCopilotModelIds()).toContain("claude-sonnet-4.6");
|
||||
});
|
||||
|
||||
it("includes claude-sonnet-4.5", () => {
|
||||
expect(getDefaultCopilotModelIds()).toContain("claude-sonnet-4.5");
|
||||
});
|
||||
|
||||
it("returns a mutable copy", () => {
|
||||
const a = getDefaultCopilotModelIds();
|
||||
const b = getDefaultCopilotModelIds();
|
||||
expect(a).not.toBe(b);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCopilotModelDefinition", () => {
|
||||
it("builds a valid definition for claude-sonnet-4.6", () => {
|
||||
const def = buildCopilotModelDefinition("claude-sonnet-4.6");
|
||||
expect(def.id).toBe("claude-sonnet-4.6");
|
||||
expect(def.api).toBe("openai-responses");
|
||||
});
|
||||
|
||||
it("trims whitespace from model id", () => {
|
||||
const def = buildCopilotModelDefinition(" gpt-4o ");
|
||||
expect(def.id).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
it("throws on empty model id", () => {
|
||||
expect(() => buildCopilotModelDefinition("")).toThrow("Model id required");
|
||||
expect(() => buildCopilotModelDefinition(" ")).toThrow("Model id required");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createProviderUsageFetch,
|
||||
makeResponse,
|
||||
} from "../../test/helpers/extensions/provider-usage-fetch.js";
|
||||
import { buildCopilotModelDefinition, getDefaultCopilotModelIds } from "./models-defaults.js";
|
||||
import { fetchCopilotUsage } from "./usage.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@mariozechner/pi-ai/oauth", async () => {
|
||||
const actual = await vi.importActual<typeof import("@mariozechner/pi-ai/oauth")>(
|
||||
@@ -21,24 +15,9 @@ vi.mock("openclaw/plugin-sdk/provider-models", () => ({
|
||||
normalizeModelCompat: (model: Record<string, unknown>) => model,
|
||||
}));
|
||||
|
||||
const loadJsonFile = vi.fn();
|
||||
const saveJsonFile = vi.fn();
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/json-store", () => ({
|
||||
loadJsonFile,
|
||||
saveJsonFile,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/state-paths", () => ({
|
||||
resolveStateDir: () => "/tmp/openclaw-state",
|
||||
}));
|
||||
|
||||
import type { ProviderResolveDynamicModelContext } from "openclaw/plugin-sdk/core";
|
||||
import { resolveCopilotForwardCompatModel } from "./models.js";
|
||||
|
||||
let deriveCopilotApiBaseUrlFromToken: typeof import("./token.js").deriveCopilotApiBaseUrlFromToken;
|
||||
let resolveCopilotApiToken: typeof import("./token.js").resolveCopilotApiToken;
|
||||
|
||||
function createMockCtx(
|
||||
modelId: string,
|
||||
registryModels: Record<string, Record<string, unknown>> = {},
|
||||
@@ -61,43 +40,6 @@ function requireResolvedModel(ctx: ProviderResolveDynamicModelContext) {
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("github-copilot model defaults", () => {
|
||||
describe("getDefaultCopilotModelIds", () => {
|
||||
it("includes claude-sonnet-4.6", () => {
|
||||
expect(getDefaultCopilotModelIds()).toContain("claude-sonnet-4.6");
|
||||
});
|
||||
|
||||
it("includes claude-sonnet-4.5", () => {
|
||||
expect(getDefaultCopilotModelIds()).toContain("claude-sonnet-4.5");
|
||||
});
|
||||
|
||||
it("returns a mutable copy", () => {
|
||||
const a = getDefaultCopilotModelIds();
|
||||
const b = getDefaultCopilotModelIds();
|
||||
expect(a).not.toBe(b);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCopilotModelDefinition", () => {
|
||||
it("builds a valid definition for claude-sonnet-4.6", () => {
|
||||
const def = buildCopilotModelDefinition("claude-sonnet-4.6");
|
||||
expect(def.id).toBe("claude-sonnet-4.6");
|
||||
expect(def.api).toBe("openai-responses");
|
||||
});
|
||||
|
||||
it("trims whitespace from model id", () => {
|
||||
const def = buildCopilotModelDefinition(" gpt-4o ");
|
||||
expect(def.id).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
it("throws on empty model id", () => {
|
||||
expect(() => buildCopilotModelDefinition("")).toThrow("Model id required");
|
||||
expect(() => buildCopilotModelDefinition(" ")).toThrow("Model id required");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCopilotForwardCompatModel", () => {
|
||||
it("returns undefined for empty modelId", () => {
|
||||
expect(resolveCopilotForwardCompatModel(createMockCtx(""))).toBeUndefined();
|
||||
@@ -166,141 +108,3 @@ describe("resolveCopilotForwardCompatModel", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchCopilotUsage", () => {
|
||||
it("returns HTTP errors for failed requests", async () => {
|
||||
const mockFetch = createProviderUsageFetch(async () => makeResponse(500, "boom"));
|
||||
const result = await fetchCopilotUsage("token", 5000, mockFetch);
|
||||
|
||||
expect(result.error).toBe("HTTP 500");
|
||||
expect(result.windows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("parses premium/chat usage from remaining percentages", async () => {
|
||||
const mockFetch = createProviderUsageFetch(async (_url, init) => {
|
||||
const headers = (init?.headers as Record<string, string> | undefined) ?? {};
|
||||
expect(headers.Authorization).toBe("token token");
|
||||
expect(headers["X-Github-Api-Version"]).toBe("2025-04-01");
|
||||
|
||||
return makeResponse(200, {
|
||||
quota_snapshots: {
|
||||
premium_interactions: { percent_remaining: 20 },
|
||||
chat: { percent_remaining: 75 },
|
||||
},
|
||||
copilot_plan: "pro",
|
||||
});
|
||||
});
|
||||
|
||||
const result = await fetchCopilotUsage("token", 5000, mockFetch);
|
||||
|
||||
expect(result.plan).toBe("pro");
|
||||
expect(result.windows).toEqual([
|
||||
{ label: "Premium", usedPercent: 80 },
|
||||
{ label: "Chat", usedPercent: 25 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("defaults missing snapshot values and clamps invalid remaining percentages", async () => {
|
||||
const mockFetch = createProviderUsageFetch(async () =>
|
||||
makeResponse(200, {
|
||||
quota_snapshots: {
|
||||
premium_interactions: { percent_remaining: null },
|
||||
chat: { percent_remaining: 140 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await fetchCopilotUsage("token", 5000, mockFetch);
|
||||
|
||||
expect(result.windows).toEqual([
|
||||
{ label: "Premium", usedPercent: 100 },
|
||||
{ label: "Chat", usedPercent: 0 },
|
||||
]);
|
||||
expect(result.plan).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns an empty window list when quota snapshots are missing", async () => {
|
||||
const mockFetch = createProviderUsageFetch(async () =>
|
||||
makeResponse(200, {
|
||||
copilot_plan: "free",
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await fetchCopilotUsage("token", 5000, mockFetch);
|
||||
|
||||
expect(result).toEqual({
|
||||
provider: "github-copilot",
|
||||
displayName: "Copilot",
|
||||
windows: [],
|
||||
plan: "free",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("github-copilot token", () => {
|
||||
const cachePath = "/tmp/openclaw-state/credentials/github-copilot.token.json";
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
loadJsonFile.mockClear();
|
||||
saveJsonFile.mockClear();
|
||||
({ deriveCopilotApiBaseUrlFromToken, resolveCopilotApiToken } = await import("./token.js"));
|
||||
});
|
||||
|
||||
it("derives baseUrl from token", async () => {
|
||||
expect(deriveCopilotApiBaseUrlFromToken("token;proxy-ep=proxy.example.com;")).toBe(
|
||||
"https://api.example.com",
|
||||
);
|
||||
expect(deriveCopilotApiBaseUrlFromToken("token;proxy-ep=https://proxy.foo.bar;")).toBe(
|
||||
"https://api.foo.bar",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses cache when token is still valid", async () => {
|
||||
const now = Date.now();
|
||||
loadJsonFile.mockReturnValue({
|
||||
token: "cached;proxy-ep=proxy.example.com;",
|
||||
expiresAt: now + 60 * 60 * 1000,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const fetchImpl = vi.fn();
|
||||
const res = await resolveCopilotApiToken({
|
||||
githubToken: "gh",
|
||||
cachePath,
|
||||
loadJsonFileImpl: loadJsonFile,
|
||||
saveJsonFileImpl: saveJsonFile,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(res.token).toBe("cached;proxy-ep=proxy.example.com;");
|
||||
expect(res.baseUrl).toBe("https://api.example.com");
|
||||
expect(String(res.source)).toContain("cache:");
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches and stores token when cache is missing", async () => {
|
||||
loadJsonFile.mockReturnValue(undefined);
|
||||
|
||||
const fetchImpl = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
token: "fresh;proxy-ep=https://proxy.contoso.test;",
|
||||
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await resolveCopilotApiToken({
|
||||
githubToken: "gh",
|
||||
cachePath,
|
||||
loadJsonFileImpl: loadJsonFile,
|
||||
saveJsonFileImpl: saveJsonFile,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(res.token).toBe("fresh;proxy-ep=https://proxy.contoso.test;");
|
||||
expect(res.baseUrl).toBe("https://api.contoso.test");
|
||||
expect(saveJsonFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
84
extensions/github-copilot/token.test.ts
Normal file
84
extensions/github-copilot/token.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const loadJsonFile = vi.fn();
|
||||
const saveJsonFile = vi.fn();
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/json-store", () => ({
|
||||
loadJsonFile,
|
||||
saveJsonFile,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/state-paths", () => ({
|
||||
resolveStateDir: () => "/tmp/openclaw-state",
|
||||
}));
|
||||
|
||||
let deriveCopilotApiBaseUrlFromToken: typeof import("./token.js").deriveCopilotApiBaseUrlFromToken;
|
||||
let resolveCopilotApiToken: typeof import("./token.js").resolveCopilotApiToken;
|
||||
|
||||
describe("github-copilot token", () => {
|
||||
const cachePath = "/tmp/openclaw-state/credentials/github-copilot.token.json";
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
loadJsonFile.mockClear();
|
||||
saveJsonFile.mockClear();
|
||||
({ deriveCopilotApiBaseUrlFromToken, resolveCopilotApiToken } = await import("./token.js"));
|
||||
});
|
||||
|
||||
it("derives baseUrl from token", async () => {
|
||||
expect(deriveCopilotApiBaseUrlFromToken("token;proxy-ep=proxy.example.com;")).toBe(
|
||||
"https://api.example.com",
|
||||
);
|
||||
expect(deriveCopilotApiBaseUrlFromToken("token;proxy-ep=https://proxy.foo.bar;")).toBe(
|
||||
"https://api.foo.bar",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses cache when token is still valid", async () => {
|
||||
const now = Date.now();
|
||||
loadJsonFile.mockReturnValue({
|
||||
token: "cached;proxy-ep=proxy.example.com;",
|
||||
expiresAt: now + 60 * 60 * 1000,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const fetchImpl = vi.fn();
|
||||
const res = await resolveCopilotApiToken({
|
||||
githubToken: "gh",
|
||||
cachePath,
|
||||
loadJsonFileImpl: loadJsonFile,
|
||||
saveJsonFileImpl: saveJsonFile,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(res.token).toBe("cached;proxy-ep=proxy.example.com;");
|
||||
expect(res.baseUrl).toBe("https://api.example.com");
|
||||
expect(String(res.source)).toContain("cache:");
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches and stores token when cache is missing", async () => {
|
||||
loadJsonFile.mockReturnValue(undefined);
|
||||
|
||||
const fetchImpl = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
token: "fresh;proxy-ep=https://proxy.contoso.test;",
|
||||
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await resolveCopilotApiToken({
|
||||
githubToken: "gh",
|
||||
cachePath,
|
||||
loadJsonFileImpl: loadJsonFile,
|
||||
saveJsonFileImpl: saveJsonFile,
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(res.token).toBe("fresh;proxy-ep=https://proxy.contoso.test;");
|
||||
expect(res.baseUrl).toBe("https://api.contoso.test");
|
||||
expect(saveJsonFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
76
extensions/github-copilot/usage.test.ts
Normal file
76
extensions/github-copilot/usage.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createProviderUsageFetch,
|
||||
makeResponse,
|
||||
} from "../../test/helpers/extensions/provider-usage-fetch.js";
|
||||
import { fetchCopilotUsage } from "./usage.js";
|
||||
|
||||
describe("fetchCopilotUsage", () => {
|
||||
it("returns HTTP errors for failed requests", async () => {
|
||||
const mockFetch = createProviderUsageFetch(async () => makeResponse(500, "boom"));
|
||||
const result = await fetchCopilotUsage("token", 5000, mockFetch);
|
||||
|
||||
expect(result.error).toBe("HTTP 500");
|
||||
expect(result.windows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("parses premium/chat usage from remaining percentages", async () => {
|
||||
const mockFetch = createProviderUsageFetch(async (_url, init) => {
|
||||
const headers = (init?.headers as Record<string, string> | undefined) ?? {};
|
||||
expect(headers.Authorization).toBe("token token");
|
||||
expect(headers["X-Github-Api-Version"]).toBe("2025-04-01");
|
||||
|
||||
return makeResponse(200, {
|
||||
quota_snapshots: {
|
||||
premium_interactions: { percent_remaining: 20 },
|
||||
chat: { percent_remaining: 75 },
|
||||
},
|
||||
copilot_plan: "pro",
|
||||
});
|
||||
});
|
||||
|
||||
const result = await fetchCopilotUsage("token", 5000, mockFetch);
|
||||
|
||||
expect(result.plan).toBe("pro");
|
||||
expect(result.windows).toEqual([
|
||||
{ label: "Premium", usedPercent: 80 },
|
||||
{ label: "Chat", usedPercent: 25 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("defaults missing snapshot values and clamps invalid remaining percentages", async () => {
|
||||
const mockFetch = createProviderUsageFetch(async () =>
|
||||
makeResponse(200, {
|
||||
quota_snapshots: {
|
||||
premium_interactions: { percent_remaining: null },
|
||||
chat: { percent_remaining: 140 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await fetchCopilotUsage("token", 5000, mockFetch);
|
||||
|
||||
expect(result.windows).toEqual([
|
||||
{ label: "Premium", usedPercent: 100 },
|
||||
{ label: "Chat", usedPercent: 0 },
|
||||
]);
|
||||
expect(result.plan).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns an empty window list when quota snapshots are missing", async () => {
|
||||
const mockFetch = createProviderUsageFetch(async () =>
|
||||
makeResponse(200, {
|
||||
copilot_plan: "free",
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await fetchCopilotUsage("token", 5000, mockFetch);
|
||||
|
||||
expect(result).toEqual({
|
||||
provider: "github-copilot",
|
||||
displayName: "Copilot",
|
||||
windows: [],
|
||||
plan: "free",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as providerAuth from "openclaw/plugin-sdk/provider-auth";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildGoogleImageGenerationProvider } from "./image-generation-provider.js";
|
||||
import { __testing as geminiWebSearchTesting } from "./src/gemini-web-search-provider.js";
|
||||
|
||||
function mockGoogleApiKeyAuth() {
|
||||
vi.spyOn(providerAuth, "resolveApiKeyForProvider").mockResolvedValue({
|
||||
@@ -283,20 +282,4 @@ describe("Google image-generation provider", () => {
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers scoped configured Gemini API keys over environment fallbacks", () => {
|
||||
expect(
|
||||
geminiWebSearchTesting.resolveGeminiApiKey({
|
||||
apiKey: "gemini-secret",
|
||||
}),
|
||||
).toBe("gemini-secret");
|
||||
});
|
||||
|
||||
it("falls back to the default Gemini model when unset or blank", () => {
|
||||
expect(geminiWebSearchTesting.resolveGeminiModel()).toBe("gemini-2.5-flash");
|
||||
expect(geminiWebSearchTesting.resolveGeminiModel({ model: " " })).toBe("gemini-2.5-flash");
|
||||
expect(geminiWebSearchTesting.resolveGeminiModel({ model: "gemini-2.5-pro" })).toBe(
|
||||
"gemini-2.5-pro",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
18
extensions/google/src/gemini-web-search-provider.test.ts
Normal file
18
extensions/google/src/gemini-web-search-provider.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __testing } from "./gemini-web-search-provider.js";
|
||||
|
||||
describe("gemini web search provider", () => {
|
||||
it("prefers scoped configured api keys over environment fallbacks", () => {
|
||||
expect(
|
||||
__testing.resolveGeminiApiKey({
|
||||
apiKey: "gemini-secret",
|
||||
}),
|
||||
).toBe("gemini-secret");
|
||||
});
|
||||
|
||||
it("falls back to the default Gemini model when unset or blank", () => {
|
||||
expect(__testing.resolveGeminiModel()).toBe("gemini-2.5-flash");
|
||||
expect(__testing.resolveGeminiModel({ model: " " })).toBe("gemini-2.5-flash");
|
||||
expect(__testing.resolveGeminiModel({ model: "gemini-2.5-pro" })).toBe("gemini-2.5-pro");
|
||||
});
|
||||
});
|
||||
131
extensions/googlechat/src/accounts.test.ts
Normal file
131
extensions/googlechat/src/accounts.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import { resolveGoogleChatAccount } from "./accounts.js";
|
||||
|
||||
describe("resolveGoogleChatAccount", () => {
|
||||
it("inherits shared defaults from accounts.default for named accounts", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
accounts: {
|
||||
default: {
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
webhookPath: "/googlechat",
|
||||
},
|
||||
andy: {
|
||||
serviceAccountFile: "/tmp/andy-sa.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveGoogleChatAccount({ cfg, accountId: "andy" });
|
||||
expect(resolved.config.audienceType).toBe("app-url");
|
||||
expect(resolved.config.audience).toBe("https://example.com/googlechat");
|
||||
expect(resolved.config.webhookPath).toBe("/googlechat");
|
||||
expect(resolved.config.serviceAccountFile).toBe("/tmp/andy-sa.json");
|
||||
});
|
||||
|
||||
it("prefers top-level and account overrides over accounts.default", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
audienceType: "project-number",
|
||||
audience: "1234567890",
|
||||
accounts: {
|
||||
default: {
|
||||
audienceType: "app-url",
|
||||
audience: "https://default.example.com/googlechat",
|
||||
webhookPath: "/googlechat-default",
|
||||
},
|
||||
april: {
|
||||
webhookPath: "/googlechat-april",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveGoogleChatAccount({ cfg, accountId: "april" });
|
||||
expect(resolved.config.audienceType).toBe("project-number");
|
||||
expect(resolved.config.audience).toBe("1234567890");
|
||||
expect(resolved.config.webhookPath).toBe("/googlechat-april");
|
||||
});
|
||||
|
||||
it("does not inherit disabled state from accounts.default for named accounts", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
accounts: {
|
||||
default: {
|
||||
enabled: false,
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
},
|
||||
andy: {
|
||||
serviceAccountFile: "/tmp/andy-sa.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveGoogleChatAccount({ cfg, accountId: "andy" });
|
||||
expect(resolved.enabled).toBe(true);
|
||||
expect(resolved.config.enabled).toBeUndefined();
|
||||
expect(resolved.config.audienceType).toBe("app-url");
|
||||
});
|
||||
|
||||
it("does not inherit default-account credentials into named accounts", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
accounts: {
|
||||
default: {
|
||||
serviceAccountRef: {
|
||||
source: "env",
|
||||
provider: "test",
|
||||
id: "default-sa",
|
||||
},
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
},
|
||||
andy: {
|
||||
serviceAccountFile: "/tmp/andy-sa.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveGoogleChatAccount({ cfg, accountId: "andy" });
|
||||
expect(resolved.credentialSource).toBe("file");
|
||||
expect(resolved.credentialsFile).toBe("/tmp/andy-sa.json");
|
||||
expect(resolved.config.audienceType).toBe("app-url");
|
||||
});
|
||||
|
||||
it("does not inherit dangerous name matching from accounts.default", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
accounts: {
|
||||
default: {
|
||||
dangerouslyAllowNameMatching: true,
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
},
|
||||
andy: {
|
||||
serviceAccountFile: "/tmp/andy-sa.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveGoogleChatAccount({ cfg, accountId: "andy" });
|
||||
expect(resolved.config.dangerouslyAllowNameMatching).toBeUndefined();
|
||||
expect(resolved.config.audienceType).toBe("app-url");
|
||||
});
|
||||
});
|
||||
105
extensions/googlechat/src/api.test.ts
Normal file
105
extensions/googlechat/src/api.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import { downloadGoogleChatMedia, sendGoogleChatMessage } from "./api.js";
|
||||
|
||||
vi.mock("./auth.js", () => ({
|
||||
getGoogleChatAccessToken: vi.fn().mockResolvedValue("token"),
|
||||
}));
|
||||
|
||||
const account = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
credentialSource: "inline",
|
||||
config: {},
|
||||
} as ResolvedGoogleChatAccount;
|
||||
|
||||
function stubSuccessfulSend(name: string) {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response(JSON.stringify({ name }), { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
async function expectDownloadToRejectForResponse(response: Response) {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
|
||||
await expect(
|
||||
downloadGoogleChatMedia({ account, resourceName: "media/123", maxBytes: 10 }),
|
||||
).rejects.toThrow(/max bytes/i);
|
||||
}
|
||||
|
||||
describe("downloadGoogleChatMedia", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("rejects when content-length exceeds max bytes", async () => {
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array([1, 2, 3]));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const response = new Response(body, {
|
||||
status: 200,
|
||||
headers: { "content-length": "50", "content-type": "application/octet-stream" },
|
||||
});
|
||||
await expectDownloadToRejectForResponse(response);
|
||||
});
|
||||
|
||||
it("rejects when streamed payload exceeds max bytes", async () => {
|
||||
const chunks = [new Uint8Array(6), new Uint8Array(6)];
|
||||
let index = 0;
|
||||
const body = new ReadableStream({
|
||||
pull(controller) {
|
||||
if (index < chunks.length) {
|
||||
controller.enqueue(chunks[index++]);
|
||||
} else {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
const response = new Response(body, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/octet-stream" },
|
||||
});
|
||||
await expectDownloadToRejectForResponse(response);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendGoogleChatMessage", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("adds messageReplyOption when sending to an existing thread", async () => {
|
||||
const fetchMock = stubSuccessfulSend("spaces/AAA/messages/123");
|
||||
|
||||
await sendGoogleChatMessage({
|
||||
account,
|
||||
space: "spaces/AAA",
|
||||
text: "hello",
|
||||
thread: "spaces/AAA/threads/xyz",
|
||||
});
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] ?? [];
|
||||
expect(String(url)).toContain("messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD"); // pragma: allowlist secret
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||
text: "hello",
|
||||
thread: { name: "spaces/AAA/threads/xyz" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not set messageReplyOption for non-thread sends", async () => {
|
||||
const fetchMock = stubSuccessfulSend("spaces/AAA/messages/124");
|
||||
|
||||
await sendGoogleChatMessage({
|
||||
account,
|
||||
space: "spaces/AAA",
|
||||
text: "hello",
|
||||
});
|
||||
|
||||
const [url] = fetchMock.mock.calls[0] ?? [];
|
||||
expect(String(url)).not.toContain("messageReplyOption=");
|
||||
});
|
||||
});
|
||||
97
extensions/googlechat/src/auth.test.ts
Normal file
97
extensions/googlechat/src/auth.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
verifyIdToken: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("google-auth-library", () => ({
|
||||
GoogleAuth: class {},
|
||||
OAuth2Client: class {
|
||||
verifyIdToken = mocks.verifyIdToken;
|
||||
},
|
||||
}));
|
||||
|
||||
const { verifyGoogleChatRequest } = await import("./auth.js");
|
||||
|
||||
function mockTicket(payload: Record<string, unknown>) {
|
||||
mocks.verifyIdToken.mockResolvedValue({
|
||||
getPayload: () => payload,
|
||||
});
|
||||
}
|
||||
|
||||
describe("verifyGoogleChatRequest", () => {
|
||||
beforeEach(() => {
|
||||
mocks.verifyIdToken.mockReset();
|
||||
});
|
||||
|
||||
it("accepts Google Chat app-url tokens from the Chat issuer", async () => {
|
||||
mockTicket({
|
||||
email: "chat@system.gserviceaccount.com",
|
||||
email_verified: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyGoogleChatRequest({
|
||||
bearer: "token",
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("rejects add-on tokens when no principal binding is configured", async () => {
|
||||
mockTicket({
|
||||
email: "service-123@gcp-sa-gsuiteaddons.iam.gserviceaccount.com",
|
||||
email_verified: true,
|
||||
sub: "principal-1",
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyGoogleChatRequest({
|
||||
bearer: "token",
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "missing add-on principal binding",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts add-on tokens only when the bound principal matches", async () => {
|
||||
mockTicket({
|
||||
email: "service-123@gcp-sa-gsuiteaddons.iam.gserviceaccount.com",
|
||||
email_verified: true,
|
||||
sub: "principal-1",
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyGoogleChatRequest({
|
||||
bearer: "token",
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
expectedAddOnPrincipal: "principal-1",
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("rejects add-on tokens when the bound principal does not match", async () => {
|
||||
mockTicket({
|
||||
email: "service-123@gcp-sa-gsuiteaddons.iam.gserviceaccount.com",
|
||||
email_verified: true,
|
||||
sub: "principal-2",
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyGoogleChatRequest({
|
||||
bearer: "token",
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
expectedAddOnPrincipal: "principal-1",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "unexpected add-on principal: principal-2",
|
||||
});
|
||||
});
|
||||
});
|
||||
86
extensions/googlechat/src/channel.directory.test.ts
Normal file
86
extensions/googlechat/src/channel.directory.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createDirectoryTestRuntime,
|
||||
expectDirectorySurface,
|
||||
} from "../../../test/helpers/extensions/directory.ts";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import { googlechatPlugin } from "./channel.js";
|
||||
|
||||
describe("googlechat directory", () => {
|
||||
const runtimeEnv = createDirectoryTestRuntime() as never;
|
||||
|
||||
it("lists peers and groups from config", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
serviceAccount: { client_email: "bot@example.com" },
|
||||
dm: { allowFrom: ["users/alice", "googlechat:bob"] },
|
||||
groups: {
|
||||
"spaces/AAA": {},
|
||||
"spaces/BBB": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const directory = expectDirectorySurface(googlechatPlugin.directory);
|
||||
|
||||
await expect(
|
||||
directory.listPeers({
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
query: undefined,
|
||||
limit: undefined,
|
||||
runtime: runtimeEnv,
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: "user", id: "users/alice" },
|
||||
{ kind: "user", id: "bob" },
|
||||
]),
|
||||
);
|
||||
|
||||
await expect(
|
||||
directory.listGroups({
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
query: undefined,
|
||||
limit: undefined,
|
||||
runtime: runtimeEnv,
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: "group", id: "spaces/AAA" },
|
||||
{ kind: "group", id: "spaces/BBB" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes spaced provider-prefixed dm allowlist entries", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
serviceAccount: { client_email: "bot@example.com" },
|
||||
dm: { allowFrom: [" users/alice ", " googlechat:user:Bob@Example.com "] },
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const directory = expectDirectorySurface(googlechatPlugin.directory);
|
||||
|
||||
await expect(
|
||||
directory.listPeers({
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
query: undefined,
|
||||
limit: undefined,
|
||||
runtime: runtimeEnv,
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: "user", id: "users/alice" },
|
||||
{ kind: "user", id: "users/bob@example.com" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
159
extensions/googlechat/src/channel.outbound.test.ts
Normal file
159
extensions/googlechat/src/channel.outbound.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
|
||||
|
||||
const uploadGoogleChatAttachmentMock = vi.hoisted(() => vi.fn());
|
||||
const sendGoogleChatMessageMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./api.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./api.js")>();
|
||||
return {
|
||||
...actual,
|
||||
sendGoogleChatMessage: sendGoogleChatMessageMock,
|
||||
uploadGoogleChatAttachment: uploadGoogleChatAttachmentMock,
|
||||
};
|
||||
});
|
||||
|
||||
import { googlechatPlugin } from "./channel.js";
|
||||
import { setGoogleChatRuntime } from "./runtime.js";
|
||||
|
||||
function createGoogleChatCfg(): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
googlechat: {
|
||||
enabled: true,
|
||||
serviceAccount: {
|
||||
type: "service_account",
|
||||
client_email: "bot@example.com",
|
||||
private_key: "test-key", // pragma: allowlist secret
|
||||
token_uri: "https://oauth2.googleapis.com/token",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setupRuntimeMediaMocks(params: { loadFileName: string; loadBytes: string }) {
|
||||
const loadWebMedia = vi.fn(async () => ({
|
||||
buffer: Buffer.from(params.loadBytes),
|
||||
fileName: params.loadFileName,
|
||||
contentType: "image/png",
|
||||
}));
|
||||
const fetchRemoteMedia = vi.fn(async () => ({
|
||||
buffer: Buffer.from("remote-bytes"),
|
||||
fileName: "remote.png",
|
||||
contentType: "image/png",
|
||||
}));
|
||||
|
||||
setGoogleChatRuntime({
|
||||
media: { loadWebMedia },
|
||||
channel: {
|
||||
media: { fetchRemoteMedia },
|
||||
text: { chunkMarkdownText: (text: string) => [text] },
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
|
||||
return { loadWebMedia, fetchRemoteMedia };
|
||||
}
|
||||
|
||||
describe("googlechatPlugin outbound sendMedia", () => {
|
||||
it("loads local media with mediaLocalRoots via runtime media loader", async () => {
|
||||
const { loadWebMedia, fetchRemoteMedia } = setupRuntimeMediaMocks({
|
||||
loadFileName: "image.png",
|
||||
loadBytes: "image-bytes",
|
||||
});
|
||||
|
||||
uploadGoogleChatAttachmentMock.mockResolvedValue({
|
||||
attachmentUploadToken: "token-1",
|
||||
});
|
||||
sendGoogleChatMessageMock.mockResolvedValue({
|
||||
messageName: "spaces/AAA/messages/msg-1",
|
||||
});
|
||||
|
||||
const cfg = createGoogleChatCfg();
|
||||
|
||||
const result = await googlechatPlugin.outbound?.sendMedia?.({
|
||||
cfg,
|
||||
to: "spaces/AAA",
|
||||
text: "caption",
|
||||
mediaUrl: "/tmp/workspace/image.png",
|
||||
mediaLocalRoots: ["/tmp/workspace"],
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(loadWebMedia).toHaveBeenCalledWith(
|
||||
"/tmp/workspace/image.png",
|
||||
expect.objectContaining({
|
||||
localRoots: ["/tmp/workspace"],
|
||||
}),
|
||||
);
|
||||
expect(fetchRemoteMedia).not.toHaveBeenCalled();
|
||||
expect(uploadGoogleChatAttachmentMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
space: "spaces/AAA",
|
||||
filename: "image.png",
|
||||
contentType: "image/png",
|
||||
}),
|
||||
);
|
||||
expect(sendGoogleChatMessageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
space: "spaces/AAA",
|
||||
text: "caption",
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
channel: "googlechat",
|
||||
messageId: "spaces/AAA/messages/msg-1",
|
||||
chatId: "spaces/AAA",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps remote URL media fetch on fetchRemoteMedia with maxBytes cap", async () => {
|
||||
const { loadWebMedia, fetchRemoteMedia } = setupRuntimeMediaMocks({
|
||||
loadFileName: "unused.png",
|
||||
loadBytes: "should-not-be-used",
|
||||
});
|
||||
|
||||
uploadGoogleChatAttachmentMock.mockResolvedValue({
|
||||
attachmentUploadToken: "token-2",
|
||||
});
|
||||
sendGoogleChatMessageMock.mockResolvedValue({
|
||||
messageName: "spaces/AAA/messages/msg-2",
|
||||
});
|
||||
|
||||
const cfg = createGoogleChatCfg();
|
||||
|
||||
const result = await googlechatPlugin.outbound?.sendMedia?.({
|
||||
cfg,
|
||||
to: "spaces/AAA",
|
||||
text: "caption",
|
||||
mediaUrl: "https://example.com/image.png",
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(fetchRemoteMedia).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://example.com/image.png",
|
||||
maxBytes: 20 * 1024 * 1024,
|
||||
}),
|
||||
);
|
||||
expect(loadWebMedia).not.toHaveBeenCalled();
|
||||
expect(uploadGoogleChatAttachmentMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
space: "spaces/AAA",
|
||||
filename: "remote.png",
|
||||
contentType: "image/png",
|
||||
}),
|
||||
);
|
||||
expect(sendGoogleChatMessageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
space: "spaces/AAA",
|
||||
text: "caption",
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
channel: "googlechat",
|
||||
messageId: "spaces/AAA/messages/msg-2",
|
||||
chatId: "spaces/AAA",
|
||||
});
|
||||
});
|
||||
});
|
||||
41
extensions/googlechat/src/channel.security.test.ts
Normal file
41
extensions/googlechat/src/channel.security.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import { googlechatPlugin } from "./channel.js";
|
||||
|
||||
describe("googlechatPlugin security", () => {
|
||||
it("normalizes prefixed DM allowlist entries to lowercase user ids", () => {
|
||||
const security = googlechatPlugin.security;
|
||||
if (!security) {
|
||||
throw new Error("googlechat security unavailable");
|
||||
}
|
||||
const resolveDmPolicy = security.resolveDmPolicy;
|
||||
const normalizeAllowEntry = googlechatPlugin.pairing?.normalizeAllowEntry;
|
||||
expect(resolveDmPolicy).toBeTypeOf("function");
|
||||
expect(normalizeAllowEntry).toBeTypeOf("function");
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
serviceAccount: { client_email: "bot@example.com" },
|
||||
dm: {
|
||||
policy: "allowlist",
|
||||
allowFrom: [" googlechat:user:Bob@Example.com "],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const account = googlechatPlugin.config.resolveAccount(cfg, "default");
|
||||
const resolved = resolveDmPolicy!({ cfg, account });
|
||||
if (!resolved) {
|
||||
throw new Error("googlechat resolveDmPolicy returned null");
|
||||
}
|
||||
|
||||
expect(resolved.policy).toBe("allowlist");
|
||||
expect(resolved.allowFrom).toEqual([" googlechat:user:Bob@Example.com "]);
|
||||
expect(resolved.normalizeEntry?.(" googlechat:user:Bob@Example.com ")).toBe(
|
||||
"bob@example.com",
|
||||
);
|
||||
expect(normalizeAllowEntry!(" users/Alice@Example.com ")).toBe("alice@example.com");
|
||||
});
|
||||
});
|
||||
67
extensions/googlechat/src/channel.startup.test.ts
Normal file
67
extensions/googlechat/src/channel.startup.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
expectLifecyclePatch,
|
||||
expectPendingUntilAbort,
|
||||
startAccountAndTrackLifecycle,
|
||||
waitForStartedMocks,
|
||||
} from "../../../test/helpers/extensions/start-account-lifecycle.js";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
startGoogleChatMonitor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./monitor.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./monitor.js")>("./monitor.js");
|
||||
return {
|
||||
...actual,
|
||||
startGoogleChatMonitor: hoisted.startGoogleChatMonitor,
|
||||
};
|
||||
});
|
||||
|
||||
import { googlechatPlugin } from "./channel.js";
|
||||
|
||||
function buildAccount(): ResolvedGoogleChatAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
credentialSource: "inline",
|
||||
credentials: {},
|
||||
config: {
|
||||
webhookPath: "/googlechat",
|
||||
webhookUrl: "https://example.com/googlechat",
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("googlechatPlugin gateway.startAccount", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("keeps startAccount pending until abort, then unregisters", async () => {
|
||||
const unregister = vi.fn();
|
||||
hoisted.startGoogleChatMonitor.mockResolvedValue(unregister);
|
||||
|
||||
const { abort, patches, task, isSettled } = startAccountAndTrackLifecycle({
|
||||
startAccount: googlechatPlugin.gateway!.startAccount!,
|
||||
account: buildAccount(),
|
||||
});
|
||||
await expectPendingUntilAbort({
|
||||
waitForStarted: waitForStartedMocks(hoisted.startGoogleChatMonitor),
|
||||
isSettled,
|
||||
abort,
|
||||
task,
|
||||
assertBeforeAbort: () => {
|
||||
expect(unregister).not.toHaveBeenCalled();
|
||||
},
|
||||
assertAfterAbort: () => {
|
||||
expect(unregister).toHaveBeenCalledOnce();
|
||||
},
|
||||
});
|
||||
expectLifecyclePatch(patches, { running: true });
|
||||
expectLifecyclePatch(patches, { running: false });
|
||||
});
|
||||
});
|
||||
@@ -1,494 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createDirectoryTestRuntime,
|
||||
expectDirectorySurface,
|
||||
} from "../../../test/helpers/extensions/directory.ts";
|
||||
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
|
||||
|
||||
const uploadGoogleChatAttachmentMock = vi.hoisted(() => vi.fn());
|
||||
const sendGoogleChatMessageMock = vi.hoisted(() => vi.fn());
|
||||
const resolveGoogleChatAccountMock = vi.hoisted(() => vi.fn());
|
||||
const resolveGoogleChatOutboundSpaceMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./api.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./api.js")>();
|
||||
return {
|
||||
...actual,
|
||||
sendGoogleChatMessage: sendGoogleChatMessageMock,
|
||||
uploadGoogleChatAttachment: uploadGoogleChatAttachmentMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./accounts.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./accounts.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveGoogleChatAccount: resolveGoogleChatAccountMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./targets.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./targets.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveGoogleChatOutboundSpace: resolveGoogleChatOutboundSpaceMock,
|
||||
};
|
||||
});
|
||||
|
||||
const accountsActual = await vi.importActual<typeof import("./accounts.js")>("./accounts.js");
|
||||
const targetsActual = await vi.importActual<typeof import("./targets.js")>("./targets.js");
|
||||
|
||||
resolveGoogleChatAccountMock.mockImplementation(accountsActual.resolveGoogleChatAccount);
|
||||
resolveGoogleChatOutboundSpaceMock.mockImplementation(targetsActual.resolveGoogleChatOutboundSpace);
|
||||
|
||||
import { googlechatPlugin } from "./channel.js";
|
||||
import { setGoogleChatRuntime } from "./runtime.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resolveGoogleChatAccountMock.mockImplementation(accountsActual.resolveGoogleChatAccount);
|
||||
resolveGoogleChatOutboundSpaceMock.mockImplementation(
|
||||
targetsActual.resolveGoogleChatOutboundSpace,
|
||||
);
|
||||
});
|
||||
|
||||
function createGoogleChatCfg(): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
googlechat: {
|
||||
enabled: true,
|
||||
serviceAccount: {
|
||||
type: "service_account",
|
||||
client_email: "bot@example.com",
|
||||
private_key: "test-key", // pragma: allowlist secret
|
||||
token_uri: "https://oauth2.googleapis.com/token",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setupRuntimeMediaMocks(params: { loadFileName: string; loadBytes: string }) {
|
||||
const loadWebMedia = vi.fn(async () => ({
|
||||
buffer: Buffer.from(params.loadBytes),
|
||||
fileName: params.loadFileName,
|
||||
contentType: "image/png",
|
||||
}));
|
||||
const fetchRemoteMedia = vi.fn(async () => ({
|
||||
buffer: Buffer.from("remote-bytes"),
|
||||
fileName: "remote.png",
|
||||
contentType: "image/png",
|
||||
}));
|
||||
|
||||
setGoogleChatRuntime({
|
||||
media: { loadWebMedia },
|
||||
channel: {
|
||||
media: { fetchRemoteMedia },
|
||||
text: { chunkMarkdownText: (text: string) => [text] },
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
|
||||
return { loadWebMedia, fetchRemoteMedia };
|
||||
}
|
||||
|
||||
describe("googlechatPlugin outbound sendMedia", () => {
|
||||
it("loads local media with mediaLocalRoots via runtime media loader", async () => {
|
||||
const { loadWebMedia, fetchRemoteMedia } = setupRuntimeMediaMocks({
|
||||
loadFileName: "image.png",
|
||||
loadBytes: "image-bytes",
|
||||
});
|
||||
|
||||
uploadGoogleChatAttachmentMock.mockResolvedValue({
|
||||
attachmentUploadToken: "token-1",
|
||||
});
|
||||
sendGoogleChatMessageMock.mockResolvedValue({
|
||||
messageName: "spaces/AAA/messages/msg-1",
|
||||
});
|
||||
|
||||
const cfg = createGoogleChatCfg();
|
||||
|
||||
const result = await googlechatPlugin.outbound?.sendMedia?.({
|
||||
cfg,
|
||||
to: "spaces/AAA",
|
||||
text: "caption",
|
||||
mediaUrl: "/tmp/workspace/image.png",
|
||||
mediaLocalRoots: ["/tmp/workspace"],
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(loadWebMedia).toHaveBeenCalledWith(
|
||||
"/tmp/workspace/image.png",
|
||||
expect.objectContaining({
|
||||
localRoots: ["/tmp/workspace"],
|
||||
}),
|
||||
);
|
||||
expect(fetchRemoteMedia).not.toHaveBeenCalled();
|
||||
expect(uploadGoogleChatAttachmentMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
space: "spaces/AAA",
|
||||
filename: "image.png",
|
||||
contentType: "image/png",
|
||||
}),
|
||||
);
|
||||
expect(sendGoogleChatMessageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
space: "spaces/AAA",
|
||||
text: "caption",
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
channel: "googlechat",
|
||||
messageId: "spaces/AAA/messages/msg-1",
|
||||
chatId: "spaces/AAA",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps remote URL media fetch on fetchRemoteMedia with maxBytes cap", async () => {
|
||||
const { loadWebMedia, fetchRemoteMedia } = setupRuntimeMediaMocks({
|
||||
loadFileName: "unused.png",
|
||||
loadBytes: "should-not-be-used",
|
||||
});
|
||||
|
||||
uploadGoogleChatAttachmentMock.mockResolvedValue({
|
||||
attachmentUploadToken: "token-2",
|
||||
});
|
||||
sendGoogleChatMessageMock.mockResolvedValue({
|
||||
messageName: "spaces/AAA/messages/msg-2",
|
||||
});
|
||||
|
||||
const cfg = createGoogleChatCfg();
|
||||
|
||||
const result = await googlechatPlugin.outbound?.sendMedia?.({
|
||||
cfg,
|
||||
to: "spaces/AAA",
|
||||
text: "caption",
|
||||
mediaUrl: "https://example.com/image.png",
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(fetchRemoteMedia).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://example.com/image.png",
|
||||
maxBytes: 20 * 1024 * 1024,
|
||||
}),
|
||||
);
|
||||
expect(loadWebMedia).not.toHaveBeenCalled();
|
||||
expect(uploadGoogleChatAttachmentMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
space: "spaces/AAA",
|
||||
filename: "remote.png",
|
||||
contentType: "image/png",
|
||||
}),
|
||||
);
|
||||
expect(sendGoogleChatMessageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
space: "spaces/AAA",
|
||||
text: "caption",
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
channel: "googlechat",
|
||||
messageId: "spaces/AAA/messages/msg-2",
|
||||
chatId: "spaces/AAA",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const resolveTarget = googlechatPlugin.outbound?.resolveTarget;
|
||||
|
||||
describe("googlechatPlugin outbound resolveTarget", () => {
|
||||
it("resolves valid chat targets", () => {
|
||||
if (!resolveTarget) {
|
||||
throw new Error("Expected googlechatPlugin.outbound.resolveTarget to be defined");
|
||||
}
|
||||
|
||||
const result = resolveTarget({
|
||||
to: "spaces/AAA",
|
||||
mode: "explicit",
|
||||
allowFrom: [],
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
throw result.error;
|
||||
}
|
||||
expect(result.to).toBe("spaces/AAA");
|
||||
});
|
||||
|
||||
it("resolves email targets", () => {
|
||||
if (!resolveTarget) {
|
||||
throw new Error("Expected googlechatPlugin.outbound.resolveTarget to be defined");
|
||||
}
|
||||
|
||||
const result = resolveTarget({
|
||||
to: "user@example.com",
|
||||
mode: "explicit",
|
||||
allowFrom: [],
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
throw result.error;
|
||||
}
|
||||
expect(result.to).toBe("users/user@example.com");
|
||||
});
|
||||
|
||||
it("errors on invalid targets", () => {
|
||||
if (!resolveTarget) {
|
||||
throw new Error("Expected googlechatPlugin.outbound.resolveTarget to be defined");
|
||||
}
|
||||
|
||||
const result = resolveTarget({
|
||||
to: " ",
|
||||
mode: "explicit",
|
||||
allowFrom: [],
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
throw new Error("Expected invalid target to fail");
|
||||
}
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
|
||||
it("errors when no target is provided", () => {
|
||||
if (!resolveTarget) {
|
||||
throw new Error("Expected googlechatPlugin.outbound.resolveTarget to be defined");
|
||||
}
|
||||
|
||||
const result = resolveTarget({
|
||||
to: undefined,
|
||||
mode: "explicit",
|
||||
allowFrom: [],
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
throw new Error("Expected missing target to fail");
|
||||
}
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("googlechatPlugin outbound cfg threading", () => {
|
||||
it("threads resolved cfg into sendText account resolution", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
serviceAccount: {
|
||||
type: "service_account",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const account = {
|
||||
accountId: "default",
|
||||
config: {},
|
||||
credentialSource: "inline",
|
||||
};
|
||||
resolveGoogleChatAccountMock.mockReturnValue(account);
|
||||
resolveGoogleChatOutboundSpaceMock.mockResolvedValue("spaces/AAA");
|
||||
sendGoogleChatMessageMock.mockResolvedValue({
|
||||
messageName: "spaces/AAA/messages/msg-1",
|
||||
});
|
||||
|
||||
await googlechatPlugin.outbound?.sendText?.({
|
||||
cfg: cfg as never,
|
||||
to: "users/123",
|
||||
text: "hello",
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(resolveGoogleChatAccountMock).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
});
|
||||
expect(sendGoogleChatMessageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
account,
|
||||
space: "spaces/AAA",
|
||||
text: "hello",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("threads resolved cfg into sendMedia account and media loading path", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
serviceAccount: {
|
||||
type: "service_account",
|
||||
},
|
||||
mediaMaxMb: 8,
|
||||
},
|
||||
},
|
||||
};
|
||||
const account = {
|
||||
accountId: "default",
|
||||
config: { mediaMaxMb: 20 },
|
||||
credentialSource: "inline",
|
||||
};
|
||||
const { fetchRemoteMedia } = setupRuntimeMediaMocks({
|
||||
loadFileName: "unused.png",
|
||||
loadBytes: "should-not-be-used",
|
||||
});
|
||||
|
||||
resolveGoogleChatAccountMock.mockReturnValue(account);
|
||||
resolveGoogleChatOutboundSpaceMock.mockResolvedValue("spaces/AAA");
|
||||
uploadGoogleChatAttachmentMock.mockResolvedValue({
|
||||
attachmentUploadToken: "token-1",
|
||||
});
|
||||
sendGoogleChatMessageMock.mockResolvedValue({
|
||||
messageName: "spaces/AAA/messages/msg-2",
|
||||
});
|
||||
|
||||
await googlechatPlugin.outbound?.sendMedia?.({
|
||||
cfg: cfg as never,
|
||||
to: "users/123",
|
||||
text: "photo",
|
||||
mediaUrl: "https://example.com/file.png",
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(resolveGoogleChatAccountMock).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
});
|
||||
expect(fetchRemoteMedia).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "https://example.com/file.png",
|
||||
maxBytes: 8 * 1024 * 1024,
|
||||
}),
|
||||
);
|
||||
expect(uploadGoogleChatAttachmentMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
account,
|
||||
space: "spaces/AAA",
|
||||
filename: "remote.png",
|
||||
}),
|
||||
);
|
||||
expect(sendGoogleChatMessageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
account,
|
||||
attachments: [{ attachmentUploadToken: "token-1", contentName: "remote.png" }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("googlechat directory", () => {
|
||||
const runtimeEnv = createDirectoryTestRuntime() as never;
|
||||
|
||||
it("lists peers and groups from config", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
serviceAccount: { client_email: "bot@example.com" },
|
||||
dm: { allowFrom: ["users/alice", "googlechat:bob"] },
|
||||
groups: {
|
||||
"spaces/AAA": {},
|
||||
"spaces/BBB": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const directory = expectDirectorySurface(googlechatPlugin.directory);
|
||||
|
||||
await expect(
|
||||
directory.listPeers({
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
query: undefined,
|
||||
limit: undefined,
|
||||
runtime: runtimeEnv,
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: "user", id: "users/alice" },
|
||||
{ kind: "user", id: "bob" },
|
||||
]),
|
||||
);
|
||||
|
||||
await expect(
|
||||
directory.listGroups({
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
query: undefined,
|
||||
limit: undefined,
|
||||
runtime: runtimeEnv,
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: "group", id: "spaces/AAA" },
|
||||
{ kind: "group", id: "spaces/BBB" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes spaced provider-prefixed dm allowlist entries", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
serviceAccount: { client_email: "bot@example.com" },
|
||||
dm: { allowFrom: [" users/alice ", " googlechat:user:Bob@Example.com "] },
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const directory = expectDirectorySurface(googlechatPlugin.directory);
|
||||
|
||||
await expect(
|
||||
directory.listPeers({
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
query: undefined,
|
||||
limit: undefined,
|
||||
runtime: runtimeEnv,
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: "user", id: "users/alice" },
|
||||
{ kind: "user", id: "users/bob@example.com" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("googlechatPlugin security", () => {
|
||||
it("normalizes prefixed DM allowlist entries to lowercase user ids", () => {
|
||||
const security = googlechatPlugin.security;
|
||||
if (!security) {
|
||||
throw new Error("googlechat security unavailable");
|
||||
}
|
||||
const resolveDmPolicy = security.resolveDmPolicy;
|
||||
const normalizeAllowEntry = googlechatPlugin.pairing?.normalizeAllowEntry;
|
||||
expect(resolveDmPolicy).toBeTypeOf("function");
|
||||
expect(normalizeAllowEntry).toBeTypeOf("function");
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
serviceAccount: { client_email: "bot@example.com" },
|
||||
dm: {
|
||||
policy: "allowlist",
|
||||
allowFrom: [" googlechat:user:Bob@Example.com "],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const account = googlechatPlugin.config.resolveAccount(cfg, "default");
|
||||
const resolved = resolveDmPolicy!({ cfg, account });
|
||||
if (!resolved) {
|
||||
throw new Error("googlechat resolveDmPolicy returned null");
|
||||
}
|
||||
|
||||
expect(resolved.policy).toBe("allowlist");
|
||||
expect(resolved.allowFrom).toEqual([" googlechat:user:Bob@Example.com "]);
|
||||
expect(resolved.normalizeEntry?.(" googlechat:user:Bob@Example.com ")).toBe(
|
||||
"bob@example.com",
|
||||
);
|
||||
expect(normalizeAllowEntry!(" users/Alice@Example.com ")).toBe("alice@example.com");
|
||||
});
|
||||
});
|
||||
25
extensions/googlechat/src/group-policy.test.ts
Normal file
25
extensions/googlechat/src/group-policy.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveGoogleChatGroupRequireMention } from "./group-policy.js";
|
||||
|
||||
describe("googlechat group policy", () => {
|
||||
it("uses generic channel group policy helpers", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
groups: {
|
||||
"spaces/AAA": {
|
||||
requireMention: false,
|
||||
},
|
||||
"*": {
|
||||
requireMention: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
} as any;
|
||||
|
||||
expect(resolveGoogleChatGroupRequireMention({ cfg, groupId: "spaces/AAA" })).toBe(false);
|
||||
expect(resolveGoogleChatGroupRequireMention({ cfg, groupId: "spaces/BBB" })).toBe(true);
|
||||
});
|
||||
});
|
||||
25
extensions/googlechat/src/monitor.test.ts
Normal file
25
extensions/googlechat/src/monitor.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isSenderAllowed } from "./monitor.js";
|
||||
|
||||
describe("isSenderAllowed", () => {
|
||||
it("matches raw email entries only when dangerous name matching is enabled", () => {
|
||||
expect(isSenderAllowed("users/123", "Jane@Example.com", ["jane@example.com"])).toBe(false);
|
||||
expect(isSenderAllowed("users/123", "Jane@Example.com", ["jane@example.com"], true)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat users/<email> entries as email allowlist (deprecated form)", () => {
|
||||
expect(isSenderAllowed("users/123", "Jane@Example.com", ["users/jane@example.com"])).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("still matches user id entries", () => {
|
||||
expect(isSenderAllowed("users/abc", "jane@example.com", ["users/abc"])).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-matching raw email entries", () => {
|
||||
expect(isSenderAllowed("users/123", "jane@example.com", ["other@example.com"], true)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
235
extensions/googlechat/src/resolve-target.test.ts
Normal file
235
extensions/googlechat/src/resolve-target.test.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { installCommonResolveTargetErrorCases } from "openclaw/plugin-sdk/testing";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const runtimeMocks = vi.hoisted(() => ({
|
||||
chunkMarkdownText: vi.fn((text: string) => [text]),
|
||||
fetchRemoteMedia: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../runtime-api.js", () => ({
|
||||
getChatChannelMeta: () => ({ id: "googlechat", label: "Google Chat" }),
|
||||
missingTargetError: (provider: string, hint: string) =>
|
||||
new Error(`Delivering to ${provider} requires target ${hint}`),
|
||||
GoogleChatConfigSchema: {},
|
||||
DEFAULT_ACCOUNT_ID: "default",
|
||||
PAIRING_APPROVED_MESSAGE: "Approved",
|
||||
applyAccountNameToChannelSection: vi.fn(),
|
||||
buildChannelConfigSchema: vi.fn(),
|
||||
deleteAccountFromConfigSection: vi.fn(),
|
||||
formatPairingApproveHint: vi.fn(),
|
||||
migrateBaseNameToDefaultAccount: vi.fn(),
|
||||
normalizeAccountId: vi.fn(),
|
||||
resolveChannelMediaMaxBytes: vi.fn(),
|
||||
resolveGoogleChatGroupRequireMention: vi.fn(),
|
||||
setAccountEnabledInConfigSection: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
listGoogleChatAccountIds: vi.fn(),
|
||||
resolveDefaultGoogleChatAccountId: vi.fn(),
|
||||
resolveGoogleChatAccount: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./actions.js", () => ({
|
||||
googlechatMessageActions: [],
|
||||
}));
|
||||
|
||||
vi.mock("./api.js", () => ({
|
||||
sendGoogleChatMessage: vi.fn(),
|
||||
uploadGoogleChatAttachment: vi.fn(),
|
||||
probeGoogleChat: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./monitor.js", () => ({
|
||||
resolveGoogleChatWebhookPath: vi.fn(),
|
||||
startGoogleChatMonitor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./setup-core.js", () => ({
|
||||
googlechatSetupAdapter: {},
|
||||
}));
|
||||
|
||||
vi.mock("./setup-surface.js", () => ({
|
||||
googlechatSetupWizard: {},
|
||||
}));
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
getGoogleChatRuntime: vi.fn(() => ({
|
||||
channel: {
|
||||
text: { chunkMarkdownText: runtimeMocks.chunkMarkdownText },
|
||||
media: { fetchRemoteMedia: runtimeMocks.fetchRemoteMedia },
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("./targets.js", () => ({
|
||||
normalizeGoogleChatTarget: (raw?: string | null) => {
|
||||
if (!raw?.trim()) return undefined;
|
||||
if (raw === "invalid-target") return undefined;
|
||||
const trimmed = raw.trim().replace(/^(googlechat|google-chat|gchat):/i, "");
|
||||
if (trimmed.startsWith("spaces/")) return trimmed;
|
||||
if (trimmed.includes("@")) return `users/${trimmed.toLowerCase()}`;
|
||||
return `users/${trimmed}`;
|
||||
},
|
||||
isGoogleChatUserTarget: (value: string) => value.startsWith("users/"),
|
||||
isGoogleChatSpaceTarget: (value: string) => value.startsWith("spaces/"),
|
||||
resolveGoogleChatOutboundSpace: vi.fn(),
|
||||
}));
|
||||
|
||||
import { resolveChannelMediaMaxBytes } from "../runtime-api.js";
|
||||
import { resolveGoogleChatAccount } from "./accounts.js";
|
||||
import { sendGoogleChatMessage, uploadGoogleChatAttachment } from "./api.js";
|
||||
import { googlechatPlugin } from "./channel.js";
|
||||
import { resolveGoogleChatOutboundSpace } from "./targets.js";
|
||||
|
||||
const resolveTarget = googlechatPlugin.outbound!.resolveTarget!;
|
||||
|
||||
describe("googlechat resolveTarget", () => {
|
||||
it("should resolve valid target", () => {
|
||||
const result = resolveTarget({
|
||||
to: "spaces/AAA",
|
||||
mode: "explicit",
|
||||
allowFrom: [],
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
throw result.error;
|
||||
}
|
||||
expect(result.to).toBe("spaces/AAA");
|
||||
});
|
||||
|
||||
it("should resolve email target", () => {
|
||||
const result = resolveTarget({
|
||||
to: "user@example.com",
|
||||
mode: "explicit",
|
||||
allowFrom: [],
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
throw result.error;
|
||||
}
|
||||
expect(result.to).toBe("users/user@example.com");
|
||||
});
|
||||
|
||||
installCommonResolveTargetErrorCases({
|
||||
resolveTarget,
|
||||
implicitAllowFrom: ["spaces/BBB"],
|
||||
});
|
||||
});
|
||||
|
||||
describe("googlechat outbound cfg threading", () => {
|
||||
beforeEach(() => {
|
||||
runtimeMocks.fetchRemoteMedia.mockReset();
|
||||
runtimeMocks.chunkMarkdownText.mockClear();
|
||||
vi.mocked(resolveGoogleChatAccount).mockReset();
|
||||
vi.mocked(resolveGoogleChatOutboundSpace).mockReset();
|
||||
vi.mocked(resolveChannelMediaMaxBytes).mockReset();
|
||||
vi.mocked(uploadGoogleChatAttachment).mockReset();
|
||||
vi.mocked(sendGoogleChatMessage).mockReset();
|
||||
});
|
||||
|
||||
it("threads resolved cfg into sendText account resolution", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
serviceAccount: {
|
||||
type: "service_account",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const account = {
|
||||
accountId: "default",
|
||||
config: {},
|
||||
credentialSource: "inline",
|
||||
};
|
||||
vi.mocked(resolveGoogleChatAccount).mockReturnValue(account as any);
|
||||
vi.mocked(resolveGoogleChatOutboundSpace).mockResolvedValue("spaces/AAA");
|
||||
vi.mocked(sendGoogleChatMessage).mockResolvedValue({
|
||||
messageName: "spaces/AAA/messages/msg-1",
|
||||
} as any);
|
||||
|
||||
await googlechatPlugin.outbound!.sendText!({
|
||||
cfg: cfg as any,
|
||||
to: "users/123",
|
||||
text: "hello",
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(resolveGoogleChatAccount).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
});
|
||||
expect(sendGoogleChatMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
account,
|
||||
space: "spaces/AAA",
|
||||
text: "hello",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("threads resolved cfg into sendMedia account and media loading path", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
serviceAccount: {
|
||||
type: "service_account",
|
||||
},
|
||||
mediaMaxMb: 8,
|
||||
},
|
||||
},
|
||||
};
|
||||
const account = {
|
||||
accountId: "default",
|
||||
config: { mediaMaxMb: 20 },
|
||||
credentialSource: "inline",
|
||||
};
|
||||
vi.mocked(resolveGoogleChatAccount).mockReturnValue(account as any);
|
||||
vi.mocked(resolveGoogleChatOutboundSpace).mockResolvedValue("spaces/AAA");
|
||||
vi.mocked(resolveChannelMediaMaxBytes).mockReturnValue(1024);
|
||||
runtimeMocks.fetchRemoteMedia.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("file"),
|
||||
fileName: "file.png",
|
||||
contentType: "image/png",
|
||||
});
|
||||
vi.mocked(uploadGoogleChatAttachment).mockResolvedValue({
|
||||
attachmentUploadToken: "token-1",
|
||||
} as any);
|
||||
vi.mocked(sendGoogleChatMessage).mockResolvedValue({
|
||||
messageName: "spaces/AAA/messages/msg-2",
|
||||
} as any);
|
||||
|
||||
await googlechatPlugin.outbound!.sendMedia!({
|
||||
cfg: cfg as any,
|
||||
to: "users/123",
|
||||
text: "photo",
|
||||
mediaUrl: "https://example.com/file.png",
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(resolveGoogleChatAccount).toHaveBeenCalledWith({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
});
|
||||
expect(runtimeMocks.fetchRemoteMedia).toHaveBeenCalledWith({
|
||||
url: "https://example.com/file.png",
|
||||
maxBytes: 1024,
|
||||
});
|
||||
expect(uploadGoogleChatAttachment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
account,
|
||||
space: "spaces/AAA",
|
||||
filename: "file.png",
|
||||
}),
|
||||
);
|
||||
expect(sendGoogleChatMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
account,
|
||||
attachments: [{ attachmentUploadToken: "token-1", contentName: "file.png" }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
85
extensions/googlechat/src/setup-core.test.ts
Normal file
85
extensions/googlechat/src/setup-core.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { googlechatSetupAdapter } from "./setup-core.js";
|
||||
|
||||
describe("googlechat setup core", () => {
|
||||
it("rejects env auth for non-default accounts", () => {
|
||||
if (!googlechatSetupAdapter.validateInput) {
|
||||
throw new Error("Expected googlechatSetupAdapter.validateInput to be defined");
|
||||
}
|
||||
expect(
|
||||
googlechatSetupAdapter.validateInput({
|
||||
accountId: "secondary",
|
||||
input: { useEnv: true },
|
||||
} as never),
|
||||
).toBe("GOOGLE_CHAT_SERVICE_ACCOUNT env vars can only be used for the default account.");
|
||||
});
|
||||
|
||||
it("requires inline or file credentials when env auth is not used", () => {
|
||||
if (!googlechatSetupAdapter.validateInput) {
|
||||
throw new Error("Expected googlechatSetupAdapter.validateInput to be defined");
|
||||
}
|
||||
expect(
|
||||
googlechatSetupAdapter.validateInput({
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
input: { useEnv: false, token: "", tokenFile: "" },
|
||||
} as never),
|
||||
).toBe("Google Chat requires --token (service account JSON) or --token-file.");
|
||||
});
|
||||
|
||||
it("builds a patch from token-file and trims optional webhook fields", () => {
|
||||
if (!googlechatSetupAdapter.applyAccountConfig) {
|
||||
throw new Error("Expected googlechatSetupAdapter.applyAccountConfig to be defined");
|
||||
}
|
||||
expect(
|
||||
googlechatSetupAdapter.applyAccountConfig({
|
||||
cfg: { channels: { googlechat: {} } },
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
input: {
|
||||
name: "Default",
|
||||
tokenFile: "/tmp/googlechat.json",
|
||||
audienceType: " app-url ",
|
||||
audience: " https://example.com/googlechat ",
|
||||
webhookPath: " /googlechat ",
|
||||
webhookUrl: " https://example.com/googlechat/hook ",
|
||||
},
|
||||
} as never),
|
||||
).toEqual({
|
||||
channels: {
|
||||
googlechat: {
|
||||
enabled: true,
|
||||
name: "Default",
|
||||
serviceAccountFile: "/tmp/googlechat.json",
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
webhookPath: "/googlechat",
|
||||
webhookUrl: "https://example.com/googlechat/hook",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers inline token patch when token-file is absent", () => {
|
||||
if (!googlechatSetupAdapter.applyAccountConfig) {
|
||||
throw new Error("Expected googlechatSetupAdapter.applyAccountConfig to be defined");
|
||||
}
|
||||
expect(
|
||||
googlechatSetupAdapter.applyAccountConfig({
|
||||
cfg: { channels: { googlechat: {} } },
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
input: {
|
||||
name: "Default",
|
||||
token: { client_email: "bot@example.com" },
|
||||
},
|
||||
} as never),
|
||||
).toEqual({
|
||||
channels: {
|
||||
googlechat: {
|
||||
enabled: true,
|
||||
name: "Default",
|
||||
serviceAccount: { client_email: "bot@example.com" },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
42
extensions/googlechat/src/setup-surface.test.ts
Normal file
42
extensions/googlechat/src/setup-surface.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createPluginSetupWizardConfigure,
|
||||
createTestWizardPrompter,
|
||||
runSetupWizardConfigure,
|
||||
type WizardPrompter,
|
||||
} from "../../../test/helpers/extensions/setup-wizard.js";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import { googlechatPlugin } from "./channel.js";
|
||||
|
||||
const googlechatConfigure = createPluginSetupWizardConfigure(googlechatPlugin);
|
||||
|
||||
describe("googlechat setup wizard", () => {
|
||||
it("configures service-account auth and webhook audience", async () => {
|
||||
const prompter = createTestWizardPrompter({
|
||||
text: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Service account JSON path") {
|
||||
return "/tmp/googlechat-service-account.json";
|
||||
}
|
||||
if (message === "App URL") {
|
||||
return "https://example.com/googlechat";
|
||||
}
|
||||
throw new Error(`Unexpected prompt: ${message}`);
|
||||
}) as WizardPrompter["text"],
|
||||
});
|
||||
|
||||
const result = await runSetupWizardConfigure({
|
||||
configure: googlechatConfigure,
|
||||
cfg: {} as OpenClawConfig,
|
||||
prompter,
|
||||
options: {},
|
||||
});
|
||||
|
||||
expect(result.accountId).toBe("default");
|
||||
expect(result.cfg.channels?.googlechat?.enabled).toBe(true);
|
||||
expect(result.cfg.channels?.googlechat?.serviceAccountFile).toBe(
|
||||
"/tmp/googlechat-service-account.json",
|
||||
);
|
||||
expect(result.cfg.channels?.googlechat?.audienceType).toBe("app-url");
|
||||
expect(result.cfg.channels?.googlechat?.audience).toBe("https://example.com/googlechat");
|
||||
});
|
||||
});
|
||||
@@ -1,314 +0,0 @@
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createPluginSetupWizardConfigure,
|
||||
createTestWizardPrompter,
|
||||
runSetupWizardConfigure,
|
||||
type WizardPrompter,
|
||||
} from "../../../test/helpers/extensions/setup-wizard.js";
|
||||
import {
|
||||
expectLifecyclePatch,
|
||||
expectPendingUntilAbort,
|
||||
startAccountAndTrackLifecycle,
|
||||
waitForStartedMocks,
|
||||
} from "../../../test/helpers/extensions/start-account-lifecycle.js";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import { resolveGoogleChatAccount, type ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import { googlechatPlugin } from "./channel.js";
|
||||
import { googlechatSetupAdapter } from "./setup-core.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
startGoogleChatMonitor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./monitor.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./monitor.js")>("./monitor.js");
|
||||
return {
|
||||
...actual,
|
||||
startGoogleChatMonitor: hoisted.startGoogleChatMonitor,
|
||||
};
|
||||
});
|
||||
|
||||
const googlechatConfigure = createPluginSetupWizardConfigure(googlechatPlugin);
|
||||
|
||||
function buildAccount(): ResolvedGoogleChatAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
credentialSource: "inline",
|
||||
credentials: {},
|
||||
config: {
|
||||
webhookPath: "/googlechat",
|
||||
webhookUrl: "https://example.com/googlechat",
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("googlechat setup", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("rejects env auth for non-default accounts", () => {
|
||||
if (!googlechatSetupAdapter.validateInput) {
|
||||
throw new Error("Expected googlechatSetupAdapter.validateInput to be defined");
|
||||
}
|
||||
expect(
|
||||
googlechatSetupAdapter.validateInput({
|
||||
accountId: "secondary",
|
||||
input: { useEnv: true },
|
||||
} as never),
|
||||
).toBe("GOOGLE_CHAT_SERVICE_ACCOUNT env vars can only be used for the default account.");
|
||||
});
|
||||
|
||||
it("requires inline or file credentials when env auth is not used", () => {
|
||||
if (!googlechatSetupAdapter.validateInput) {
|
||||
throw new Error("Expected googlechatSetupAdapter.validateInput to be defined");
|
||||
}
|
||||
expect(
|
||||
googlechatSetupAdapter.validateInput({
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
input: { useEnv: false, token: "", tokenFile: "" },
|
||||
} as never),
|
||||
).toBe("Google Chat requires --token (service account JSON) or --token-file.");
|
||||
});
|
||||
|
||||
it("builds a patch from token-file and trims optional webhook fields", () => {
|
||||
if (!googlechatSetupAdapter.applyAccountConfig) {
|
||||
throw new Error("Expected googlechatSetupAdapter.applyAccountConfig to be defined");
|
||||
}
|
||||
expect(
|
||||
googlechatSetupAdapter.applyAccountConfig({
|
||||
cfg: { channels: { googlechat: {} } },
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
input: {
|
||||
name: "Default",
|
||||
tokenFile: "/tmp/googlechat.json",
|
||||
audienceType: " app-url ",
|
||||
audience: " https://example.com/googlechat ",
|
||||
webhookPath: " /googlechat ",
|
||||
webhookUrl: " https://example.com/googlechat/hook ",
|
||||
},
|
||||
} as never),
|
||||
).toEqual({
|
||||
channels: {
|
||||
googlechat: {
|
||||
enabled: true,
|
||||
name: "Default",
|
||||
serviceAccountFile: "/tmp/googlechat.json",
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
webhookPath: "/googlechat",
|
||||
webhookUrl: "https://example.com/googlechat/hook",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers inline token patch when token-file is absent", () => {
|
||||
if (!googlechatSetupAdapter.applyAccountConfig) {
|
||||
throw new Error("Expected googlechatSetupAdapter.applyAccountConfig to be defined");
|
||||
}
|
||||
expect(
|
||||
googlechatSetupAdapter.applyAccountConfig({
|
||||
cfg: { channels: { googlechat: {} } },
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
input: {
|
||||
name: "Default",
|
||||
token: { client_email: "bot@example.com" },
|
||||
},
|
||||
} as never),
|
||||
).toEqual({
|
||||
channels: {
|
||||
googlechat: {
|
||||
enabled: true,
|
||||
name: "Default",
|
||||
serviceAccount: { client_email: "bot@example.com" },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("configures service-account auth and webhook audience", async () => {
|
||||
const prompter = createTestWizardPrompter({
|
||||
text: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Service account JSON path") {
|
||||
return "/tmp/googlechat-service-account.json";
|
||||
}
|
||||
if (message === "App URL") {
|
||||
return "https://example.com/googlechat";
|
||||
}
|
||||
throw new Error(`Unexpected prompt: ${message}`);
|
||||
}) as WizardPrompter["text"],
|
||||
});
|
||||
|
||||
const result = await runSetupWizardConfigure({
|
||||
configure: googlechatConfigure,
|
||||
cfg: {} as OpenClawConfig,
|
||||
prompter,
|
||||
options: {},
|
||||
});
|
||||
|
||||
expect(result.accountId).toBe("default");
|
||||
expect(result.cfg.channels?.googlechat?.enabled).toBe(true);
|
||||
expect(result.cfg.channels?.googlechat?.serviceAccountFile).toBe(
|
||||
"/tmp/googlechat-service-account.json",
|
||||
);
|
||||
expect(result.cfg.channels?.googlechat?.audienceType).toBe("app-url");
|
||||
expect(result.cfg.channels?.googlechat?.audience).toBe("https://example.com/googlechat");
|
||||
});
|
||||
|
||||
it("keeps startAccount pending until abort, then unregisters", async () => {
|
||||
const unregister = vi.fn();
|
||||
hoisted.startGoogleChatMonitor.mockResolvedValue(unregister);
|
||||
|
||||
const { abort, patches, task, isSettled } = startAccountAndTrackLifecycle({
|
||||
startAccount: googlechatPlugin.gateway!.startAccount!,
|
||||
account: buildAccount(),
|
||||
});
|
||||
await expectPendingUntilAbort({
|
||||
waitForStarted: waitForStartedMocks(hoisted.startGoogleChatMonitor),
|
||||
isSettled,
|
||||
abort,
|
||||
task,
|
||||
assertBeforeAbort: () => {
|
||||
expect(unregister).not.toHaveBeenCalled();
|
||||
},
|
||||
assertAfterAbort: () => {
|
||||
expect(unregister).toHaveBeenCalledOnce();
|
||||
},
|
||||
});
|
||||
expectLifecyclePatch(patches, { running: true });
|
||||
expectLifecyclePatch(patches, { running: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveGoogleChatAccount", () => {
|
||||
it("inherits shared defaults from accounts.default for named accounts", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
accounts: {
|
||||
default: {
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
webhookPath: "/googlechat",
|
||||
},
|
||||
andy: {
|
||||
serviceAccountFile: "/tmp/andy-sa.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveGoogleChatAccount({ cfg, accountId: "andy" });
|
||||
expect(resolved.config.audienceType).toBe("app-url");
|
||||
expect(resolved.config.audience).toBe("https://example.com/googlechat");
|
||||
expect(resolved.config.webhookPath).toBe("/googlechat");
|
||||
expect(resolved.config.serviceAccountFile).toBe("/tmp/andy-sa.json");
|
||||
});
|
||||
|
||||
it("prefers top-level and account overrides over accounts.default", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
audienceType: "project-number",
|
||||
audience: "1234567890",
|
||||
accounts: {
|
||||
default: {
|
||||
audienceType: "app-url",
|
||||
audience: "https://default.example.com/googlechat",
|
||||
webhookPath: "/googlechat-default",
|
||||
},
|
||||
april: {
|
||||
webhookPath: "/googlechat-april",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveGoogleChatAccount({ cfg, accountId: "april" });
|
||||
expect(resolved.config.audienceType).toBe("project-number");
|
||||
expect(resolved.config.audience).toBe("1234567890");
|
||||
expect(resolved.config.webhookPath).toBe("/googlechat-april");
|
||||
});
|
||||
|
||||
it("does not inherit disabled state from accounts.default for named accounts", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
accounts: {
|
||||
default: {
|
||||
enabled: false,
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
},
|
||||
andy: {
|
||||
serviceAccountFile: "/tmp/andy-sa.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveGoogleChatAccount({ cfg, accountId: "andy" });
|
||||
expect(resolved.enabled).toBe(true);
|
||||
expect(resolved.config.enabled).toBeUndefined();
|
||||
expect(resolved.config.audienceType).toBe("app-url");
|
||||
});
|
||||
|
||||
it("does not inherit default-account credentials into named accounts", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
accounts: {
|
||||
default: {
|
||||
serviceAccountRef: {
|
||||
source: "env",
|
||||
provider: "test",
|
||||
id: "default-sa",
|
||||
},
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
},
|
||||
andy: {
|
||||
serviceAccountFile: "/tmp/andy-sa.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveGoogleChatAccount({ cfg, accountId: "andy" });
|
||||
expect(resolved.credentialSource).toBe("file");
|
||||
expect(resolved.credentialsFile).toBe("/tmp/andy-sa.json");
|
||||
expect(resolved.config.audienceType).toBe("app-url");
|
||||
});
|
||||
|
||||
it("does not inherit dangerous name matching from accounts.default", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
accounts: {
|
||||
default: {
|
||||
dangerouslyAllowNameMatching: true,
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
},
|
||||
andy: {
|
||||
serviceAccountFile: "/tmp/andy-sa.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveGoogleChatAccount({ cfg, accountId: "andy" });
|
||||
expect(resolved.config.dangerouslyAllowNameMatching).toBeUndefined();
|
||||
expect(resolved.config.audienceType).toBe("app-url");
|
||||
});
|
||||
});
|
||||
@@ -1,58 +1,10 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
import { downloadGoogleChatMedia, sendGoogleChatMessage } from "./api.js";
|
||||
import { resolveGoogleChatGroupRequireMention } from "./group-policy.js";
|
||||
import { isSenderAllowed } from "./monitor.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isGoogleChatSpaceTarget,
|
||||
isGoogleChatUserTarget,
|
||||
normalizeGoogleChatTarget,
|
||||
} from "./targets.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
verifyIdToken: vi.fn(),
|
||||
getGoogleChatAccessToken: vi.fn().mockResolvedValue("token"),
|
||||
}));
|
||||
|
||||
vi.mock("google-auth-library", () => ({
|
||||
GoogleAuth: class {},
|
||||
OAuth2Client: class {
|
||||
verifyIdToken = mocks.verifyIdToken;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./auth.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./auth.js")>();
|
||||
return {
|
||||
...actual,
|
||||
getGoogleChatAccessToken: mocks.getGoogleChatAccessToken,
|
||||
};
|
||||
});
|
||||
|
||||
const { verifyGoogleChatRequest } = await import("./auth.js");
|
||||
|
||||
const account = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
credentialSource: "inline",
|
||||
config: {},
|
||||
} as ResolvedGoogleChatAccount;
|
||||
|
||||
function stubSuccessfulSend(name: string) {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Response(JSON.stringify({ name }), { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
async function expectDownloadToRejectForResponse(response: Response) {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
|
||||
await expect(
|
||||
downloadGoogleChatMedia({ account, resourceName: "media/123", maxBytes: 10 }),
|
||||
).rejects.toThrow(/max bytes/i);
|
||||
}
|
||||
|
||||
describe("normalizeGoogleChatTarget", () => {
|
||||
it("normalizes provider prefixes", () => {
|
||||
expect(normalizeGoogleChatTarget("googlechat:users/123")).toBe("users/123");
|
||||
@@ -78,208 +30,3 @@ describe("target helpers", () => {
|
||||
expect(isGoogleChatUserTarget("spaces/abc")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("googlechat group policy", () => {
|
||||
it("uses generic channel group policy helpers", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
groups: {
|
||||
"spaces/AAA": {
|
||||
requireMention: false,
|
||||
},
|
||||
"*": {
|
||||
requireMention: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
} as any;
|
||||
|
||||
expect(resolveGoogleChatGroupRequireMention({ cfg, groupId: "spaces/AAA" })).toBe(false);
|
||||
expect(resolveGoogleChatGroupRequireMention({ cfg, groupId: "spaces/BBB" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSenderAllowed", () => {
|
||||
it("matches raw email entries only when dangerous name matching is enabled", () => {
|
||||
expect(isSenderAllowed("users/123", "Jane@Example.com", ["jane@example.com"])).toBe(false);
|
||||
expect(isSenderAllowed("users/123", "Jane@Example.com", ["jane@example.com"], true)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat users/<email> entries as email allowlist (deprecated form)", () => {
|
||||
expect(isSenderAllowed("users/123", "Jane@Example.com", ["users/jane@example.com"])).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("still matches user id entries", () => {
|
||||
expect(isSenderAllowed("users/abc", "jane@example.com", ["users/abc"])).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-matching raw email entries", () => {
|
||||
expect(isSenderAllowed("users/123", "jane@example.com", ["other@example.com"], true)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadGoogleChatMedia", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("rejects when content-length exceeds max bytes", async () => {
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array([1, 2, 3]));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const response = new Response(body, {
|
||||
status: 200,
|
||||
headers: { "content-length": "50", "content-type": "application/octet-stream" },
|
||||
});
|
||||
await expectDownloadToRejectForResponse(response);
|
||||
});
|
||||
|
||||
it("rejects when streamed payload exceeds max bytes", async () => {
|
||||
const chunks = [new Uint8Array(6), new Uint8Array(6)];
|
||||
let index = 0;
|
||||
const body = new ReadableStream({
|
||||
pull(controller) {
|
||||
if (index < chunks.length) {
|
||||
controller.enqueue(chunks[index++]);
|
||||
} else {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
const response = new Response(body, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/octet-stream" },
|
||||
});
|
||||
await expectDownloadToRejectForResponse(response);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendGoogleChatMessage", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("adds messageReplyOption when sending to an existing thread", async () => {
|
||||
const fetchMock = stubSuccessfulSend("spaces/AAA/messages/123");
|
||||
|
||||
await sendGoogleChatMessage({
|
||||
account,
|
||||
space: "spaces/AAA",
|
||||
text: "hello",
|
||||
thread: "spaces/AAA/threads/xyz",
|
||||
});
|
||||
|
||||
const [url, init] = fetchMock.mock.calls[0] ?? [];
|
||||
expect(String(url)).toContain("messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD");
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
||||
text: "hello",
|
||||
thread: { name: "spaces/AAA/threads/xyz" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not set messageReplyOption for non-thread sends", async () => {
|
||||
const fetchMock = stubSuccessfulSend("spaces/AAA/messages/124");
|
||||
|
||||
await sendGoogleChatMessage({
|
||||
account,
|
||||
space: "spaces/AAA",
|
||||
text: "hello",
|
||||
});
|
||||
|
||||
const [url] = fetchMock.mock.calls[0] ?? [];
|
||||
expect(String(url)).not.toContain("messageReplyOption=");
|
||||
});
|
||||
});
|
||||
|
||||
function mockTicket(payload: Record<string, unknown>) {
|
||||
mocks.verifyIdToken.mockResolvedValue({
|
||||
getPayload: () => payload,
|
||||
});
|
||||
}
|
||||
|
||||
describe("verifyGoogleChatRequest", () => {
|
||||
it("accepts Google Chat app-url tokens from the Chat issuer", async () => {
|
||||
mocks.verifyIdToken.mockReset();
|
||||
mockTicket({
|
||||
email: "chat@system.gserviceaccount.com",
|
||||
email_verified: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyGoogleChatRequest({
|
||||
bearer: "token",
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("rejects add-on tokens when no principal binding is configured", async () => {
|
||||
mocks.verifyIdToken.mockReset();
|
||||
mockTicket({
|
||||
email: "service-123@gcp-sa-gsuiteaddons.iam.gserviceaccount.com",
|
||||
email_verified: true,
|
||||
sub: "principal-1",
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyGoogleChatRequest({
|
||||
bearer: "token",
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "missing add-on principal binding",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts add-on tokens only when the bound principal matches", async () => {
|
||||
mocks.verifyIdToken.mockReset();
|
||||
mockTicket({
|
||||
email: "service-123@gcp-sa-gsuiteaddons.iam.gserviceaccount.com",
|
||||
email_verified: true,
|
||||
sub: "principal-1",
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyGoogleChatRequest({
|
||||
bearer: "token",
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
expectedAddOnPrincipal: "principal-1",
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("rejects add-on tokens when the bound principal does not match", async () => {
|
||||
mocks.verifyIdToken.mockReset();
|
||||
mockTicket({
|
||||
email: "service-123@gcp-sa-gsuiteaddons.iam.gserviceaccount.com",
|
||||
email_verified: true,
|
||||
sub: "principal-2",
|
||||
});
|
||||
|
||||
await expect(
|
||||
verifyGoogleChatRequest({
|
||||
bearer: "token",
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
expectedAddOnPrincipal: "principal-1",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "unexpected add-on principal: principal-2",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ResolvedIMessageAccount } from "./accounts.js";
|
||||
import { imessagePlugin } from "./channel.js";
|
||||
import type { IMessageRpcClient } from "./client.js";
|
||||
import { imessageOutbound } from "./outbound-adapter.js";
|
||||
import { sendMessageIMessage } from "./send.js";
|
||||
|
||||
function requireIMessageSendText() {
|
||||
const sendText = imessagePlugin.outbound?.sendText;
|
||||
@@ -21,40 +17,6 @@ function requireIMessageSendMedia() {
|
||||
return sendMedia;
|
||||
}
|
||||
|
||||
const requestMock = vi.fn();
|
||||
const stopMock = vi.fn();
|
||||
|
||||
const defaultAccount: ResolvedIMessageAccount = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: false,
|
||||
config: {},
|
||||
};
|
||||
|
||||
function createClient(): IMessageRpcClient {
|
||||
return {
|
||||
request: (...args: unknown[]) => requestMock(...args),
|
||||
stop: (...args: unknown[]) => stopMock(...args),
|
||||
} as unknown as IMessageRpcClient;
|
||||
}
|
||||
|
||||
async function sendWithDefaults(
|
||||
to: string,
|
||||
text: string,
|
||||
opts: Parameters<typeof sendMessageIMessage>[2] = {},
|
||||
) {
|
||||
return await sendMessageIMessage(to, text, {
|
||||
account: defaultAccount,
|
||||
config: {},
|
||||
client: createClient(),
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
function getSentParams() {
|
||||
return requestMock.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("imessagePlugin outbound", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
@@ -144,188 +106,3 @@ describe("imessagePlugin outbound", () => {
|
||||
expect(result).toEqual({ channel: "imessage", messageId: "m-media-local" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("imessageOutbound", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
imessage: {
|
||||
mediaMaxMb: 3,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("forwards replyToId on direct text sends", async () => {
|
||||
const sendIMessage = vi.fn().mockResolvedValueOnce({ messageId: "m-text" });
|
||||
|
||||
const result = await imessageOutbound.sendText!({
|
||||
cfg,
|
||||
to: "chat_id:12",
|
||||
text: "hello",
|
||||
accountId: "default",
|
||||
replyToId: "reply-1",
|
||||
deps: { sendIMessage },
|
||||
});
|
||||
|
||||
expect(sendIMessage).toHaveBeenCalledWith(
|
||||
"chat_id:12",
|
||||
"hello",
|
||||
expect.objectContaining({
|
||||
accountId: "default",
|
||||
replyToId: "reply-1",
|
||||
maxBytes: 3 * 1024 * 1024,
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ channel: "imessage", messageId: "m-text" });
|
||||
});
|
||||
|
||||
it("forwards mediaLocalRoots on direct media sends", async () => {
|
||||
const sendIMessage = vi.fn().mockResolvedValueOnce({ messageId: "m-media-local" });
|
||||
|
||||
const result = await imessageOutbound.sendMedia!({
|
||||
cfg,
|
||||
to: "chat_id:88",
|
||||
text: "caption",
|
||||
mediaUrl: "/tmp/workspace/pic.png",
|
||||
mediaLocalRoots: ["/tmp/workspace"],
|
||||
accountId: "acct-1",
|
||||
replyToId: "reply-2",
|
||||
deps: { sendIMessage },
|
||||
});
|
||||
|
||||
expect(sendIMessage).toHaveBeenCalledWith(
|
||||
"chat_id:88",
|
||||
"caption",
|
||||
expect.objectContaining({
|
||||
mediaUrl: "/tmp/workspace/pic.png",
|
||||
mediaLocalRoots: ["/tmp/workspace"],
|
||||
accountId: "acct-1",
|
||||
replyToId: "reply-2",
|
||||
maxBytes: 3 * 1024 * 1024,
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ channel: "imessage", messageId: "m-media-local" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendMessageIMessage", () => {
|
||||
it("sends to chat_id targets", async () => {
|
||||
requestMock.mockClear().mockResolvedValue({ ok: true });
|
||||
stopMock.mockClear().mockResolvedValue(undefined);
|
||||
|
||||
await sendWithDefaults("chat_id:123", "hi");
|
||||
const params = getSentParams();
|
||||
expect(requestMock).toHaveBeenCalledWith("send", expect.any(Object), expect.any(Object));
|
||||
expect(params.chat_id).toBe(123);
|
||||
expect(params.text).toBe("hi");
|
||||
});
|
||||
|
||||
it("applies sms service prefix", async () => {
|
||||
requestMock.mockClear().mockResolvedValue({ ok: true });
|
||||
stopMock.mockClear().mockResolvedValue(undefined);
|
||||
|
||||
await sendWithDefaults("sms:+1555", "hello");
|
||||
const params = getSentParams();
|
||||
expect(params.service).toBe("sms");
|
||||
expect(params.to).toBe("+1555");
|
||||
});
|
||||
|
||||
it("adds file attachment with placeholder text", async () => {
|
||||
requestMock.mockClear().mockResolvedValue({ ok: true });
|
||||
stopMock.mockClear().mockResolvedValue(undefined);
|
||||
|
||||
await sendWithDefaults("chat_id:7", "", {
|
||||
mediaUrl: "http://x/y.jpg",
|
||||
resolveAttachmentImpl: async () => ({
|
||||
path: "/tmp/imessage-media.jpg",
|
||||
contentType: "image/jpeg",
|
||||
}),
|
||||
});
|
||||
const params = getSentParams();
|
||||
expect(params.file).toBe("/tmp/imessage-media.jpg");
|
||||
expect(params.text).toBe("<media:image>");
|
||||
});
|
||||
|
||||
it("normalizes mixed-case parameterized MIME for attachment placeholder text", async () => {
|
||||
requestMock.mockClear().mockResolvedValue({ ok: true });
|
||||
stopMock.mockClear().mockResolvedValue(undefined);
|
||||
|
||||
await sendWithDefaults("chat_id:7", "", {
|
||||
mediaUrl: "http://x/voice",
|
||||
resolveAttachmentImpl: async () => ({
|
||||
path: "/tmp/imessage-media.ogg",
|
||||
contentType: " Audio/Ogg; codecs=opus ",
|
||||
}),
|
||||
});
|
||||
const params = getSentParams();
|
||||
expect(params.file).toBe("/tmp/imessage-media.ogg");
|
||||
expect(params.text).toBe("<media:audio>");
|
||||
});
|
||||
|
||||
it("returns message id when rpc provides one", async () => {
|
||||
requestMock.mockClear().mockResolvedValue({ ok: true, id: 123 });
|
||||
stopMock.mockClear().mockResolvedValue(undefined);
|
||||
|
||||
const result = await sendWithDefaults("chat_id:7", "hello");
|
||||
expect(result.messageId).toBe("123");
|
||||
});
|
||||
|
||||
it("prepends reply tag as the first token when replyToId is provided", async () => {
|
||||
requestMock.mockClear().mockResolvedValue({ ok: true });
|
||||
stopMock.mockClear().mockResolvedValue(undefined);
|
||||
|
||||
await sendWithDefaults("chat_id:123", " hello\nworld", {
|
||||
replyToId: "abc-123",
|
||||
});
|
||||
const params = getSentParams();
|
||||
expect(params.text).toBe("[[reply_to:abc-123]] hello\nworld");
|
||||
});
|
||||
|
||||
it("rewrites an existing leading reply tag to keep the requested id first", async () => {
|
||||
requestMock.mockClear().mockResolvedValue({ ok: true });
|
||||
stopMock.mockClear().mockResolvedValue(undefined);
|
||||
|
||||
await sendWithDefaults("chat_id:123", " [[reply_to:old-id]] hello", {
|
||||
replyToId: "new-id",
|
||||
});
|
||||
const params = getSentParams();
|
||||
expect(params.text).toBe("[[reply_to:new-id]] hello");
|
||||
});
|
||||
|
||||
it("sanitizes replyToId before writing the leading reply tag", async () => {
|
||||
requestMock.mockClear().mockResolvedValue({ ok: true });
|
||||
stopMock.mockClear().mockResolvedValue(undefined);
|
||||
|
||||
await sendWithDefaults("chat_id:123", "hello", {
|
||||
replyToId: " [ab]\n\u0000c\td ] ",
|
||||
});
|
||||
const params = getSentParams();
|
||||
expect(params.text).toBe("[[reply_to:abcd]] hello");
|
||||
});
|
||||
|
||||
it("skips reply tagging when sanitized replyToId is empty", async () => {
|
||||
requestMock.mockClear().mockResolvedValue({ ok: true });
|
||||
stopMock.mockClear().mockResolvedValue(undefined);
|
||||
|
||||
await sendWithDefaults("chat_id:123", "hello", {
|
||||
replyToId: "[]\u0000\n\r",
|
||||
});
|
||||
const params = getSentParams();
|
||||
expect(params.text).toBe("hello");
|
||||
});
|
||||
|
||||
it("normalizes string message_id values from rpc result", async () => {
|
||||
requestMock.mockClear().mockResolvedValue({ ok: true, message_id: " guid-1 " });
|
||||
stopMock.mockClear().mockResolvedValue(undefined);
|
||||
|
||||
const result = await sendWithDefaults("chat_id:7", "hello");
|
||||
expect(result.messageId).toBe("guid-1");
|
||||
});
|
||||
|
||||
it("does not stop an injected client", async () => {
|
||||
requestMock.mockClear().mockResolvedValue({ ok: true });
|
||||
stopMock.mockClear().mockResolvedValue(undefined);
|
||||
|
||||
await sendWithDefaults("chat_id:123", "hello");
|
||||
expect(stopMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
36
extensions/imessage/src/group-policy.test.ts
Normal file
36
extensions/imessage/src/group-policy.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveIMessageGroupRequireMention,
|
||||
resolveIMessageGroupToolPolicy,
|
||||
} from "./group-policy.js";
|
||||
|
||||
describe("imessage group policy", () => {
|
||||
it("uses generic channel group policy helpers", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
imessage: {
|
||||
groups: {
|
||||
"chat:family": {
|
||||
requireMention: false,
|
||||
tools: { deny: ["exec"] },
|
||||
},
|
||||
"*": {
|
||||
requireMention: true,
|
||||
tools: { allow: ["message.send"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
} as any;
|
||||
|
||||
expect(resolveIMessageGroupRequireMention({ cfg, groupId: "chat:family" })).toBe(false);
|
||||
expect(resolveIMessageGroupRequireMention({ cfg, groupId: "chat:other" })).toBe(true);
|
||||
expect(resolveIMessageGroupToolPolicy({ cfg, groupId: "chat:family" })).toEqual({
|
||||
deny: ["exec"],
|
||||
});
|
||||
expect(resolveIMessageGroupToolPolicy({ cfg, groupId: "chat:other" })).toEqual({
|
||||
allow: ["message.send"],
|
||||
});
|
||||
});
|
||||
});
|
||||
70
extensions/imessage/src/outbound-adapter.test.ts
Normal file
70
extensions/imessage/src/outbound-adapter.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { imessageOutbound } from "./outbound-adapter.js";
|
||||
|
||||
describe("imessageOutbound", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
imessage: {
|
||||
mediaMaxMb: 3,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const sendIMessage = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
sendIMessage.mockReset();
|
||||
});
|
||||
|
||||
it("forwards replyToId on direct text sends", async () => {
|
||||
sendIMessage.mockResolvedValueOnce({ messageId: "m-text" });
|
||||
|
||||
const result = await imessageOutbound.sendText!({
|
||||
cfg,
|
||||
to: "chat_id:12",
|
||||
text: "hello",
|
||||
accountId: "default",
|
||||
replyToId: "reply-1",
|
||||
deps: { sendIMessage },
|
||||
});
|
||||
|
||||
expect(sendIMessage).toHaveBeenCalledWith(
|
||||
"chat_id:12",
|
||||
"hello",
|
||||
expect.objectContaining({
|
||||
accountId: "default",
|
||||
replyToId: "reply-1",
|
||||
maxBytes: 3 * 1024 * 1024,
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ channel: "imessage", messageId: "m-text" });
|
||||
});
|
||||
|
||||
it("forwards mediaLocalRoots on direct media sends", async () => {
|
||||
sendIMessage.mockResolvedValueOnce({ messageId: "m-media-local" });
|
||||
|
||||
const result = await imessageOutbound.sendMedia!({
|
||||
cfg,
|
||||
to: "chat_id:88",
|
||||
text: "caption",
|
||||
mediaUrl: "/tmp/workspace/pic.png",
|
||||
mediaLocalRoots: ["/tmp/workspace"],
|
||||
accountId: "acct-1",
|
||||
replyToId: "reply-2",
|
||||
deps: { sendIMessage },
|
||||
});
|
||||
|
||||
expect(sendIMessage).toHaveBeenCalledWith(
|
||||
"chat_id:88",
|
||||
"caption",
|
||||
expect.objectContaining({
|
||||
mediaUrl: "/tmp/workspace/pic.png",
|
||||
mediaLocalRoots: ["/tmp/workspace"],
|
||||
accountId: "acct-1",
|
||||
replyToId: "reply-2",
|
||||
maxBytes: 3 * 1024 * 1024,
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ channel: "imessage", messageId: "m-media-local" });
|
||||
});
|
||||
});
|
||||
34
extensions/imessage/src/probe.test.ts
Normal file
34
extensions/imessage/src/probe.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as processRuntime from "../../../src/plugin-sdk/process-runtime.js";
|
||||
import * as setupRuntime from "../../../src/plugin-sdk/setup.js";
|
||||
import * as clientModule from "./client.js";
|
||||
import { probeIMessage } from "./probe.js";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(setupRuntime, "detectBinary").mockResolvedValue(true);
|
||||
vi.spyOn(processRuntime, "runCommandWithTimeout").mockResolvedValue({
|
||||
stdout: "",
|
||||
stderr: 'unknown command "rpc" for "imsg"',
|
||||
code: 1,
|
||||
signal: null,
|
||||
killed: false,
|
||||
termination: "exit",
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeIMessage", () => {
|
||||
it("marks unknown rpc subcommand as fatal", async () => {
|
||||
const createIMessageRpcClientMock = vi
|
||||
.spyOn(clientModule, "createIMessageRpcClient")
|
||||
.mockResolvedValue({
|
||||
request: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
} as unknown as Awaited<ReturnType<typeof clientModule.createIMessageRpcClient>>);
|
||||
const result = await probeIMessage(1000, { cliPath: "imsg-test-rpc" });
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.fatal).toBe(true);
|
||||
expect(result.error).toMatch(/rpc/i);
|
||||
expect(createIMessageRpcClientMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
135
extensions/imessage/src/send.test.ts
Normal file
135
extensions/imessage/src/send.test.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ResolvedIMessageAccount } from "./accounts.js";
|
||||
import type { IMessageRpcClient } from "./client.js";
|
||||
import { sendMessageIMessage } from "./send.js";
|
||||
|
||||
const requestMock = vi.fn();
|
||||
const stopMock = vi.fn();
|
||||
|
||||
const defaultAccount: ResolvedIMessageAccount = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: false,
|
||||
config: {},
|
||||
};
|
||||
|
||||
function createClient(): IMessageRpcClient {
|
||||
return {
|
||||
request: (...args: unknown[]) => requestMock(...args),
|
||||
stop: (...args: unknown[]) => stopMock(...args),
|
||||
} as unknown as IMessageRpcClient;
|
||||
}
|
||||
|
||||
async function sendWithDefaults(
|
||||
to: string,
|
||||
text: string,
|
||||
opts: Parameters<typeof sendMessageIMessage>[2] = {},
|
||||
) {
|
||||
return await sendMessageIMessage(to, text, {
|
||||
account: defaultAccount,
|
||||
config: {},
|
||||
client: createClient(),
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
function getSentParams() {
|
||||
return requestMock.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("sendMessageIMessage", () => {
|
||||
beforeEach(() => {
|
||||
requestMock.mockClear().mockResolvedValue({ ok: true });
|
||||
stopMock.mockClear().mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("sends to chat_id targets", async () => {
|
||||
await sendWithDefaults("chat_id:123", "hi");
|
||||
const params = getSentParams();
|
||||
expect(requestMock).toHaveBeenCalledWith("send", expect.any(Object), expect.any(Object));
|
||||
expect(params.chat_id).toBe(123);
|
||||
expect(params.text).toBe("hi");
|
||||
});
|
||||
|
||||
it("applies sms service prefix", async () => {
|
||||
await sendWithDefaults("sms:+1555", "hello");
|
||||
const params = getSentParams();
|
||||
expect(params.service).toBe("sms");
|
||||
expect(params.to).toBe("+1555");
|
||||
});
|
||||
|
||||
it("adds file attachment with placeholder text", async () => {
|
||||
await sendWithDefaults("chat_id:7", "", {
|
||||
mediaUrl: "http://x/y.jpg",
|
||||
resolveAttachmentImpl: async () => ({
|
||||
path: "/tmp/imessage-media.jpg",
|
||||
contentType: "image/jpeg",
|
||||
}),
|
||||
});
|
||||
const params = getSentParams();
|
||||
expect(params.file).toBe("/tmp/imessage-media.jpg");
|
||||
expect(params.text).toBe("<media:image>");
|
||||
});
|
||||
|
||||
it("normalizes mixed-case parameterized MIME for attachment placeholder text", async () => {
|
||||
await sendWithDefaults("chat_id:7", "", {
|
||||
mediaUrl: "http://x/voice",
|
||||
resolveAttachmentImpl: async () => ({
|
||||
path: "/tmp/imessage-media.ogg",
|
||||
contentType: " Audio/Ogg; codecs=opus ",
|
||||
}),
|
||||
});
|
||||
const params = getSentParams();
|
||||
expect(params.file).toBe("/tmp/imessage-media.ogg");
|
||||
expect(params.text).toBe("<media:audio>");
|
||||
});
|
||||
|
||||
it("returns message id when rpc provides one", async () => {
|
||||
requestMock.mockResolvedValue({ ok: true, id: 123 });
|
||||
const result = await sendWithDefaults("chat_id:7", "hello");
|
||||
expect(result.messageId).toBe("123");
|
||||
});
|
||||
|
||||
it("prepends reply tag as the first token when replyToId is provided", async () => {
|
||||
await sendWithDefaults("chat_id:123", " hello\nworld", {
|
||||
replyToId: "abc-123",
|
||||
});
|
||||
const params = getSentParams();
|
||||
expect(params.text).toBe("[[reply_to:abc-123]] hello\nworld");
|
||||
});
|
||||
|
||||
it("rewrites an existing leading reply tag to keep the requested id first", async () => {
|
||||
await sendWithDefaults("chat_id:123", " [[reply_to:old-id]] hello", {
|
||||
replyToId: "new-id",
|
||||
});
|
||||
const params = getSentParams();
|
||||
expect(params.text).toBe("[[reply_to:new-id]] hello");
|
||||
});
|
||||
|
||||
it("sanitizes replyToId before writing the leading reply tag", async () => {
|
||||
await sendWithDefaults("chat_id:123", "hello", {
|
||||
replyToId: " [ab]\n\u0000c\td ] ",
|
||||
});
|
||||
const params = getSentParams();
|
||||
expect(params.text).toBe("[[reply_to:abcd]] hello");
|
||||
});
|
||||
|
||||
it("skips reply tagging when sanitized replyToId is empty", async () => {
|
||||
await sendWithDefaults("chat_id:123", "hello", {
|
||||
replyToId: "[]\u0000\n\r",
|
||||
});
|
||||
const params = getSentParams();
|
||||
expect(params.text).toBe("hello");
|
||||
});
|
||||
|
||||
it("normalizes string message_id values from rpc result", async () => {
|
||||
requestMock.mockResolvedValue({ ok: true, message_id: " guid-1 " });
|
||||
const result = await sendWithDefaults("chat_id:7", "hello");
|
||||
expect(result.messageId).toBe("guid-1");
|
||||
});
|
||||
|
||||
it("does not stop an injected client", async () => {
|
||||
await sendWithDefaults("chat_id:123", "hello");
|
||||
expect(stopMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
24
extensions/imessage/src/setup-allow-from.test.ts
Normal file
24
extensions/imessage/src/setup-allow-from.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseIMessageAllowFromEntries } from "./setup-surface.js";
|
||||
|
||||
describe("parseIMessageAllowFromEntries", () => {
|
||||
it("parses handles and chat targets", () => {
|
||||
expect(parseIMessageAllowFromEntries("+15555550123, chat_id:123, chat_guid:abc")).toEqual({
|
||||
entries: ["+15555550123", "chat_id:123", "chat_guid:abc"],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns validation errors for invalid chat_id", () => {
|
||||
expect(parseIMessageAllowFromEntries("chat_id:abc")).toEqual({
|
||||
entries: [],
|
||||
error: "Invalid chat_id: chat_id:abc",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns validation errors for invalid chat_identifier entries", () => {
|
||||
expect(parseIMessageAllowFromEntries("chat_identifier:")).toEqual({
|
||||
entries: [],
|
||||
error: "Invalid chat_identifier entry",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as processRuntime from "../../../src/plugin-sdk/process-runtime.js";
|
||||
import * as setupRuntime from "../../../src/plugin-sdk/setup.js";
|
||||
import * as clientModule from "./client.js";
|
||||
import {
|
||||
resolveIMessageGroupRequireMention,
|
||||
resolveIMessageGroupToolPolicy,
|
||||
} from "./group-policy.js";
|
||||
import { probeIMessage } from "./probe.js";
|
||||
import { parseIMessageAllowFromEntries } from "./setup-surface.js";
|
||||
import {
|
||||
formatIMessageChatTarget,
|
||||
inferIMessageTargetChatType,
|
||||
@@ -126,85 +117,3 @@ describe("createIMessageRpcClient", () => {
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("imessage group policy", () => {
|
||||
it("uses generic channel group policy helpers", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
imessage: {
|
||||
groups: {
|
||||
"chat:family": {
|
||||
requireMention: false,
|
||||
tools: { deny: ["exec"] },
|
||||
},
|
||||
"*": {
|
||||
requireMention: true,
|
||||
tools: { allow: ["message.send"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
} as any;
|
||||
|
||||
expect(resolveIMessageGroupRequireMention({ cfg, groupId: "chat:family" })).toBe(false);
|
||||
expect(resolveIMessageGroupRequireMention({ cfg, groupId: "chat:other" })).toBe(true);
|
||||
expect(resolveIMessageGroupToolPolicy({ cfg, groupId: "chat:family" })).toEqual({
|
||||
deny: ["exec"],
|
||||
});
|
||||
expect(resolveIMessageGroupToolPolicy({ cfg, groupId: "chat:other" })).toEqual({
|
||||
allow: ["message.send"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseIMessageAllowFromEntries", () => {
|
||||
it("parses handles and chat targets", () => {
|
||||
expect(parseIMessageAllowFromEntries("+15555550123, chat_id:123, chat_guid:abc")).toEqual({
|
||||
entries: ["+15555550123", "chat_id:123", "chat_guid:abc"],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns validation errors for invalid chat_id", () => {
|
||||
expect(parseIMessageAllowFromEntries("chat_id:abc")).toEqual({
|
||||
entries: [],
|
||||
error: "Invalid chat_id: chat_id:abc",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns validation errors for invalid chat_identifier entries", () => {
|
||||
expect(parseIMessageAllowFromEntries("chat_identifier:")).toEqual({
|
||||
entries: [],
|
||||
error: "Invalid chat_identifier entry",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeIMessage", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(setupRuntime, "detectBinary").mockResolvedValue(true);
|
||||
vi.spyOn(processRuntime, "runCommandWithTimeout").mockResolvedValue({
|
||||
stdout: "",
|
||||
stderr: 'unknown command "rpc" for "imsg"',
|
||||
code: 1,
|
||||
signal: null,
|
||||
killed: false,
|
||||
termination: "exit",
|
||||
});
|
||||
});
|
||||
|
||||
it("marks unknown rpc subcommand as fatal", async () => {
|
||||
const createIMessageRpcClientMock = vi
|
||||
.spyOn(clientModule, "createIMessageRpcClient")
|
||||
.mockResolvedValue({
|
||||
request: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
} as unknown as Awaited<ReturnType<typeof clientModule.createIMessageRpcClient>>);
|
||||
const result = await probeIMessage(1000, { cliPath: "imsg-test-rpc" });
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.fatal).toBe(true);
|
||||
expect(result.error).toMatch(/rpc/i);
|
||||
expect(createIMessageRpcClientMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
63
extensions/irc/src/channel.startup.test.ts
Normal file
63
extensions/irc/src/channel.startup.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
expectStopPendingUntilAbort,
|
||||
startAccountAndTrackLifecycle,
|
||||
waitForStartedMocks,
|
||||
} from "../../../test/helpers/extensions/start-account-lifecycle.js";
|
||||
import type { ResolvedIrcAccount } from "./accounts.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
monitorIrcProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./monitor.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./monitor.js")>("./monitor.js");
|
||||
return {
|
||||
...actual,
|
||||
monitorIrcProvider: hoisted.monitorIrcProvider,
|
||||
};
|
||||
});
|
||||
|
||||
import { ircPlugin } from "./channel.js";
|
||||
|
||||
function buildAccount(): ResolvedIrcAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
name: "default",
|
||||
configured: true,
|
||||
host: "irc.example.com",
|
||||
port: 6697,
|
||||
tls: true,
|
||||
nick: "openclaw",
|
||||
username: "openclaw",
|
||||
realname: "OpenClaw",
|
||||
password: "",
|
||||
passwordSource: "none",
|
||||
config: {} as ResolvedIrcAccount["config"],
|
||||
};
|
||||
}
|
||||
|
||||
describe("ircPlugin gateway.startAccount", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("keeps startAccount pending until abort, then stops the monitor", async () => {
|
||||
const stop = vi.fn();
|
||||
hoisted.monitorIrcProvider.mockResolvedValue({ stop });
|
||||
|
||||
const { abort, task, isSettled } = startAccountAndTrackLifecycle({
|
||||
startAccount: ircPlugin.gateway!.startAccount!,
|
||||
account: buildAccount(),
|
||||
});
|
||||
|
||||
await expectStopPendingUntilAbort({
|
||||
waitForStarted: waitForStartedMocks(hoisted.monitorIrcProvider),
|
||||
isSettled,
|
||||
abort,
|
||||
task,
|
||||
stop,
|
||||
});
|
||||
});
|
||||
});
|
||||
185
extensions/irc/src/setup-core.test.ts
Normal file
185
extensions/irc/src/setup-core.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ircSetupAdapter,
|
||||
parsePort,
|
||||
setIrcAllowFrom,
|
||||
setIrcDmPolicy,
|
||||
setIrcGroupAccess,
|
||||
setIrcNickServ,
|
||||
updateIrcAccountConfig,
|
||||
} from "./setup-core.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
describe("irc setup core", () => {
|
||||
it("parses valid ports and falls back for invalid values", () => {
|
||||
expect(parsePort("6697", 6667)).toBe(6697);
|
||||
expect(parsePort(" 7000 ", 6667)).toBe(7000);
|
||||
expect(parsePort("", 6667)).toBe(6667);
|
||||
expect(parsePort("70000", 6667)).toBe(6667);
|
||||
expect(parsePort("abc", 6667)).toBe(6667);
|
||||
});
|
||||
|
||||
it("updates top-level dm policy and allowlist", () => {
|
||||
const cfg: CoreConfig = { channels: { irc: {} } };
|
||||
|
||||
expect(setIrcDmPolicy(cfg, "open")).toMatchObject({
|
||||
channels: {
|
||||
irc: {
|
||||
dmPolicy: "open",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(setIrcAllowFrom(cfg, ["alice", "bob"])).toMatchObject({
|
||||
channels: {
|
||||
irc: {
|
||||
allowFrom: ["alice", "bob"],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("stores nickserv and account config patches on the scoped account", () => {
|
||||
const cfg: CoreConfig = { channels: { irc: {} } };
|
||||
|
||||
expect(
|
||||
setIrcNickServ(cfg, "work", {
|
||||
enabled: true,
|
||||
service: "NickServ",
|
||||
}),
|
||||
).toMatchObject({
|
||||
channels: {
|
||||
irc: {
|
||||
accounts: {
|
||||
work: {
|
||||
nickserv: {
|
||||
enabled: true,
|
||||
service: "NickServ",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
updateIrcAccountConfig(cfg, "work", {
|
||||
host: "irc.libera.chat",
|
||||
nick: "openclaw-work",
|
||||
}),
|
||||
).toMatchObject({
|
||||
channels: {
|
||||
irc: {
|
||||
accounts: {
|
||||
work: {
|
||||
host: "irc.libera.chat",
|
||||
nick: "openclaw-work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes allowlist groups and handles non-allowlist policies", () => {
|
||||
const cfg: CoreConfig = { channels: { irc: {} } };
|
||||
|
||||
expect(
|
||||
setIrcGroupAccess(
|
||||
cfg,
|
||||
"default",
|
||||
"allowlist",
|
||||
["openclaw", "#ops", "openclaw", "*"],
|
||||
(raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
if (trimmed === "*") {
|
||||
return "*";
|
||||
}
|
||||
return trimmed.startsWith("#") ? trimmed : `#${trimmed}`;
|
||||
},
|
||||
),
|
||||
).toMatchObject({
|
||||
channels: {
|
||||
irc: {
|
||||
enabled: true,
|
||||
groupPolicy: "allowlist",
|
||||
groups: {
|
||||
"#openclaw": {},
|
||||
"#ops": {},
|
||||
"*": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(setIrcGroupAccess(cfg, "default", "disabled", [], () => null)).toMatchObject({
|
||||
channels: {
|
||||
irc: {
|
||||
enabled: true,
|
||||
groupPolicy: "disabled",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("validates required input and applies normalized account config", () => {
|
||||
const validateInput = ircSetupAdapter.validateInput;
|
||||
const applyAccountConfig = ircSetupAdapter.applyAccountConfig;
|
||||
expect(validateInput).toBeTypeOf("function");
|
||||
expect(applyAccountConfig).toBeTypeOf("function");
|
||||
|
||||
expect(
|
||||
validateInput!({
|
||||
input: { host: "", nick: "openclaw" },
|
||||
} as never),
|
||||
).toBe("IRC requires host.");
|
||||
|
||||
expect(
|
||||
validateInput!({
|
||||
input: { host: "irc.libera.chat", nick: "" },
|
||||
} as never),
|
||||
).toBe("IRC requires nick.");
|
||||
|
||||
expect(
|
||||
validateInput!({
|
||||
input: { host: "irc.libera.chat", nick: "openclaw" },
|
||||
} as never),
|
||||
).toBeNull();
|
||||
|
||||
expect(
|
||||
applyAccountConfig!({
|
||||
cfg: { channels: { irc: {} } },
|
||||
accountId: "default",
|
||||
input: {
|
||||
name: "Default",
|
||||
host: " irc.libera.chat ",
|
||||
port: "7000",
|
||||
tls: true,
|
||||
nick: " openclaw ",
|
||||
username: " claw ",
|
||||
realname: " OpenClaw Bot ",
|
||||
password: " secret ",
|
||||
channels: ["#openclaw"],
|
||||
},
|
||||
} as never),
|
||||
).toEqual({
|
||||
channels: {
|
||||
irc: {
|
||||
enabled: true,
|
||||
name: "Default",
|
||||
host: "irc.libera.chat",
|
||||
port: 7000,
|
||||
tls: true,
|
||||
nick: "openclaw",
|
||||
username: "claw",
|
||||
realname: "OpenClaw Bot",
|
||||
password: "secret",
|
||||
channels: ["#openclaw"],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
108
extensions/irc/src/setup-surface.test.ts
Normal file
108
extensions/irc/src/setup-surface.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createPluginSetupWizardAdapter,
|
||||
createTestWizardPrompter,
|
||||
promptSetupWizardAllowFrom,
|
||||
runSetupWizardConfigure,
|
||||
type WizardPrompter,
|
||||
} from "../../../test/helpers/extensions/setup-wizard.js";
|
||||
import { ircPlugin } from "./channel.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
const ircConfigureAdapter = createPluginSetupWizardAdapter(ircPlugin);
|
||||
|
||||
describe("irc setup wizard", () => {
|
||||
it("configures host and nick via setup prompts", async () => {
|
||||
const prompter = createTestWizardPrompter({
|
||||
text: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "IRC server host") {
|
||||
return "irc.libera.chat";
|
||||
}
|
||||
if (message === "IRC server port") {
|
||||
return "6697";
|
||||
}
|
||||
if (message === "IRC nick") {
|
||||
return "openclaw-bot";
|
||||
}
|
||||
if (message === "IRC username") {
|
||||
return "openclaw";
|
||||
}
|
||||
if (message === "IRC real name") {
|
||||
return "OpenClaw Bot";
|
||||
}
|
||||
if (message.startsWith("Auto-join IRC channels")) {
|
||||
return "#openclaw, #ops";
|
||||
}
|
||||
if (message.startsWith("IRC channels allowlist")) {
|
||||
return "#openclaw, #ops";
|
||||
}
|
||||
throw new Error(`Unexpected prompt: ${message}`);
|
||||
}) as WizardPrompter["text"],
|
||||
confirm: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Use TLS for IRC?") {
|
||||
return true;
|
||||
}
|
||||
if (message === "Configure IRC channels access?") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await runSetupWizardConfigure({
|
||||
configure: ircConfigureAdapter.configure,
|
||||
cfg: {} as CoreConfig,
|
||||
prompter,
|
||||
options: {},
|
||||
});
|
||||
|
||||
expect(result.accountId).toBe("default");
|
||||
expect(result.cfg.channels?.irc?.enabled).toBe(true);
|
||||
expect(result.cfg.channels?.irc?.host).toBe("irc.libera.chat");
|
||||
expect(result.cfg.channels?.irc?.nick).toBe("openclaw-bot");
|
||||
expect(result.cfg.channels?.irc?.tls).toBe(true);
|
||||
expect(result.cfg.channels?.irc?.channels).toEqual(["#openclaw", "#ops"]);
|
||||
expect(result.cfg.channels?.irc?.groupPolicy).toBe("allowlist");
|
||||
expect(Object.keys(result.cfg.channels?.irc?.groups ?? {})).toEqual(["#openclaw", "#ops"]);
|
||||
});
|
||||
|
||||
it("writes DM allowFrom to top-level config for non-default account prompts", async () => {
|
||||
const prompter = createTestWizardPrompter({
|
||||
text: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "IRC allowFrom (nick or nick!user@host)") {
|
||||
return "Alice, Bob!ident@example.org";
|
||||
}
|
||||
throw new Error(`Unexpected prompt: ${message}`);
|
||||
}) as WizardPrompter["text"],
|
||||
confirm: vi.fn(async () => false),
|
||||
});
|
||||
|
||||
const promptAllowFrom = ircConfigureAdapter.dmPolicy?.promptAllowFrom;
|
||||
if (!promptAllowFrom) {
|
||||
throw new Error("promptAllowFrom unavailable");
|
||||
}
|
||||
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
irc: {
|
||||
accounts: {
|
||||
work: {
|
||||
host: "irc.libera.chat",
|
||||
nick: "openclaw-work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const updated = (await promptSetupWizardAllowFrom({
|
||||
promptAllowFrom,
|
||||
cfg,
|
||||
prompter,
|
||||
accountId: "work",
|
||||
})) as CoreConfig;
|
||||
|
||||
expect(updated.channels?.irc?.allowFrom).toEqual(["alice", "bob!ident@example.org"]);
|
||||
expect(updated.channels?.irc?.accounts?.work?.allowFrom).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,347 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createPluginSetupWizardAdapter,
|
||||
createTestWizardPrompter,
|
||||
promptSetupWizardAllowFrom,
|
||||
runSetupWizardConfigure,
|
||||
type WizardPrompter,
|
||||
} from "../../../test/helpers/extensions/setup-wizard.js";
|
||||
import {
|
||||
expectStopPendingUntilAbort,
|
||||
startAccountAndTrackLifecycle,
|
||||
waitForStartedMocks,
|
||||
} from "../../../test/helpers/extensions/start-account-lifecycle.js";
|
||||
import type { ResolvedIrcAccount } from "./accounts.js";
|
||||
import { ircPlugin } from "./channel.js";
|
||||
import {
|
||||
ircSetupAdapter,
|
||||
parsePort,
|
||||
setIrcAllowFrom,
|
||||
setIrcDmPolicy,
|
||||
setIrcGroupAccess,
|
||||
setIrcNickServ,
|
||||
updateIrcAccountConfig,
|
||||
} from "./setup-core.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
monitorIrcProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./monitor.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./monitor.js")>("./monitor.js");
|
||||
return {
|
||||
...actual,
|
||||
monitorIrcProvider: hoisted.monitorIrcProvider,
|
||||
};
|
||||
});
|
||||
|
||||
const ircConfigureAdapter = createPluginSetupWizardAdapter(ircPlugin);
|
||||
|
||||
function buildAccount(): ResolvedIrcAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
name: "default",
|
||||
configured: true,
|
||||
host: "irc.example.com",
|
||||
port: 6697,
|
||||
tls: true,
|
||||
nick: "openclaw",
|
||||
username: "openclaw",
|
||||
realname: "OpenClaw",
|
||||
password: "",
|
||||
passwordSource: "none",
|
||||
config: {} as ResolvedIrcAccount["config"],
|
||||
};
|
||||
}
|
||||
|
||||
describe("irc setup", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("parses valid ports and falls back for invalid values", () => {
|
||||
expect(parsePort("6697", 6667)).toBe(6697);
|
||||
expect(parsePort(" 7000 ", 6667)).toBe(7000);
|
||||
expect(parsePort("", 6667)).toBe(6667);
|
||||
expect(parsePort("70000", 6667)).toBe(6667);
|
||||
expect(parsePort("abc", 6667)).toBe(6667);
|
||||
});
|
||||
|
||||
it("updates top-level dm policy and allowlist", () => {
|
||||
const cfg: CoreConfig = { channels: { irc: {} } };
|
||||
|
||||
expect(setIrcDmPolicy(cfg, "open")).toMatchObject({
|
||||
channels: {
|
||||
irc: {
|
||||
dmPolicy: "open",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(setIrcAllowFrom(cfg, ["alice", "bob"])).toMatchObject({
|
||||
channels: {
|
||||
irc: {
|
||||
allowFrom: ["alice", "bob"],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("stores nickserv and account config patches on the scoped account", () => {
|
||||
const cfg: CoreConfig = { channels: { irc: {} } };
|
||||
|
||||
expect(
|
||||
setIrcNickServ(cfg, "work", {
|
||||
enabled: true,
|
||||
service: "NickServ",
|
||||
}),
|
||||
).toMatchObject({
|
||||
channels: {
|
||||
irc: {
|
||||
accounts: {
|
||||
work: {
|
||||
nickserv: {
|
||||
enabled: true,
|
||||
service: "NickServ",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
updateIrcAccountConfig(cfg, "work", {
|
||||
host: "irc.libera.chat",
|
||||
nick: "openclaw-work",
|
||||
}),
|
||||
).toMatchObject({
|
||||
channels: {
|
||||
irc: {
|
||||
accounts: {
|
||||
work: {
|
||||
host: "irc.libera.chat",
|
||||
nick: "openclaw-work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes allowlist groups and handles non-allowlist policies", () => {
|
||||
const cfg: CoreConfig = { channels: { irc: {} } };
|
||||
|
||||
expect(
|
||||
setIrcGroupAccess(
|
||||
cfg,
|
||||
"default",
|
||||
"allowlist",
|
||||
["openclaw", "#ops", "openclaw", "*"],
|
||||
(raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
if (trimmed === "*") {
|
||||
return "*";
|
||||
}
|
||||
return trimmed.startsWith("#") ? trimmed : `#${trimmed}`;
|
||||
},
|
||||
),
|
||||
).toMatchObject({
|
||||
channels: {
|
||||
irc: {
|
||||
enabled: true,
|
||||
groupPolicy: "allowlist",
|
||||
groups: {
|
||||
"#openclaw": {},
|
||||
"#ops": {},
|
||||
"*": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(setIrcGroupAccess(cfg, "default", "disabled", [], () => null)).toMatchObject({
|
||||
channels: {
|
||||
irc: {
|
||||
enabled: true,
|
||||
groupPolicy: "disabled",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("validates required input and applies normalized account config", () => {
|
||||
const validateInput = ircSetupAdapter.validateInput;
|
||||
const applyAccountConfig = ircSetupAdapter.applyAccountConfig;
|
||||
expect(validateInput).toBeTypeOf("function");
|
||||
expect(applyAccountConfig).toBeTypeOf("function");
|
||||
|
||||
expect(
|
||||
validateInput!({
|
||||
input: { host: "", nick: "openclaw" },
|
||||
} as never),
|
||||
).toBe("IRC requires host.");
|
||||
|
||||
expect(
|
||||
validateInput!({
|
||||
input: { host: "irc.libera.chat", nick: "" },
|
||||
} as never),
|
||||
).toBe("IRC requires nick.");
|
||||
|
||||
expect(
|
||||
validateInput!({
|
||||
input: { host: "irc.libera.chat", nick: "openclaw" },
|
||||
} as never),
|
||||
).toBeNull();
|
||||
|
||||
expect(
|
||||
applyAccountConfig!({
|
||||
cfg: { channels: { irc: {} } },
|
||||
accountId: "default",
|
||||
input: {
|
||||
name: "Default",
|
||||
host: " irc.libera.chat ",
|
||||
port: "7000",
|
||||
tls: true,
|
||||
nick: " openclaw ",
|
||||
username: " claw ",
|
||||
realname: " OpenClaw Bot ",
|
||||
password: " secret ",
|
||||
channels: ["#openclaw"],
|
||||
},
|
||||
} as never),
|
||||
).toEqual({
|
||||
channels: {
|
||||
irc: {
|
||||
enabled: true,
|
||||
name: "Default",
|
||||
host: "irc.libera.chat",
|
||||
port: 7000,
|
||||
tls: true,
|
||||
nick: "openclaw",
|
||||
username: "claw",
|
||||
realname: "OpenClaw Bot",
|
||||
password: "secret",
|
||||
channels: ["#openclaw"],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("configures host and nick via setup prompts", async () => {
|
||||
const prompter = createTestWizardPrompter({
|
||||
text: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "IRC server host") {
|
||||
return "irc.libera.chat";
|
||||
}
|
||||
if (message === "IRC server port") {
|
||||
return "6697";
|
||||
}
|
||||
if (message === "IRC nick") {
|
||||
return "openclaw-bot";
|
||||
}
|
||||
if (message === "IRC username") {
|
||||
return "openclaw";
|
||||
}
|
||||
if (message === "IRC real name") {
|
||||
return "OpenClaw Bot";
|
||||
}
|
||||
if (message.startsWith("Auto-join IRC channels")) {
|
||||
return "#openclaw, #ops";
|
||||
}
|
||||
if (message.startsWith("IRC channels allowlist")) {
|
||||
return "#openclaw, #ops";
|
||||
}
|
||||
throw new Error(`Unexpected prompt: ${message}`);
|
||||
}) as WizardPrompter["text"],
|
||||
confirm: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Use TLS for IRC?") {
|
||||
return true;
|
||||
}
|
||||
if (message === "Configure IRC channels access?") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await runSetupWizardConfigure({
|
||||
configure: ircConfigureAdapter.configure,
|
||||
cfg: {} as CoreConfig,
|
||||
prompter,
|
||||
options: {},
|
||||
});
|
||||
|
||||
expect(result.accountId).toBe("default");
|
||||
expect(result.cfg.channels?.irc?.enabled).toBe(true);
|
||||
expect(result.cfg.channels?.irc?.host).toBe("irc.libera.chat");
|
||||
expect(result.cfg.channels?.irc?.nick).toBe("openclaw-bot");
|
||||
expect(result.cfg.channels?.irc?.tls).toBe(true);
|
||||
expect(result.cfg.channels?.irc?.channels).toEqual(["#openclaw", "#ops"]);
|
||||
expect(result.cfg.channels?.irc?.groupPolicy).toBe("allowlist");
|
||||
expect(Object.keys(result.cfg.channels?.irc?.groups ?? {})).toEqual(["#openclaw", "#ops"]);
|
||||
});
|
||||
|
||||
it("writes DM allowFrom to top-level config for non-default account prompts", async () => {
|
||||
const prompter = createTestWizardPrompter({
|
||||
text: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "IRC allowFrom (nick or nick!user@host)") {
|
||||
return "Alice, Bob!ident@example.org";
|
||||
}
|
||||
throw new Error(`Unexpected prompt: ${message}`);
|
||||
}) as WizardPrompter["text"],
|
||||
confirm: vi.fn(async () => false),
|
||||
});
|
||||
|
||||
const promptAllowFrom = ircConfigureAdapter.dmPolicy?.promptAllowFrom;
|
||||
if (!promptAllowFrom) {
|
||||
throw new Error("promptAllowFrom unavailable");
|
||||
}
|
||||
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
irc: {
|
||||
accounts: {
|
||||
work: {
|
||||
host: "irc.libera.chat",
|
||||
nick: "openclaw-work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const updated = (await promptSetupWizardAllowFrom({
|
||||
promptAllowFrom,
|
||||
cfg,
|
||||
prompter,
|
||||
accountId: "work",
|
||||
})) as CoreConfig;
|
||||
|
||||
expect(updated.channels?.irc?.allowFrom).toEqual(["alice", "bob!ident@example.org"]);
|
||||
expect(updated.channels?.irc?.accounts?.work?.allowFrom).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps startAccount pending until abort, then stops the monitor", async () => {
|
||||
const stop = vi.fn();
|
||||
hoisted.monitorIrcProvider.mockResolvedValue({ stop });
|
||||
|
||||
const { abort, task, isSettled } = startAccountAndTrackLifecycle({
|
||||
startAccount: ircPlugin.gateway!.startAccount!,
|
||||
account: buildAccount(),
|
||||
});
|
||||
|
||||
await expectStopPendingUntilAbort({
|
||||
waitForStarted: waitForStartedMocks(hoisted.monitorIrcProvider),
|
||||
isSettled,
|
||||
abort,
|
||||
task,
|
||||
stop,
|
||||
});
|
||||
});
|
||||
});
|
||||
223
extensions/line/index.test.ts
Normal file
223
extensions/line/index.test.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import ts from "typescript";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { loadRuntimeApiExportTypesViaJiti } from "../../test/helpers/extensions/jiti-runtime-api.ts";
|
||||
|
||||
function normalizeModuleSpecifier(specifier: string): string | null {
|
||||
if (specifier.startsWith("./src/")) {
|
||||
return specifier;
|
||||
}
|
||||
if (specifier.startsWith("../../extensions/line/src/")) {
|
||||
return `./src/${specifier.slice("../../extensions/line/src/".length)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectModuleExportNames(filePath: string): string[] {
|
||||
const sourcePath = filePath.replace(/\.js$/, ".ts");
|
||||
const sourceText = readFileSync(sourcePath, "utf8");
|
||||
const sourceFile = ts.createSourceFile(sourcePath, sourceText, ts.ScriptTarget.Latest, true);
|
||||
const names = new Set<string>();
|
||||
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (
|
||||
ts.isExportDeclaration(statement) &&
|
||||
statement.exportClause &&
|
||||
ts.isNamedExports(statement.exportClause)
|
||||
) {
|
||||
for (const element of statement.exportClause.elements) {
|
||||
if (!element.isTypeOnly) {
|
||||
names.add(element.name.text);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined;
|
||||
const isExported = modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
|
||||
if (!isExported) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ts.isVariableStatement(statement)) {
|
||||
for (const declaration of statement.declarationList.declarations) {
|
||||
if (ts.isIdentifier(declaration.name)) {
|
||||
names.add(declaration.name.text);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isFunctionDeclaration(statement) ||
|
||||
ts.isClassDeclaration(statement) ||
|
||||
ts.isEnumDeclaration(statement)
|
||||
) {
|
||||
if (statement.name) {
|
||||
names.add(statement.name.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(names).toSorted();
|
||||
}
|
||||
|
||||
function collectRuntimeApiOverlapExports(params: {
|
||||
lineRuntimePath: string;
|
||||
runtimeApiPath: string;
|
||||
}): string[] {
|
||||
const runtimeApiSource = readFileSync(params.runtimeApiPath, "utf8");
|
||||
const runtimeApiFile = ts.createSourceFile(
|
||||
params.runtimeApiPath,
|
||||
runtimeApiSource,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
);
|
||||
const runtimeApiLocalModules = new Set<string>();
|
||||
let pluginSdkLineRuntimeSeen = false;
|
||||
|
||||
for (const statement of runtimeApiFile.statements) {
|
||||
if (!ts.isExportDeclaration(statement)) {
|
||||
continue;
|
||||
}
|
||||
const moduleSpecifier =
|
||||
statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
|
||||
? statement.moduleSpecifier.text
|
||||
: undefined;
|
||||
if (!moduleSpecifier) {
|
||||
continue;
|
||||
}
|
||||
if (moduleSpecifier === "openclaw/plugin-sdk/line-runtime") {
|
||||
pluginSdkLineRuntimeSeen = true;
|
||||
continue;
|
||||
}
|
||||
if (!pluginSdkLineRuntimeSeen) {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeModuleSpecifier(moduleSpecifier);
|
||||
if (normalized) {
|
||||
runtimeApiLocalModules.add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
const lineRuntimeSource = readFileSync(params.lineRuntimePath, "utf8");
|
||||
const lineRuntimeFile = ts.createSourceFile(
|
||||
params.lineRuntimePath,
|
||||
lineRuntimeSource,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
);
|
||||
const overlapExports = new Set<string>();
|
||||
|
||||
for (const statement of lineRuntimeFile.statements) {
|
||||
if (!ts.isExportDeclaration(statement)) {
|
||||
continue;
|
||||
}
|
||||
const moduleSpecifier =
|
||||
statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
|
||||
? statement.moduleSpecifier.text
|
||||
: undefined;
|
||||
const normalized = moduleSpecifier ? normalizeModuleSpecifier(moduleSpecifier) : null;
|
||||
if (!normalized || !runtimeApiLocalModules.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!statement.exportClause) {
|
||||
for (const name of collectModuleExportNames(
|
||||
path.join(process.cwd(), "extensions", "line", normalized),
|
||||
)) {
|
||||
overlapExports.add(name);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ts.isNamedExports(statement.exportClause)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const element of statement.exportClause.elements) {
|
||||
if (!element.isTypeOnly) {
|
||||
overlapExports.add(element.name.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(overlapExports).toSorted();
|
||||
}
|
||||
|
||||
function collectRuntimeApiPreExports(runtimeApiPath: string): string[] {
|
||||
const runtimeApiSource = readFileSync(runtimeApiPath, "utf8");
|
||||
const runtimeApiFile = ts.createSourceFile(
|
||||
runtimeApiPath,
|
||||
runtimeApiSource,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
);
|
||||
const preExports = new Set<string>();
|
||||
|
||||
for (const statement of runtimeApiFile.statements) {
|
||||
if (!ts.isExportDeclaration(statement)) {
|
||||
continue;
|
||||
}
|
||||
const moduleSpecifier =
|
||||
statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
|
||||
? statement.moduleSpecifier.text
|
||||
: undefined;
|
||||
if (!moduleSpecifier) {
|
||||
continue;
|
||||
}
|
||||
if (moduleSpecifier === "openclaw/plugin-sdk/line-runtime") {
|
||||
break;
|
||||
}
|
||||
const normalized = normalizeModuleSpecifier(moduleSpecifier);
|
||||
if (!normalized || !statement.exportClause || !ts.isNamedExports(statement.exportClause)) {
|
||||
continue;
|
||||
}
|
||||
for (const element of statement.exportClause.elements) {
|
||||
if (!element.isTypeOnly) {
|
||||
preExports.add(element.name.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(preExports).toSorted();
|
||||
}
|
||||
|
||||
describe("line runtime api", () => {
|
||||
it("loads through Jiti without duplicate export errors", () => {
|
||||
const runtimeApiPath = path.join(process.cwd(), "extensions", "line", "runtime-api.ts");
|
||||
|
||||
expect(
|
||||
loadRuntimeApiExportTypesViaJiti({
|
||||
modulePath: runtimeApiPath,
|
||||
exportNames: [
|
||||
"buildTemplateMessageFromPayload",
|
||||
"downloadLineMedia",
|
||||
"isSenderAllowed",
|
||||
"probeLineBot",
|
||||
"pushMessageLine",
|
||||
],
|
||||
realPluginSdkSpecifiers: ["openclaw/plugin-sdk/line-runtime"],
|
||||
}),
|
||||
).toEqual({
|
||||
buildTemplateMessageFromPayload: "function",
|
||||
downloadLineMedia: "function",
|
||||
isSenderAllowed: "function",
|
||||
probeLineBot: "function",
|
||||
pushMessageLine: "function",
|
||||
});
|
||||
}, 240_000);
|
||||
|
||||
it("keeps the LINE pre-export block aligned with plugin-sdk/line-runtime overlap", () => {
|
||||
const runtimeApiPath = path.join(process.cwd(), "extensions", "line", "runtime-api.ts");
|
||||
const lineRuntimePath = path.join(process.cwd(), "src", "plugin-sdk", "line-runtime.ts");
|
||||
|
||||
expect(collectRuntimeApiPreExports(runtimeApiPath)).toEqual(
|
||||
collectRuntimeApiOverlapExports({
|
||||
lineRuntimePath,
|
||||
runtimeApiPath,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
96
extensions/line/src/channel.startup.test.ts
Normal file
96
extensions/line/src/channel.startup.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createStartAccountContext } from "../../../test/helpers/extensions/start-account-context.js";
|
||||
import type { PluginRuntime, ResolvedLineAccount } from "../api.js";
|
||||
import { linePlugin } from "./channel.js";
|
||||
import { setLineRuntime } from "./runtime.js";
|
||||
|
||||
function createRuntime() {
|
||||
const monitorLineProvider = vi.fn(async () => ({
|
||||
account: { accountId: "default" },
|
||||
handleWebhook: async () => {},
|
||||
stop: () => {},
|
||||
}));
|
||||
|
||||
const runtime = {
|
||||
channel: {
|
||||
line: {
|
||||
monitorLineProvider,
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
shouldLogVerbose: () => false,
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
|
||||
return { runtime, monitorLineProvider };
|
||||
}
|
||||
|
||||
function createAccount(params: { token: string; secret: string }): ResolvedLineAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
channelAccessToken: params.token,
|
||||
channelSecret: params.secret,
|
||||
tokenSource: "config",
|
||||
config: {} as ResolvedLineAccount["config"],
|
||||
};
|
||||
}
|
||||
|
||||
function startLineAccount(params: { account: ResolvedLineAccount; abortSignal?: AbortSignal }) {
|
||||
const { runtime, monitorLineProvider } = createRuntime();
|
||||
setLineRuntime(runtime);
|
||||
return {
|
||||
monitorLineProvider,
|
||||
task: linePlugin.gateway!.startAccount!(
|
||||
createStartAccountContext({
|
||||
account: params.account,
|
||||
abortSignal: params.abortSignal,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe("linePlugin gateway.startAccount", () => {
|
||||
it("fails startup when channel secret is missing", async () => {
|
||||
const { monitorLineProvider, task } = startLineAccount({
|
||||
account: createAccount({ token: "token", secret: " " }),
|
||||
});
|
||||
|
||||
await expect(task).rejects.toThrow(
|
||||
'LINE webhook mode requires a non-empty channel secret for account "default".',
|
||||
);
|
||||
expect(monitorLineProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails startup when channel access token is missing", async () => {
|
||||
const { monitorLineProvider, task } = startLineAccount({
|
||||
account: createAccount({ token: " ", secret: "secret" }),
|
||||
});
|
||||
|
||||
await expect(task).rejects.toThrow(
|
||||
'LINE webhook mode requires a non-empty channel access token for account "default".',
|
||||
);
|
||||
expect(monitorLineProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts provider when token and secret are present", async () => {
|
||||
const abort = new AbortController();
|
||||
const { monitorLineProvider, task } = startLineAccount({
|
||||
account: createAccount({ token: "token", secret: "secret" }),
|
||||
abortSignal: abort.signal,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(monitorLineProvider).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret",
|
||||
accountId: "default",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
abort.abort();
|
||||
await task;
|
||||
});
|
||||
});
|
||||
95
extensions/line/src/flex-templates.test.ts
Normal file
95
extensions/line/src/flex-templates.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createInfoCard,
|
||||
createListCard,
|
||||
createImageCard,
|
||||
createActionCard,
|
||||
createCarousel,
|
||||
createEventCard,
|
||||
createDeviceControlCard,
|
||||
} from "./flex-templates.js";
|
||||
|
||||
describe("createInfoCard", () => {
|
||||
it("includes footer when provided", () => {
|
||||
const card = createInfoCard("Title", "Body", "Footer text");
|
||||
|
||||
const footer = card.footer as { contents: Array<{ text: string }> };
|
||||
expect(footer.contents[0].text).toBe("Footer text");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createListCard", () => {
|
||||
it("limits items to 8", () => {
|
||||
const items = Array.from({ length: 15 }, (_, i) => ({ title: `Item ${i}` }));
|
||||
const card = createListCard("List", items);
|
||||
|
||||
const body = card.body as { contents: Array<{ type: string; contents?: unknown[] }> };
|
||||
// The list items are in the third content (after title and separator)
|
||||
const listBox = body.contents[2] as { contents: unknown[] };
|
||||
expect(listBox.contents.length).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createImageCard", () => {
|
||||
it("includes body text when provided", () => {
|
||||
const card = createImageCard("https://example.com/img.jpg", "Title", "Body text");
|
||||
|
||||
const body = card.body as { contents: Array<{ text: string }> };
|
||||
expect(body.contents.length).toBe(2);
|
||||
expect(body.contents[1].text).toBe("Body text");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createActionCard", () => {
|
||||
it("limits actions to 4", () => {
|
||||
const actions = Array.from({ length: 6 }, (_, i) => ({
|
||||
label: `Action ${i}`,
|
||||
action: { type: "message" as const, label: `A${i}`, text: `action${i}` },
|
||||
}));
|
||||
const card = createActionCard("Title", "Body", actions);
|
||||
|
||||
const footer = card.footer as { contents: unknown[] };
|
||||
expect(footer.contents.length).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createCarousel", () => {
|
||||
it("limits to 12 bubbles", () => {
|
||||
const bubbles = Array.from({ length: 15 }, (_, i) => createInfoCard(`Card ${i}`, `Body ${i}`));
|
||||
const carousel = createCarousel(bubbles);
|
||||
|
||||
expect(carousel.contents.length).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDeviceControlCard", () => {
|
||||
it("limits controls to 6", () => {
|
||||
const card = createDeviceControlCard({
|
||||
deviceName: "Device",
|
||||
controls: Array.from({ length: 10 }, (_, i) => ({
|
||||
label: `Control ${i}`,
|
||||
data: `action=${i}`,
|
||||
})),
|
||||
});
|
||||
|
||||
// Should have max 3 rows of 2 buttons
|
||||
const footer = card.footer as { contents: unknown[] };
|
||||
expect(footer.contents.length).toBeLessThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createEventCard", () => {
|
||||
it("includes all optional fields together", () => {
|
||||
const card = createEventCard({
|
||||
title: "Team Offsite",
|
||||
date: "February 15, 2026",
|
||||
time: "9:00 AM - 5:00 PM",
|
||||
location: "Mountain View Office",
|
||||
description: "Annual team building event",
|
||||
});
|
||||
|
||||
expect(card.size).toBe("mega");
|
||||
const body = card.body as { contents: Array<{ type: string }> };
|
||||
expect(body.contents).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
resolveLineGroupLookupIds,
|
||||
resolveLineGroupsConfig,
|
||||
} from "./group-keys.js";
|
||||
import { resolveLineGroupRequireMention } from "./group-policy.js";
|
||||
|
||||
describe("resolveLineGroupLookupIds", () => {
|
||||
it("expands raw ids to both prefixed candidates", () => {
|
||||
@@ -78,58 +77,3 @@ describe("account-scoped LINE groups", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("line group policy", () => {
|
||||
it("matches raw and prefixed LINE group keys for requireMention", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
line: {
|
||||
groups: {
|
||||
"room:r123": {
|
||||
requireMention: false,
|
||||
},
|
||||
"group:g123": {
|
||||
requireMention: false,
|
||||
},
|
||||
"*": {
|
||||
requireMention: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
} as any;
|
||||
|
||||
expect(resolveLineGroupRequireMention({ cfg, groupId: "r123" })).toBe(false);
|
||||
expect(resolveLineGroupRequireMention({ cfg, groupId: "room:r123" })).toBe(false);
|
||||
expect(resolveLineGroupRequireMention({ cfg, groupId: "g123" })).toBe(false);
|
||||
expect(resolveLineGroupRequireMention({ cfg, groupId: "group:g123" })).toBe(false);
|
||||
expect(resolveLineGroupRequireMention({ cfg, groupId: "other" })).toBe(true);
|
||||
});
|
||||
|
||||
it("uses account-scoped prefixed LINE group config for requireMention", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
line: {
|
||||
groups: {
|
||||
"*": {
|
||||
requireMention: true,
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
work: {
|
||||
groups: {
|
||||
"group:g123": {
|
||||
requireMention: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
} as any;
|
||||
|
||||
expect(resolveLineGroupRequireMention({ cfg, groupId: "g123", accountId: "work" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
57
extensions/line/src/group-policy.test.ts
Normal file
57
extensions/line/src/group-policy.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveLineGroupRequireMention } from "./group-policy.js";
|
||||
|
||||
describe("line group policy", () => {
|
||||
it("matches raw and prefixed LINE group keys for requireMention", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
line: {
|
||||
groups: {
|
||||
"room:r123": {
|
||||
requireMention: false,
|
||||
},
|
||||
"group:g123": {
|
||||
requireMention: false,
|
||||
},
|
||||
"*": {
|
||||
requireMention: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
} as any;
|
||||
|
||||
expect(resolveLineGroupRequireMention({ cfg, groupId: "r123" })).toBe(false);
|
||||
expect(resolveLineGroupRequireMention({ cfg, groupId: "room:r123" })).toBe(false);
|
||||
expect(resolveLineGroupRequireMention({ cfg, groupId: "g123" })).toBe(false);
|
||||
expect(resolveLineGroupRequireMention({ cfg, groupId: "group:g123" })).toBe(false);
|
||||
expect(resolveLineGroupRequireMention({ cfg, groupId: "other" })).toBe(true);
|
||||
});
|
||||
|
||||
it("uses account-scoped prefixed LINE group config for requireMention", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
line: {
|
||||
groups: {
|
||||
"*": {
|
||||
requireMention: true,
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
work: {
|
||||
groups: {
|
||||
"group:g123": {
|
||||
requireMention: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
} as any;
|
||||
|
||||
expect(resolveLineGroupRequireMention({ cfg, groupId: "g123", accountId: "work" })).toBe(false);
|
||||
});
|
||||
});
|
||||
28
extensions/line/src/monitor.fail-closed.test.ts
Normal file
28
extensions/line/src/monitor.fail-closed.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { monitorLineProvider } from "./monitor.js";
|
||||
|
||||
describe("monitorLineProvider fail-closed webhook auth", () => {
|
||||
it("rejects startup when channel secret is missing", async () => {
|
||||
await expect(
|
||||
monitorLineProvider({
|
||||
channelAccessToken: "token",
|
||||
channelSecret: " ",
|
||||
config: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
}),
|
||||
).rejects.toThrow("LINE webhook mode requires a non-empty channel secret.");
|
||||
});
|
||||
|
||||
it("rejects startup when channel access token is missing", async () => {
|
||||
await expect(
|
||||
monitorLineProvider({
|
||||
channelAccessToken: " ",
|
||||
channelSecret: "secret",
|
||||
config: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
}),
|
||||
).rejects.toThrow("LINE webhook mode requires a non-empty channel access token.");
|
||||
});
|
||||
});
|
||||
@@ -142,26 +142,4 @@ describe("monitorLineProvider lifecycle", () => {
|
||||
monitor.stop();
|
||||
expect(unregisterHttpMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects startup when channel secret is missing", async () => {
|
||||
await expect(
|
||||
monitorLineProvider({
|
||||
channelAccessToken: "token",
|
||||
channelSecret: " ",
|
||||
config: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
}),
|
||||
).rejects.toThrow("LINE webhook mode requires a non-empty channel secret.");
|
||||
});
|
||||
|
||||
it("rejects startup when channel access token is missing", async () => {
|
||||
await expect(
|
||||
monitorLineProvider({
|
||||
channelAccessToken: " ",
|
||||
channelSecret: "secret",
|
||||
config: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
}),
|
||||
).rejects.toThrow("LINE webhook mode requires a non-empty channel access token.");
|
||||
});
|
||||
});
|
||||
|
||||
16
extensions/line/src/monitor.read-body.test.ts
Normal file
16
extensions/line/src/monitor.read-body.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createMockIncomingRequest } from "../../../test/helpers/mock-incoming-request.js";
|
||||
import { readLineWebhookRequestBody } from "./webhook-node.js";
|
||||
|
||||
describe("readLineWebhookRequestBody", () => {
|
||||
it("reads body within limit", async () => {
|
||||
const req = createMockIncomingRequest(['{"events":[{"type":"message"}]}']);
|
||||
const body = await readLineWebhookRequestBody(req, 1024);
|
||||
expect(body).toContain('"events"');
|
||||
});
|
||||
|
||||
it("rejects oversized body", async () => {
|
||||
const req = createMockIncomingRequest(["x".repeat(2048)]);
|
||||
await expect(readLineWebhookRequestBody(req, 128)).rejects.toThrow("PayloadTooLarge");
|
||||
});
|
||||
});
|
||||
57
extensions/line/src/probe.test.ts
Normal file
57
extensions/line/src/probe.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const { getBotInfoMock, MessagingApiClientMock } = vi.hoisted(() => {
|
||||
const getBotInfoMock = vi.fn();
|
||||
const MessagingApiClientMock = vi.fn(function () {
|
||||
return { getBotInfo: getBotInfoMock };
|
||||
});
|
||||
return { getBotInfoMock, MessagingApiClientMock };
|
||||
});
|
||||
|
||||
vi.mock("@line/bot-sdk", () => ({
|
||||
messagingApi: { MessagingApiClient: MessagingApiClientMock },
|
||||
}));
|
||||
|
||||
let probeLineBot: typeof import("./probe.js").probeLineBot;
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
getBotInfoMock.mockClear();
|
||||
});
|
||||
|
||||
describe("probeLineBot", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
getBotInfoMock.mockReset();
|
||||
MessagingApiClientMock.mockReset();
|
||||
MessagingApiClientMock.mockImplementation(function () {
|
||||
return { getBotInfo: getBotInfoMock };
|
||||
});
|
||||
({ probeLineBot } = await import("./probe.js"));
|
||||
});
|
||||
|
||||
it("returns timeout when bot info stalls", async () => {
|
||||
vi.useFakeTimers();
|
||||
getBotInfoMock.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
const probePromise = probeLineBot("token", 10);
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
const result = await probePromise;
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe("timeout");
|
||||
});
|
||||
|
||||
it("returns bot info when available", async () => {
|
||||
getBotInfoMock.mockResolvedValue({
|
||||
displayName: "OpenClaw",
|
||||
userId: "U123",
|
||||
basicId: "@openclaw",
|
||||
pictureUrl: "https://example.com/bot.png",
|
||||
});
|
||||
|
||||
const result = await probeLineBot("token", 50);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.bot?.userId).toBe("U123");
|
||||
});
|
||||
});
|
||||
@@ -1,213 +1,14 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import ts from "typescript";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { loadRuntimeApiExportTypesViaJiti } from "../../../test/helpers/extensions/jiti-runtime-api.ts";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createPluginSetupWizardConfigure,
|
||||
createTestWizardPrompter,
|
||||
runSetupWizardConfigure,
|
||||
type WizardPrompter,
|
||||
} from "../../../test/helpers/extensions/setup-wizard.js";
|
||||
import { createStartAccountContext } from "../../../test/helpers/extensions/start-account-context.js";
|
||||
import type { OpenClawConfig, PluginRuntime, ResolvedLineAccount } from "../api.js";
|
||||
import type { OpenClawConfig } from "../api.js";
|
||||
import { linePlugin } from "./channel.js";
|
||||
import { setLineRuntime } from "./runtime.js";
|
||||
|
||||
const { getBotInfoMock, MessagingApiClientMock } = vi.hoisted(() => {
|
||||
const getBotInfoMock = vi.fn();
|
||||
const MessagingApiClientMock = vi.fn(function () {
|
||||
return { getBotInfo: getBotInfoMock };
|
||||
});
|
||||
return { getBotInfoMock, MessagingApiClientMock };
|
||||
});
|
||||
|
||||
vi.mock("@line/bot-sdk", () => ({
|
||||
messagingApi: { MessagingApiClient: MessagingApiClientMock },
|
||||
}));
|
||||
|
||||
const lineConfigure = createPluginSetupWizardConfigure(linePlugin);
|
||||
let probeLineBot: typeof import("./probe.js").probeLineBot;
|
||||
|
||||
function normalizeModuleSpecifier(specifier: string): string | null {
|
||||
if (specifier.startsWith("./src/")) {
|
||||
return specifier;
|
||||
}
|
||||
if (specifier.startsWith("../../extensions/line/src/")) {
|
||||
return `./src/${specifier.slice("../../extensions/line/src/".length)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectModuleExportNames(filePath: string): string[] {
|
||||
const sourcePath = filePath.replace(/\.js$/, ".ts");
|
||||
const sourceText = readFileSync(sourcePath, "utf8");
|
||||
const sourceFile = ts.createSourceFile(sourcePath, sourceText, ts.ScriptTarget.Latest, true);
|
||||
const names = new Set<string>();
|
||||
|
||||
for (const statement of sourceFile.statements) {
|
||||
if (
|
||||
ts.isExportDeclaration(statement) &&
|
||||
statement.exportClause &&
|
||||
ts.isNamedExports(statement.exportClause)
|
||||
) {
|
||||
for (const element of statement.exportClause.elements) {
|
||||
if (!element.isTypeOnly) {
|
||||
names.add(element.name.text);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined;
|
||||
const isExported = modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
|
||||
if (!isExported) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ts.isVariableStatement(statement)) {
|
||||
for (const declaration of statement.declarationList.declarations) {
|
||||
if (ts.isIdentifier(declaration.name)) {
|
||||
names.add(declaration.name.text);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
ts.isFunctionDeclaration(statement) ||
|
||||
ts.isClassDeclaration(statement) ||
|
||||
ts.isEnumDeclaration(statement)
|
||||
) {
|
||||
if (statement.name) {
|
||||
names.add(statement.name.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(names).toSorted();
|
||||
}
|
||||
|
||||
function collectRuntimeApiOverlapExports(params: {
|
||||
lineRuntimePath: string;
|
||||
runtimeApiPath: string;
|
||||
}): string[] {
|
||||
const runtimeApiSource = readFileSync(params.runtimeApiPath, "utf8");
|
||||
const runtimeApiFile = ts.createSourceFile(
|
||||
params.runtimeApiPath,
|
||||
runtimeApiSource,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
);
|
||||
const runtimeApiLocalModules = new Set<string>();
|
||||
let pluginSdkLineRuntimeSeen = false;
|
||||
|
||||
for (const statement of runtimeApiFile.statements) {
|
||||
if (!ts.isExportDeclaration(statement)) {
|
||||
continue;
|
||||
}
|
||||
const moduleSpecifier =
|
||||
statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
|
||||
? statement.moduleSpecifier.text
|
||||
: undefined;
|
||||
if (!moduleSpecifier) {
|
||||
continue;
|
||||
}
|
||||
if (moduleSpecifier === "openclaw/plugin-sdk/line-runtime") {
|
||||
pluginSdkLineRuntimeSeen = true;
|
||||
continue;
|
||||
}
|
||||
if (!pluginSdkLineRuntimeSeen) {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeModuleSpecifier(moduleSpecifier);
|
||||
if (normalized) {
|
||||
runtimeApiLocalModules.add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
const lineRuntimeSource = readFileSync(params.lineRuntimePath, "utf8");
|
||||
const lineRuntimeFile = ts.createSourceFile(
|
||||
params.lineRuntimePath,
|
||||
lineRuntimeSource,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
);
|
||||
const overlapExports = new Set<string>();
|
||||
|
||||
for (const statement of lineRuntimeFile.statements) {
|
||||
if (!ts.isExportDeclaration(statement)) {
|
||||
continue;
|
||||
}
|
||||
const moduleSpecifier =
|
||||
statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
|
||||
? statement.moduleSpecifier.text
|
||||
: undefined;
|
||||
const normalized = moduleSpecifier ? normalizeModuleSpecifier(moduleSpecifier) : null;
|
||||
if (!normalized || !runtimeApiLocalModules.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!statement.exportClause) {
|
||||
for (const name of collectModuleExportNames(
|
||||
path.join(process.cwd(), "extensions", "line", normalized),
|
||||
)) {
|
||||
overlapExports.add(name);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ts.isNamedExports(statement.exportClause)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const element of statement.exportClause.elements) {
|
||||
if (!element.isTypeOnly) {
|
||||
overlapExports.add(element.name.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(overlapExports).toSorted();
|
||||
}
|
||||
|
||||
function collectRuntimeApiPreExports(runtimeApiPath: string): string[] {
|
||||
const runtimeApiSource = readFileSync(runtimeApiPath, "utf8");
|
||||
const runtimeApiFile = ts.createSourceFile(
|
||||
runtimeApiPath,
|
||||
runtimeApiSource,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
);
|
||||
const preExports = new Set<string>();
|
||||
|
||||
for (const statement of runtimeApiFile.statements) {
|
||||
if (!ts.isExportDeclaration(statement)) {
|
||||
continue;
|
||||
}
|
||||
const moduleSpecifier =
|
||||
statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)
|
||||
? statement.moduleSpecifier.text
|
||||
: undefined;
|
||||
if (!moduleSpecifier) {
|
||||
continue;
|
||||
}
|
||||
if (moduleSpecifier === "openclaw/plugin-sdk/line-runtime") {
|
||||
break;
|
||||
}
|
||||
const normalized = normalizeModuleSpecifier(moduleSpecifier);
|
||||
if (!normalized || !statement.exportClause || !ts.isNamedExports(statement.exportClause)) {
|
||||
continue;
|
||||
}
|
||||
for (const element of statement.exportClause.elements) {
|
||||
if (!element.isTypeOnly) {
|
||||
preExports.add(element.name.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(preExports).toSorted();
|
||||
}
|
||||
|
||||
describe("line setup wizard", () => {
|
||||
it("configures token and secret for the default account", async () => {
|
||||
@@ -236,175 +37,3 @@ describe("line setup wizard", () => {
|
||||
expect(result.cfg.channels?.line?.channelSecret).toBe("line-secret");
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeLineBot", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
getBotInfoMock.mockReset();
|
||||
MessagingApiClientMock.mockReset();
|
||||
MessagingApiClientMock.mockImplementation(function () {
|
||||
return { getBotInfo: getBotInfoMock };
|
||||
});
|
||||
({ probeLineBot } = await import("./probe.js"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
getBotInfoMock.mockClear();
|
||||
});
|
||||
|
||||
it("returns timeout when bot info stalls", async () => {
|
||||
vi.useFakeTimers();
|
||||
getBotInfoMock.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
const probePromise = probeLineBot("token", 10);
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
const result = await probePromise;
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toBe("timeout");
|
||||
});
|
||||
|
||||
it("returns bot info when available", async () => {
|
||||
getBotInfoMock.mockResolvedValue({
|
||||
displayName: "OpenClaw",
|
||||
userId: "U123",
|
||||
basicId: "@openclaw",
|
||||
pictureUrl: "https://example.com/bot.png",
|
||||
});
|
||||
|
||||
const result = await probeLineBot("token", 50);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.bot?.userId).toBe("U123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("line runtime api", () => {
|
||||
it("loads through Jiti without duplicate export errors", () => {
|
||||
const runtimeApiPath = path.join(process.cwd(), "extensions", "line", "runtime-api.ts");
|
||||
|
||||
expect(
|
||||
loadRuntimeApiExportTypesViaJiti({
|
||||
modulePath: runtimeApiPath,
|
||||
exportNames: [
|
||||
"buildTemplateMessageFromPayload",
|
||||
"downloadLineMedia",
|
||||
"isSenderAllowed",
|
||||
"probeLineBot",
|
||||
"pushMessageLine",
|
||||
],
|
||||
realPluginSdkSpecifiers: ["openclaw/plugin-sdk/line-runtime"],
|
||||
}),
|
||||
).toEqual({
|
||||
buildTemplateMessageFromPayload: "function",
|
||||
downloadLineMedia: "function",
|
||||
isSenderAllowed: "function",
|
||||
probeLineBot: "function",
|
||||
pushMessageLine: "function",
|
||||
});
|
||||
}, 240_000);
|
||||
|
||||
it("keeps the LINE pre-export block aligned with plugin-sdk/line-runtime overlap", () => {
|
||||
const runtimeApiPath = path.join(process.cwd(), "extensions", "line", "runtime-api.ts");
|
||||
const lineRuntimePath = path.join(process.cwd(), "src", "plugin-sdk", "line-runtime.ts");
|
||||
|
||||
expect(collectRuntimeApiPreExports(runtimeApiPath)).toEqual(
|
||||
collectRuntimeApiOverlapExports({
|
||||
lineRuntimePath,
|
||||
runtimeApiPath,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function createRuntime() {
|
||||
const monitorLineProvider = vi.fn(async () => ({
|
||||
account: { accountId: "default" },
|
||||
handleWebhook: async () => {},
|
||||
stop: () => {},
|
||||
}));
|
||||
|
||||
const runtime = {
|
||||
channel: {
|
||||
line: {
|
||||
monitorLineProvider,
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
shouldLogVerbose: () => false,
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
|
||||
return { runtime, monitorLineProvider };
|
||||
}
|
||||
|
||||
function createAccount(params: { token: string; secret: string }): ResolvedLineAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
channelAccessToken: params.token,
|
||||
channelSecret: params.secret,
|
||||
tokenSource: "config",
|
||||
config: {} as ResolvedLineAccount["config"],
|
||||
};
|
||||
}
|
||||
|
||||
function startLineAccount(params: { account: ResolvedLineAccount; abortSignal?: AbortSignal }) {
|
||||
const { runtime, monitorLineProvider } = createRuntime();
|
||||
setLineRuntime(runtime);
|
||||
return {
|
||||
monitorLineProvider,
|
||||
task: linePlugin.gateway!.startAccount!(
|
||||
createStartAccountContext({
|
||||
account: params.account,
|
||||
abortSignal: params.abortSignal,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe("linePlugin gateway.startAccount", () => {
|
||||
it("fails startup when channel secret is missing", async () => {
|
||||
const { monitorLineProvider, task } = startLineAccount({
|
||||
account: createAccount({ token: "token", secret: " " }),
|
||||
});
|
||||
|
||||
await expect(task).rejects.toThrow(
|
||||
'LINE webhook mode requires a non-empty channel secret for account "default".',
|
||||
);
|
||||
expect(monitorLineProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails startup when channel access token is missing", async () => {
|
||||
const { monitorLineProvider, task } = startLineAccount({
|
||||
account: createAccount({ token: " ", secret: "secret" }),
|
||||
});
|
||||
|
||||
await expect(task).rejects.toThrow(
|
||||
'LINE webhook mode requires a non-empty channel access token for account "default".',
|
||||
);
|
||||
expect(monitorLineProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts provider when token and secret are present", async () => {
|
||||
const abort = new AbortController();
|
||||
const { monitorLineProvider, task } = startLineAccount({
|
||||
account: createAccount({ token: "token", secret: "secret" }),
|
||||
abortSignal: abort.signal,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(monitorLineProvider).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channelAccessToken: "token",
|
||||
channelSecret: "secret",
|
||||
accountId: "default",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
abort.abort();
|
||||
await task;
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user