mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-09 15:31:18 +08:00
Compare commits
223 Commits
fix/daemon
...
fix/webcha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16e6789dd5 | ||
|
|
ba99fda951 | ||
|
|
7dadd5027b | ||
|
|
f8ed48293c | ||
|
|
96a38d5aa4 | ||
|
|
c7ec237089 | ||
|
|
1ae82be55a | ||
|
|
fd782d811e | ||
|
|
a467517b2b | ||
|
|
3eec79bd6c | ||
|
|
4ba5937ef9 | ||
|
|
6fc3f504d6 | ||
|
|
b17687b775 | ||
|
|
eca242b971 | ||
|
|
4494844d17 | ||
|
|
5193189953 | ||
|
|
fbb88d5063 | ||
|
|
c0715db3c8 | ||
|
|
20c15ccc63 | ||
|
|
16fd604219 | ||
|
|
61f29830bc | ||
|
|
47736e3432 | ||
|
|
39520ad21b | ||
|
|
bd8c3230e8 | ||
|
|
ebbb572639 | ||
|
|
3b9877dee7 | ||
|
|
40e5c6a18d | ||
|
|
11e1363d2d | ||
|
|
ee646dae82 | ||
|
|
85f01cd9eb | ||
|
|
bab5d994bc | ||
|
|
2365c6c86a | ||
|
|
53ada1e9b9 | ||
|
|
b91a22a3fb | ||
|
|
2aab6dff76 | ||
|
|
980388fcf0 | ||
|
|
3e6451f2d8 | ||
|
|
2f6718b8e7 | ||
|
|
b5350bf46f | ||
|
|
0f5f20ee6b | ||
|
|
6b6af1a64f | ||
|
|
c1b37f29f0 | ||
|
|
a3b674cc98 | ||
|
|
cdc1ef85e8 | ||
|
|
1ca69c8fd7 | ||
|
|
469cd5b464 | ||
|
|
666073ee46 | ||
|
|
747902a26a | ||
|
|
61adcea68e | ||
|
|
5ee6ca13b7 | ||
|
|
71cd337137 | ||
|
|
4d04e1a41f | ||
|
|
67e3eb85d7 | ||
|
|
1b4062defd | ||
|
|
3e4dd84511 | ||
|
|
5084621f43 | ||
|
|
346d3590fb | ||
|
|
687ef2e00f | ||
|
|
1187464041 | ||
|
|
4e4a100038 | ||
|
|
ddd71bc9f6 | ||
|
|
1a7a18d0bc | ||
|
|
4e4d94cd38 | ||
|
|
f0640b0100 | ||
|
|
46df7e2421 | ||
|
|
42626648d7 | ||
|
|
17b40c4a59 | ||
|
|
d9119f0791 | ||
|
|
586f057c24 | ||
|
|
90d8b40808 | ||
|
|
d7bafae387 | ||
|
|
588fbd5b68 | ||
|
|
ef920f2f39 | ||
|
|
57e1534df8 | ||
|
|
a48a3dbdda | ||
|
|
c3d5159121 | ||
|
|
1bd20dbdb6 | ||
|
|
a2fdc3415f | ||
|
|
ced267c5cb | ||
|
|
287606e445 | ||
|
|
f26853f14c | ||
|
|
a44843507f | ||
|
|
de09ca149f | ||
|
|
503d395780 | ||
|
|
924d9e34ef | ||
|
|
f3e6578e6c | ||
|
|
e930517154 | ||
|
|
47083460ea | ||
|
|
7de4204e57 | ||
|
|
36dfd462a8 | ||
|
|
6649c22471 | ||
|
|
596621919c | ||
|
|
9657ded2e1 | ||
|
|
282b107e99 | ||
|
|
86090b0ff2 | ||
|
|
77ecef1fde | ||
|
|
53fd7f8163 | ||
|
|
1b5ac8b0b1 | ||
|
|
f6233cfa5c | ||
|
|
61be533ad4 | ||
|
|
d76ddd61ec | ||
|
|
82101b152a | ||
|
|
439a7732f4 | ||
|
|
a96b3b406a | ||
|
|
68e982ec80 | ||
|
|
d0a3743abd | ||
|
|
0d8beeb4e5 | ||
|
|
1e8afa16f0 | ||
|
|
65dc3ee76c | ||
|
|
f4682742d9 | ||
|
|
d37ad9d866 | ||
|
|
4b3d9f4fb2 | ||
|
|
6bf84ac28c | ||
|
|
051b380d38 | ||
|
|
dee7cda1ec | ||
|
|
8824565c2a | ||
|
|
d7dda4dd1a | ||
|
|
6a42d09129 | ||
|
|
fd3ca8a34c | ||
|
|
fe14be2352 | ||
|
|
e870cee542 | ||
|
|
3e9c8721fb | ||
|
|
11c397ef46 | ||
|
|
4bfbf2dfff | ||
|
|
1d0a4d1be2 | ||
|
|
d6491d8d71 | ||
|
|
6b85ec3022 | ||
|
|
3e1ec5ad8b | ||
|
|
c5ddba52d7 | ||
|
|
381bb867ac | ||
|
|
24dcd68f42 | ||
|
|
a1b4a0066b | ||
|
|
a5b81d1c13 | ||
|
|
d3dc4e54f7 | ||
|
|
dba47f349f | ||
|
|
fe4c627432 | ||
|
|
b8b8a5f314 | ||
|
|
ea3b7dfde5 | ||
|
|
32ecd6f579 | ||
|
|
dc825e59f5 | ||
|
|
500d7cb107 | ||
|
|
1234cc4c31 | ||
|
|
abec8a4f0a | ||
|
|
41bdf2df41 | ||
|
|
c20ee11348 | ||
|
|
4d19dc8671 | ||
|
|
73e08ed7b0 | ||
|
|
5868344ade | ||
|
|
abb0252a1a | ||
|
|
55f04636f3 | ||
|
|
f22fc17c78 | ||
|
|
28c88e9fa1 | ||
|
|
58ad617e64 | ||
|
|
dc2aa1e21d | ||
|
|
8fdd1d2f05 | ||
|
|
bb60687b89 | ||
|
|
a282b459b9 | ||
|
|
de77a36579 | ||
|
|
79e114a82f | ||
|
|
7c7c22d66f | ||
|
|
ec688d809f | ||
|
|
481da215b9 | ||
|
|
132794fe74 | ||
|
|
d4ec0ed3c7 | ||
|
|
2e0f5b73d1 | ||
|
|
66397c2855 | ||
|
|
e2483a5381 | ||
|
|
c703aa0fe9 | ||
|
|
3bf19d6f40 | ||
|
|
7365aefa19 | ||
|
|
7066d5e192 | ||
|
|
350d041eaf | ||
|
|
e05bcccde8 | ||
|
|
0954b6bf5f | ||
|
|
3b3e47e15d | ||
|
|
8f3eb0f7b4 | ||
|
|
0e16749f00 | ||
|
|
7eda632324 | ||
|
|
3043e68dfa | ||
|
|
36c6b63ea6 | ||
|
|
fc1787fd4b | ||
|
|
2287d1ec13 | ||
|
|
ba5ae5b4f1 | ||
|
|
a81704e622 | ||
|
|
02eeb08e04 | ||
|
|
7cbcbbc642 | ||
|
|
903e4dff35 | ||
|
|
905c3357eb | ||
|
|
f431f20c48 | ||
|
|
d9fdec12ab | ||
|
|
f25be781c4 | ||
|
|
0d8f14fed3 | ||
|
|
842a79cf99 | ||
|
|
caae34cbaf | ||
|
|
fa47f74c0f | ||
|
|
ac11f0af73 | ||
|
|
a78ec81ae6 | ||
|
|
be578b43d3 | ||
|
|
0b5d8e5b47 | ||
|
|
b9b47f5002 | ||
|
|
319b7c68a1 | ||
|
|
6200e242b2 | ||
|
|
5b5ccb0769 | ||
|
|
0743463b88 | ||
|
|
48155729fc | ||
|
|
163f5184b3 | ||
|
|
8950c59581 | ||
|
|
29dde80c3e | ||
|
|
b5102ba4f9 | ||
|
|
7ad6a04058 | ||
|
|
e0b8b80067 | ||
|
|
44183c6eb1 | ||
|
|
f9cbcfca0d | ||
|
|
5d3032b293 | ||
|
|
11adaa15a8 | ||
|
|
3cb851be90 | ||
|
|
1fa2488db1 | ||
|
|
d3cb85eaf5 | ||
|
|
d89c25d69e | ||
|
|
f257818ea5 | ||
|
|
350ac0d824 | ||
|
|
19fafed11d | ||
|
|
ffa7c13c9b |
@@ -9,7 +9,7 @@ Input
|
||||
- If ambiguous: ask.
|
||||
|
||||
Do (end-to-end)
|
||||
Goal: PR must end in GitHub state = MERGED (never CLOSED). Use `gh pr merge` with `--rebase` or `--squash`.
|
||||
Goal: PR must end in GitHub state = MERGED (never CLOSED). Prefer `gh pr merge --squash`; use `--rebase` only when preserving commit history is required.
|
||||
|
||||
1. Assign PR to self:
|
||||
- `gh pr edit <PR> --add-assignee @me`
|
||||
@@ -37,8 +37,8 @@ Goal: PR must end in GitHub state = MERGED (never CLOSED). Use `gh pr merge` wit
|
||||
- Implement fixes + add/adjust tests
|
||||
- Update `CHANGELOG.md` and mention `#<PR>` + `@$contrib`
|
||||
9. Decide merge strategy:
|
||||
- Rebase if we want to preserve commit history
|
||||
- Squash if we want a single clean commit
|
||||
- Squash (preferred): use when we want a single clean commit
|
||||
- Rebase: use only when we explicitly want to preserve commit history
|
||||
- If unclear, ask
|
||||
10. Full gate (BEFORE commit):
|
||||
- `pnpm lint && pnpm build && pnpm test`
|
||||
@@ -54,8 +54,8 @@ Goal: PR must end in GitHub state = MERGED (never CLOSED). Use `gh pr merge` wit
|
||||
```
|
||||
|
||||
13. Merge PR (must show MERGED on GitHub):
|
||||
- Rebase: `gh pr merge <PR> --rebase`
|
||||
- Squash: `gh pr merge <PR> --squash`
|
||||
- Squash (preferred): `gh pr merge <PR> --squash`
|
||||
- Rebase (history-preserving fallback): `gh pr merge <PR> --rebase`
|
||||
- Never `gh pr close` (closing is wrong)
|
||||
14. Sync main:
|
||||
- `git checkout main`
|
||||
|
||||
74
CHANGELOG.md
74
CHANGELOG.md
@@ -6,14 +6,25 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- Models/MiniMax: add first-class `MiniMax-M2.5-highspeed` support across built-in provider catalogs, onboarding flows, and MiniMax OAuth plugin defaults, while keeping legacy `MiniMax-M2.5-Lightning` compatibility for existing configs.
|
||||
- Docs/Models: refresh MiniMax, Moonshot (Kimi), GLM/Z.AI model docs to align with latest defaults (`MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `moonshot/kimi-k2.5`, `zai/glm-5`) and keep Moonshot model lists synced from shared source data.
|
||||
- Memory/Ollama embeddings: add `memorySearch.provider = "ollama"` and `memorySearch.fallback = "ollama"` support, honor `models.providers.ollama` settings for memory embedding requests, and document Ollama embedding usage. (#26349) Thanks @nico-hoff.
|
||||
- Outbound adapters/plugins: add shared `sendPayload` support across direct-text-media, Discord, Slack, WhatsApp, Zalo, and Zalouser with multi-media iteration and chunk-aware text fallback. (#30144) Thanks @nohat.
|
||||
- Media understanding/audio echo: add optional `tools.media.audio.echoTranscript` + `echoFormat` to send a pre-agent transcript confirmation message to the originating chat, with echo disabled by default. (#32150) Thanks @AytuncYildizli.
|
||||
- Plugin runtime/STT: add `api.runtime.stt.transcribeAudioFile(...)` so extensions can transcribe local audio files through OpenClaw's configured media-understanding audio providers. (#22402) Thanks @benthecarman.
|
||||
- Plugin SDK/channel extensibility: expose `channelRuntime` on `ChannelGatewayContext` so external channel plugins can access shared runtime helpers (reply/routing/session/text/media/commands) without internal imports. (#25462) Thanks @guxiaobo.
|
||||
- Plugin runtime/events: expose `runtime.events.onAgentEvent` and `runtime.events.onSessionTranscriptUpdate` for extension-side subscriptions, and isolate transcript-listener failures so one faulty listener cannot break the entire update fanout. (#16044) Thanks @scifantastic.
|
||||
- Plugin runtime/system: expose `runtime.system.requestHeartbeatNow(...)` so extensions can wake targeted sessions immediately after enqueueing system events. (#19464) Thanks @AustinEral.
|
||||
- Plugin hooks/session lifecycle: include `sessionKey` in `session_start`/`session_end` hook events and contexts so plugins can correlate lifecycle callbacks with routing identity. (#26394) Thanks @tempeste.
|
||||
- Sessions/Attachments: add inline file attachment support for `sessions_spawn` (subagent runtime only) with base64/utf8 encoding, transcript content redaction, lifecycle cleanup, and configurable limits via `tools.sessions_spawn.attachments`. (#16761) Thanks @napetrov.
|
||||
- Tools/PDF analysis: add a first-class `pdf` tool with native Anthropic and Google PDF provider support, extraction fallback for non-native models, configurable defaults (`agents.defaults.pdfModel`, `pdfMaxBytesMb`, `pdfMaxPages`), and docs/tests covering routing, validation, and registration. (#31319) Thanks @tyler6204.
|
||||
- Zalo Personal plugin (`@openclaw/zalouser`): rebuilt channel runtime to use native `zca-js` integration in-process, removing external CLI transport usage and keeping QR/login + send/listen flows fully inside OpenClaw.
|
||||
- Telegram/DM streaming: use `sendMessageDraft` for private preview streaming, keep reasoning/answer preview lanes separated in DM reasoning-stream mode. (#31824) Thanks @obviyus.
|
||||
- Telegram/voice mention gating: add optional `disableAudioPreflight` on group/topic config to skip mention-detection preflight transcription for inbound voice notes where operators want text-only mention checks. (#23067) Thanks @yangnim21029.
|
||||
- Hooks/message lifecycle: add internal hook events `message:transcribed` and `message:preprocessed`, plus richer outbound `message:sent` context (`isGroup`, `groupId`) for group-conversation correlation and post-transcription automations. (#9859) Thanks @Drickon.
|
||||
- Telegram/Streaming defaults: default `channels.telegram.streaming` to `partial` (from `off`) so new Telegram setups get live preview streaming out of the box, with runtime fallback to message-edit preview when native drafts are unavailable.
|
||||
- CLI/Config validation: add `openclaw config validate` (with `--json`) to validate config files before gateway startup, and include detailed invalid-key paths in startup invalid-config errors. (#31220) thanks @Sid-Qin.
|
||||
- CLI/Banner taglines: add `cli.banner.taglineMode` (`random` | `default` | `off`) to control funny tagline behavior in startup output, with docs + FAQ guidance and regression tests for config override behavior.
|
||||
- Tools/Diffs: add PDF file output support and rendering quality customization controls (`fileQuality`, `fileScale`, `fileMaxWidth`) for generated diff artifacts, and document PDF as the preferred option when messaging channels compress images. (#31342) Thanks @gumadeiras.
|
||||
- README/Contributors: rank contributor avatars by composite score (commits + merged PRs + code LOC), excluding docs-only LOC to prevent bulk-generated files from inflating rankings. (#23970) Thanks @tyler6204.
|
||||
|
||||
@@ -22,25 +33,69 @@ Docs: https://docs.openclaw.ai
|
||||
- **BREAKING:** Plugin SDK removed `api.registerHttpHandler(...)`. Plugins must register explicit HTTP routes via `api.registerHttpRoute({ path, auth, match, handler })`, and dynamic webhook lifecycles should use `registerPluginHttpRoute(...)`.
|
||||
- **BREAKING:** Zalo Personal plugin (`@openclaw/zalouser`) no longer depends on external `zca`-compatible CLI binaries (`openzca`, `zca-cli`) for runtime send/listen/login; operators should use `openclaw channels login --channel zalouser` after upgrade to refresh sessions in the new JS-native path.
|
||||
- **BREAKING:** Onboarding now defaults `tools.profile` to `messaging` for new local installs (interactive + non-interactive). New setups no longer start with broad coding/system tools unless explicitly configured.
|
||||
- **BREAKING:** ACP dispatch now defaults to enabled unless explicitly disabled (`acp.dispatch.enabled=false`). If you need to pause ACP turn routing while keeping `/acp` controls, set `acp.dispatch.enabled=false`. Docs: https://docs.openclaw.ai/tools/acp-agents
|
||||
|
||||
### Fixes
|
||||
|
||||
- Sessions/idle reset correctness: preserve existing `updatedAt` during inbound metadata-only writes so idle-reset boundaries are not unintentionally refreshed before actual user turns. (#32379) Thanks @romeodiaz.
|
||||
- Slack/socket auth failure handling: fail fast on non-recoverable auth errors (`account_inactive`, `invalid_auth`, etc.) during startup and reconnect instead of retry-looping indefinitely, including `unable_to_socket_mode_start` error payload propagation. (#32377) Thanks @scoootscooob.
|
||||
- CLI/installer Node preflight: enforce Node.js `v22.12+` consistently in both `openclaw.mjs` runtime bootstrap and installer active-shell checks, with actionable nvm recovery guidance for mismatched shell PATH/defaults. (#32356) Thanks @jasonhargrove.
|
||||
- Web UI/inline code copy fidelity: disable forced mid-token wraps on inline `<code>` spans so copied UUID/hash/token strings preserve exact content instead of inserting line-break spaces. (#32346) Thanks @hclsys.
|
||||
- Gateway/message tool reliability: avoid false `Unknown channel` failures when `message.*` actions receive platform-specific channel ids by falling back to `toolContext.currentChannelProvider`, and prevent health-monitor restart thrash for channels that just (re)started by adding a per-channel startup-connect grace window. (from #32367) Thanks @MunemHashmi.
|
||||
- Discord/lifecycle startup status: push an immediate `connected` status snapshot when the gateway is already connected before lifecycle debug listeners attach, with abort-guarding to avoid contradictory status flips during pre-aborted startup. (#32336) Thanks @mitchmcalister.
|
||||
- Cron/isolated delivery target fallback: remove early unresolved-target return so cron delivery can flow through shared outbound target resolution (including per-channel `resolveDefaultTo` fallback) when `delivery.to` is omitted. (#32364) Thanks @hclsys.
|
||||
- WebChat/markdown tables: ensure GitHub-flavored markdown table parsing is explicitly enabled at render time and add horizontal overflow handling for wide tables, with regression coverage for table-only and mixed text+table content. (#32365) Thanks @BlueBirdBack.
|
||||
- Feishu/default account resolution: always honor explicit `channels.feishu.defaultAccount` during outbound account selection (including top-level-credential setups where the preferred id is not present in `accounts`), instead of silently falling back to another account id. (#32253) Thanks @bmendonca3.
|
||||
- Gemini schema sanitization: coerce malformed JSON Schema `properties` values (`null`, arrays, primitives) to `{}` before provider validation, preventing downstream strict-validator crashes on invalid plugin/tool schemas. (#32332) Thanks @webdevtodayjason.
|
||||
- Models/openai-completions developer-role compatibility: force `supportsDeveloperRole=false` for non-native endpoints, treat unparseable `baseUrl` values as non-native, and add regression coverage for empty/malformed baseUrl plus explicit-true override behavior. (#29479) thanks @akramcodez.
|
||||
- OpenAI/Responses WebSocket tool-call id hygiene: normalize blank/whitespace streamed tool-call ids before persistence, and block empty `function_call_output.call_id` payloads in the WS conversion path to avoid OpenAI 400 errors (`Invalid 'input[n].call_id': empty string`), with regression coverage for both inbound stream normalization and outbound payload guards.
|
||||
- Gateway/Control UI basePath webhook passthrough: let non-read methods under configured `controlUiBasePath` fall through to plugin routes (instead of returning Control UI 405), restoring webhook handlers behind basePath mounts. (#32311) Thanks @ademczuk.
|
||||
- CLI/Config validation and routing hardening: dedupe `openclaw config validate` failures to a single authoritative report, expose allowed-values metadata/hints across core Zod and plugin AJV validation (including `--json` fields), sanitize terminal-rendered validation text, and make command-path parsing root-option-aware across preaction/route/lazy registration (including routed `config get/unset` with split root options). Thanks @gumadeiras.
|
||||
- Models/config env propagation: apply `config.env.vars` before implicit provider discovery in models bootstrap so config-scoped credentials are visible to implicit provider resolution paths. (#32295) Thanks @hsiaoa.
|
||||
- Hooks/runtime stability: keep the internal hook handler registry on a `globalThis` singleton so hook registration/dispatch remains consistent when bundling emits duplicate module copies. (#32292) Thanks @Drickon.
|
||||
- Hooks/plugin context parity: ensure `llm_input` hooks in embedded attempts receive the same `trigger` and `channelId`-aware `hookCtx` used by the other hook phases, preserving channel/trigger-scoped plugin behavior. (#28623) Thanks @davidrudduck and @vincentkoc.
|
||||
- Restart sentinel formatting: avoid duplicate `Reason:` lines when restart message text already matches `stats.reason`, keeping restart notifications concise for users and downstream parsers. (#32083) Thanks @velamints2.
|
||||
- Voice-call/Twilio signature verification: retry signature validation across deterministic URL port variants (with/without port) to handle mixed Twilio signing behavior behind reverse proxies and non-standard ports. (#25140) Thanks @drvoss.
|
||||
- Hooks/webhook ACK compatibility: return `200` (instead of `202`) for successful `/hooks/agent` requests so providers that require `200` (for example Forward Email) accept dispatched agent hook deliveries. (#28204) Thanks @Glucksberg.
|
||||
- Voice-call/Twilio external outbound: auto-register webhook-first `outbound-api` calls (initiated outside OpenClaw) so media streams are accepted and call direction metadata stays accurate. (#31181) Thanks @scoootscooob.
|
||||
- Voice-call/Twilio inbound greeting: run answered-call initial notify greeting for Twilio instead of skipping the manager speak path, with regression coverage for both Twilio and Plivo notify flows. (#29121) Thanks @xinhuagu.
|
||||
- Voice-call/stale call hydration: verify active calls with the provider before loading persisted in-progress calls so stale locally persisted records do not block or misroute new call handling after restarts. (#4325) Thanks @garnetlyx.
|
||||
- Feishu/topic session routing: use `thread_id` as topic session scope fallback when `root_id` is absent, keep first-turn topic keys stable across thread creation, and force thread replies when inbound events already carry topic/thread context. (#29788) Thanks @songyaolun.
|
||||
- Feishu/topic root replies: prefer `root_id` as outbound `replyTargetMessageId` when present, and parse millisecond `message_create_time` values correctly so topic replies anchor to the root message in grouped thread flows. (#29968) Thanks @bmendonca3.
|
||||
- Feishu/DM pairing reply target: send pairing challenge replies to `chat:<chat_id>` instead of `user:<sender_open_id>` so Lark/Feishu private chats with user-id-only sender payloads receive pairing messages reliably. (#31403) Thanks @stakeswky.
|
||||
- Feishu/Lark private DM routing: treat inbound `chat_type: "private"` as direct-message context for pairing/mention-forward/reaction synthetic handling so Lark private chats behave like Feishu p2p DMs. (#31400) Thanks @stakeswky.
|
||||
- Feishu/Sender lookup permissions: suppress user-facing grant prompts for stale non-existent scope errors (`contact:contact.base:readonly`) during best-effort sender-name resolution so inbound messages continue without repeated false permission notices. (#31761)
|
||||
- Sandbox/workspace mount permissions: make primary `/workspace` bind mounts read-only whenever `workspaceAccess` is not `rw` (including `none`) across both core sandbox container and sandbox browser create flows. (#32227) Thanks @guanyu-zhang.
|
||||
- Security audit/skills workspace hardening: add `skills.workspace.symlink_escape` warning in `openclaw security audit` when workspace `skills/**/SKILL.md` resolves outside the workspace root (for example symlink-chain drift), plus docs coverage in the security glossary.
|
||||
- Signal/message actions: allow `react` to fall back to `toolContext.currentMessageId` when `messageId` is omitted, matching Telegram behavior and unblocking agent-initiated reactions on inbound turns. (#32217) Thanks @dunamismax.
|
||||
- Discord/message actions: allow `react` to fall back to `toolContext.currentMessageId` when `messageId` is omitted, matching Telegram/Signal reaction ergonomics in inbound turns.
|
||||
- Gateway/OpenAI chat completions: honor `x-openclaw-message-channel` when building `agentCommand` input for `/v1/chat/completions`, preserving caller channel identity instead of forcing `webchat`. (#30462) Thanks @bmendonca3.
|
||||
- Secrets/exec resolver timeout defaults: use provider `timeoutMs` as the default inactivity (`noOutputTimeoutMs`) watchdog for exec secret providers, preventing premature no-output kills for resolvers that start producing output after 2s. (#32235) Thanks @bmendonca3.
|
||||
- Feishu/File upload filenames: percent-encode non-ASCII/special-character `file_name` values in Feishu multipart uploads so Chinese/symbol-heavy filenames are sent as proper attachments instead of plain text links. (#31179) Thanks @Kay-051.
|
||||
- Auto-reply/inline command cleanup: preserve newline structure when stripping inline `/status` and extracting inline slash commands by collapsing only horizontal whitespace, preventing paragraph flattening in multi-line replies. (#32224) Thanks @scoootscooob.
|
||||
- macOS/LaunchAgent security defaults: write `Umask=63` (octal `077`) into generated gateway launchd plists so post-update service reinstalls keep owner-only file permissions by default instead of falling back to system `022`. (#32022) Fixes #31905. Thanks @liuxiaopai-ai.
|
||||
- Daemon/macOS TLS trust defaults: set `NODE_USE_SYSTEM_CA=1` by default in gateway/node supervised service environments on macOS (while preserving explicit env overrides), so launchd-managed installs trust enterprise system keychains without manual shell env wiring. (#32205) Thanks @magos-minor.
|
||||
- Plugin SDK/runtime hardening: add package export verification in CI/release checks to catch missing runtime exports before publish-time regressions. (#28575) Thanks @Glucksberg.
|
||||
- Media understanding/provider HTTP proxy routing: pass a proxy-aware fetch function from `HTTPS_PROXY`/`HTTP_PROXY` env vars into audio/video provider calls (with graceful malformed-proxy fallback) so transcription/video requests honor configured outbound proxies. (#27093) Thanks @mcaxtr.
|
||||
- Media/MIME normalization: normalize parameterized/case-variant MIME strings in `kindFromMime` (for example `Audio/Ogg; codecs=opus`) so WhatsApp voice notes are classified as audio and routed through transcription correctly. (#32280) Thanks @Lucenx9.
|
||||
- Media/MIME channel parity: route Telegram/Signal/iMessage media-kind checks through normalized `kindFromMime` so mixed-case/parameterized MIME values classify consistently across message channels.
|
||||
- Media understanding/malformed attachment guards: harden attachment selection and decision summary formatting against non-array or malformed attachment payloads to prevent runtime crashes on invalid inbound metadata shapes. (#28024) Thanks @claw9267.
|
||||
- Media understanding/parakeet CLI output parsing: read `parakeet-mlx` transcripts from `--output-dir/<media-basename>.txt` when txt output is requested (or default), with stdout fallback for non-txt formats. (#9177) Thanks @mac-110.
|
||||
- Media understanding/audio transcription guard: skip tiny/empty audio files (<1024 bytes) before provider/CLI transcription to avoid noisy invalid-audio failures and preserve clean fallback behavior. (#8388) Thanks @Glucksberg.
|
||||
- OpenAI media capabilities: include `audio` in the OpenAI provider capability list so audio transcription models are eligible in media-understanding provider selection. (#12717) Thanks @openjay.
|
||||
- Security/Node exec approvals: preserve shell/dispatch-wrapper argv semantics during approval hardening so approved wrapper commands (for example `env sh -c ...`) cannot drift into a different runtime command shape, and add regression coverage for both approval-plan generation and approved runtime execution paths. Thanks @tdjackey for reporting.
|
||||
- Security/Node exec approvals: revalidate approval-bound `cwd` identity immediately before execution/forwarding and fail closed with an explicit denial when `cwd` drifts after approval hardening.
|
||||
- Security/ACP sandbox inheritance: enforce fail-closed runtime guardrails for `sessions_spawn` with `runtime="acp"` by rejecting ACP spawns from sandboxed requester sessions and rejecting `sandbox="require"` for ACP runtime, preventing sandbox-boundary bypass via host-side ACP initialization. (#32254) Thanks @tdjackey for reporting, and @dutifulbob for the fix.
|
||||
- Browser/Security output boundary hardening: replace check-then-rename output commits with root-bound fd-verified writes, unify install/skills canonical path-boundary checks, and add regression coverage for symlink-rebind race paths across browser output and shared fs-safe write flows. Thanks @tdjackey for reporting.
|
||||
- Security/fs-safe write hardening: make `writeFileWithinRoot` use same-directory temp writes plus atomic rename, add post-write inode/hardlink revalidation with security warnings on boundary drift, and avoid truncating existing targets when final rename fails.
|
||||
- Security/Webhook request hardening: enforce auth-before-body parsing for BlueBubbles and Google Chat webhook handlers, add strict pre-auth body/time budgets for webhook auth paths (including LINE signature verification), and add shared in-flight/request guardrails plus regression tests/lint checks to prevent reintroducing unauthenticated slow-body DoS patterns. Thanks @GCXWLP for reporting.
|
||||
- Gateway/Security hardening: tie loopback-origin dev allowance to actual local socket clients (not Host header claims), add explicit warnings/metrics when `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` accepts websocket origins, harden safe-regex detection for quantified ambiguous alternation patterns (for example `(a|aa)+`), and bound large regex-evaluation inputs for session-filter and log-redaction paths.
|
||||
- Security/Skills archive extraction: unify tar extraction safety checks across tar.gz and tar.bz2 install flows, enforce tar compressed-size limits, and fail closed if tar.bz2 archives change between preflight and extraction to prevent bypasses of entry-type/size guardrails. Thanks @GCXWLP for reporting.
|
||||
- Security/Web tools SSRF guard: keep DNS pinning for untrusted `web_fetch` and citation-redirect URL checks when proxy env vars are set, and require explicit dangerous opt-in before env-proxy routing can bypass pinned dispatch for trusted/operator-controlled endpoints. Thanks @tdjackey for reporting.
|
||||
- Security/Nodes camera URL downloads: bind node `camera.snap`/`camera.clip` URL payload downloads to the resolved node host, enforce fail-closed behavior when node `remoteIp` is unavailable, and use SSRF-guarded fetch with redirect host/protocol checks to prevent off-node fetch pivots. Thanks @tdjackey for reporting.
|
||||
- Gateway/Security canonicalization hardening: decode plugin route path variants to canonical fixpoint (with bounded depth), fail closed on canonicalization anomalies, and enforce gateway auth for deeply encoded `/api/channels/*` variants to prevent alternate-path auth bypass through plugin handlers. Thanks @tdjackey for reporting.
|
||||
- Security/Prompt spoofing hardening: stop injecting queued runtime events into user-role prompt text, route them through trusted system-prompt context, and neutralize inbound spoof markers like `[System Message]` and line-leading `System:` in untrusted message content. (#30448)
|
||||
- Auto-reply/followup queue: avoid stale callback reuse across idle-window restarts by caching the followup runner only when a drain actually starts, preserving enqueue ordering after empty-finalize paths. (#31902) Thanks @Lanfei.
|
||||
- Auto-reply/reminder guard note suppression: when a turn makes reminder-like commitments but schedules no new cron jobs, suppress the unscheduled-reminder warning note only if an enabled cron already exists for the same session; keep warnings for unrelated sessions, disabled jobs, or unreadable cron store paths. (#32255) Thanks @scoootscooob.
|
||||
- Cron/HEARTBEAT_OK summary leak: suppress fallback main-session enqueue for heartbeat/internal ack summaries in isolated announce mode so `HEARTBEAT_OK` noise never appears in user chat while real summaries still forward. (#32093) Thanks @scoootscooob.
|
||||
- Cron/isolated announce heartbeat suppression: treat multi-payload runs as skippable when any payload is a heartbeat ack token and no payload has media, preventing internal narration + trailing `HEARTBEAT_OK` from being delivered to users. (#32131) Thanks @adhishthite.
|
||||
- Sessions/lock recovery: reclaim orphan legacy same-PID lock files missing `starttime` when no in-process lock ownership exists, avoiding false lock timeouts after PID reuse while preserving active lock safety checks. (#32081) Thanks @bmendonca3.
|
||||
@@ -48,13 +103,21 @@ Docs: https://docs.openclaw.ai
|
||||
- Config/raw redaction safety: preserve non-sensitive literals during raw redaction round-trips, scope SecretRef redaction to secret IDs (not structural fields like `source`/`provider`), and fall back to structured raw redaction when text replacement cannot restore the original config shape. (#32174) Thanks @bmendonca3.
|
||||
- Models/Codex usage labels: infer weekly secondary usage windows from reset cadence when API window seconds are ambiguously reported as 24h, so `openclaw models status` no longer mislabels weekly limits as daily. (#31938) Thanks @bmendonca3.
|
||||
- Config/backups hardening: enforce owner-only (`0600`) permissions on rotated config backups and clean orphan `.bak.*` files outside the managed backup ring, reducing credential leakage risk from stale or permissive backup artifacts. (#31718) Thanks @YUJIE2002.
|
||||
- Tests/Windows backup rotation: skip chmod-only backup permission assertions on Windows while retaining compose/rotation/prune coverage across platforms to avoid false CI failures from Windows non-POSIX mode semantics. (#32286) Thanks @jalehman.
|
||||
- WhatsApp/inbound self-message context: propagate inbound `fromMe` through the web inbox pipeline and annotate direct self messages as `(self)` in envelopes so agents can distinguish owner-authored turns from contact turns. (#32167) Thanks @scoootscooob.
|
||||
- Webchat/silent token leak: filter assistant `NO_REPLY`-only transcript entries from `chat.history` responses and add client-side defense-in-depth guards in the chat controller so internal silent tokens never render as visible chat bubbles. (#32015) Consolidates overlap from #32183, #32082, #32045, #32052, #32172, and #32112. Thanks @ademczuk, @liuxiaopai-ai, @ningding97, @bmendonca3, and @x4v13r1120.
|
||||
- Exec approvals/allowlist matching: escape regex metacharacters in path-pattern literals (while preserving glob wildcards), preventing crashes on allowlisted executables like `/usr/bin/g++` and correctly matching mixed wildcard/literal token paths. (#32162) Thanks @stakeswky.
|
||||
- Agents/tool-result guard: always clear pending tool-call state on interruptions even when synthetic tool results are disabled, preventing orphaned tool-use transcripts that cause follow-up provider request failures. (#32120) Thanks @jnMetaCode.
|
||||
- Hooks/after_tool_call: include embedded session context (`sessionKey`, `agentId`) and fire the hook exactly once per tool execution by removing duplicate adapter-path dispatch in embedded runs. (#32201) Thanks @jbeno, @scoootscooob, @vincentkoc.
|
||||
- Hooks/session-scoped memory context: expose ephemeral `sessionId` in embedded plugin tool contexts and `before_tool_call`/`after_tool_call` hook contexts (including compaction and client-tool wiring) so plugins can isolate per-conversation state across `/new` and `/reset`. Related #31253 and #31304. Thanks @Sid-Qin and @Servo-AIpex.
|
||||
- Hooks/tool-call correlation: include `runId` and `toolCallId` in plugin tool hook payloads/context and scope tool start/adjusted-param tracking by run to prevent cross-run collisions in `before_tool_call` and `after_tool_call`. (#32360) Thanks @vincentkoc.
|
||||
- Webchat/stream finalization: persist streamed assistant text when final events omit `message`, while keeping final payload precedence and skipping empty stream buffers to prevent disappearing replies after tool turns. (#31920) Thanks @Sid-Qin.
|
||||
- Cron/store migration: normalize legacy cron jobs with string `schedule` and top-level `command`/`timeout` fields into canonical schedule/payload/session-target shape on load, preventing schedule-error loops on old persisted stores. (#31926) Thanks @bmendonca3.
|
||||
- Gateway/Heartbeat model reload: treat `models.*` and `agents.defaults.model` config updates as heartbeat hot-reload triggers so heartbeat picks up model changes without a full gateway restart. (#32046) Thanks @stakeswky.
|
||||
- Gateway/Webchat NO_REPLY streaming: suppress assistant lead-fragment deltas that are prefixes of `NO_REPLY` and keep final-message buffering in sync, preventing partial `NO` leaks on silent-response runs while preserving legitimate short replies. (#32073) Thanks @liuxiaopai-ai.
|
||||
- Tools/fsPolicy propagation: honor `tools.fs.workspaceOnly` for image/pdf local-root allowlists so non-sandbox media paths outside workspace are rejected when workspace-only mode is enabled. (#31882) Thanks @justinhuangcode.
|
||||
- Daemon/Homebrew runtime pinning: resolve Homebrew Cellar Node paths to stable Homebrew-managed symlinks (including versioned formulas like `node@22`) so gateway installs keep the intended runtime across brew upgrades. (#32185) Thanks @scoootscooob.
|
||||
- Discord/audio preflight mentions: detect audio attachments via Discord `content_type` and gate preflight transcription on typed text (not media placeholders), so guild voice-note mentions are transcribed and matched correctly. (#32136) Thanks @jnMetaCode.
|
||||
- Memory/LanceDB embeddings: forward configured `embedding.dimensions` into OpenAI embeddings requests so vector size and API output dimensions stay aligned when dimensions are explicitly configured. (#32036) Thanks @scotthuang.
|
||||
- Failover/error classification: treat HTTP `529` (provider overloaded, common with Anthropic-compatible APIs) as `rate_limit` so model failover can engage instead of misclassifying the error path. (#31854) Thanks @bugkill3r.
|
||||
- Plugin command/runtime hardening: validate and normalize plugin command name/description at registration boundaries, and guard Telegram native menu normalization paths so malformed plugin command specs cannot crash startup (`trim` on undefined). (#31997) Fixes #31944. Thanks @liuxiaopai-ai.
|
||||
@@ -75,6 +138,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/Subagents `sessions_spawn`: reject malformed `agentId` inputs before normalization (for example error-message/path-like strings) to prevent unintended synthetic agent IDs and ghost workspace/session paths; includes strict validation regression coverage. (#31381) Thanks @openperf.
|
||||
- Webchat/Feishu session continuation: preserve routable `OriginatingChannel`/`OriginatingTo` metadata from session delivery context in `chat.send`, and prefer provider-normalized channel when deciding cross-channel route dispatch so Webchat replies continue on the selected Feishu session instead of falling back to main/internal session routing. (#31573)
|
||||
- Pairing/AllowFrom account fallback: handle omitted `accountId` values in `readChannelAllowFromStore` and `readChannelAllowFromStoreSync` as `default`, while preserving legacy unscoped allowFrom merges for default-account flows. Thanks @Sid-Qin and @vincentkoc.
|
||||
- Control UI/Legacy browser compatibility: replace `toSorted`-dependent cron suggestion sorting in `app-render` with a compatibility helper so older browsers without `Array.prototype.toSorted` no longer white-screen. (#31775) Thanks @liuxiaopai-ai.
|
||||
- Agents/Sandbox workdir mapping: map container workdir paths (for example `/workspace`) back to the host workspace before sandbox path validation so exec requests keep the intended directory in containerized runs instead of falling back to an unavailable host path. (#31841) Thanks @liuxiaopai-ai.
|
||||
- Agents/Subagent announce cleanup: keep completion-message runs pending while descendants settle, add a 30 minute hard-expiry backstop to avoid indefinite pending state, and keep retry bookkeeping resumable across deferred wakes. (#23970) Thanks @tyler6204.
|
||||
- Gateway/Control UI method guard: allow POST requests to non-UI routes to fall through when no base path is configured, and add POST regression coverage for fallthrough and base-path 405 behavior. (#23970) Thanks @tyler6204.
|
||||
@@ -90,6 +154,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Browser/Act request compatibility: accept legacy flattened `action="act"` params (`kind/ref/text/...`) in addition to `request={...}` so browser act calls no longer fail with `request required`. (#15120) Thanks @vincentkoc.
|
||||
- Browser/Extension relay stale tabs: evict stale cached targets from `/json/list` when extension targets are destroyed/crashed or commands fail with missing target/session errors. (#6175) Thanks @vincentkoc.
|
||||
- CLI/Browser start timeout: honor `openclaw browser --timeout <ms> start` and stop by removing the fixed 15000ms override so slower Chrome startups can use caller-provided timeouts. (#22412, #23427) Thanks @vincentkoc.
|
||||
- Browser/CDP status accuracy: require a successful `Browser.getVersion` response over the CDP websocket (not just socket-open) before reporting `cdpReady`, so stale idle command channels are surfaced as unhealthy. (#23427) Thanks @vincentkoc.
|
||||
- Browser/CDP startup diagnostics: include Chrome stderr output and a Linux no-sandbox hint in startup timeout errors so failed launches are easier to diagnose. (#29312) Thanks @veast.
|
||||
- Browser/CDP startup readiness: wait for CDP websocket readiness after launching Chrome and cleanly stop/reset when readiness never arrives, reducing follow-up `PortInUseError` races after `browser start`/`open`. (#29538) Thanks @AaronWander.
|
||||
- Browser/Managed tab cap: limit loopback managed `openclaw` page tabs to 8 via best-effort cleanup after tab opens to reduce long-running renderer buildup while preserving attach-only and remote profile behavior. (#29724) Thanks @pandego.
|
||||
@@ -108,6 +173,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Synology Chat/gateway lifecycle: keep `startAccount` pending until abort for inactive and active account paths to prevent webhook route restart loops under gateway supervision. (#23074) Thanks @druide67.
|
||||
- Discord/dispatch + Slack formatting: restore parallel outbound dispatch across Discord channels with per-channel queues while preserving in-channel ordering, and run Slack preview/stream update text through mrkdwn normalization for consistent formatting. (#31927) Thanks @Sid-Qin.
|
||||
- Telegram/inbound media filenames: preserve original `file_name` metadata for document/audio/video/animation downloads (with fetch/path fallbacks), so saved inbound attachments keep sender-provided names instead of opaque Telegram file paths. (#31837) Thanks @Kay-051.
|
||||
- Telegram/implicit mention forum handling: exclude Telegram forum system service messages (`forum_topic_*`, `general_forum_topic_*`) from reply-chain implicit mention detection so `requireMention` does not get bypassed inside bot-created topic lifecycle events. (#32262) Thanks @scoootscooob.
|
||||
- Telegram/models picker callbacks: keep long model buttons selectable by falling back to compact callback payloads and resolving provider ids on selection (with provider re-prompt on ambiguity), avoiding Telegram 64-byte callback truncation failures. (#31857) Thanks @bmendonca3.
|
||||
- WhatsApp/inbound self-message context: propagate inbound `fromMe` through the web inbox pipeline and annotate direct self messages as `(self)` in envelopes so agents can distinguish owner-authored turns from contact turns. (#32167) Thanks @scoootscooob.
|
||||
- Slack/thread context payloads: only inject thread starter/history text on first thread turn for new sessions while preserving thread metadata, reducing repeated context-token bloat on long-lived thread sessions. (#32133) Thanks @sourman.
|
||||
@@ -125,6 +191,11 @@ Docs: https://docs.openclaw.ai
|
||||
- macOS/PeekabooBridge: add compatibility socket symlinks for legacy `clawdbot`, `clawdis`, and `moltbot` Application Support socket paths so pre-rename clients can still connect. (#6033) Thanks @lumpinif and @vincentkoc.
|
||||
- Feishu/Duplicate replies: suppress same-target reply dispatch when message-tool sends use generic provider metadata (`provider: "message"`) and normalize `lark`/`feishu` provider aliases during duplicate-target checks, preventing double-delivery in Feishu sessions. (#31526)
|
||||
- Feishu/Plugin sdk compatibility: add safe webhook default fallbacks when loading Feishu monitor state so mixed-version installs no longer crash if older `openclaw/plugin-sdk` builds omit webhook default constants. (#31606)
|
||||
- Feishu/Inbound debounce: debounce rapid same-chat sender bursts into one ordered dispatch turn, skip already-processed retries when composing merged text, and preserve bot-mention intent across merged entries to reduce duplicate or late inbound handling. (#31548)
|
||||
- Feishu/Inbound ordering: serialize message handling per chat while preserving cross-chat concurrency to avoid same-chat race drops under bursty inbound traffic. (#31807)
|
||||
- Feishu/Dedup restart resilience: warm persistent dedup state into memory on monitor startup so retry events after gateway restart stay suppressed without requiring initial on-disk probe misses. (#31605)
|
||||
- Feishu/Typing notification suppression: skip typing keepalive reaction re-adds when the indicator is already active, preventing duplicate notification pings from repeated identical emoji adds. (#31580)
|
||||
- Feishu/Probe failure backoff: cache API and timeout probe failures for one minute per account key while preserving abort-aware probe timeouts, reducing repeated health-check retries during transient credential/network outages. (#29970)
|
||||
- BlueBubbles/Message metadata: harden send response ID extraction, include sender identity in DM context, and normalize inbound `message_id` selection to avoid duplicate ID metadata. (#23970) Thanks @tyler6204.
|
||||
- Docker/Image health checks: add Dockerfile `HEALTHCHECK` that probes gateway `GET /healthz` so container runtimes can mark unhealthy instances without requiring auth credentials in the probe command. (#11478) Thanks @U-C4N and @vincentkoc.
|
||||
- Docker/Sandbox bootstrap hardening: make `OPENCLAW_SANDBOX` opt-in parsing explicit (`1|true|yes|on`), support custom Docker socket paths via `OPENCLAW_DOCKER_SOCKET`, defer docker.sock exposure until sandbox prerequisites pass, and reset/roll back persisted sandbox mode to `off` when setup is skipped or partially fails to avoid stale broken sandbox state. (#29974) Thanks @jamtujest and @vincentkoc.
|
||||
@@ -171,6 +242,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Security/Feishu webhook ingress: bound unauthenticated webhook rate-limit state with stale-window pruning and a hard key cap to prevent unbounded pre-auth memory growth from rotating source keys. (#26050) Thanks @bmendonca3.
|
||||
- Security/Compaction audit: remove the post-compaction audit injection message. (#28507) Thanks @fuller-stack-dev and @vincentkoc.
|
||||
- Web tools/RFC2544 fake-IP compatibility: allow RFC2544 benchmark range (`198.18.0.0/15`) for trusted web-tool fetch endpoints so proxy fake-IP networking modes do not trigger false SSRF blocks. Landed from contributor PR #31176 by @sunkinux. Thanks @sunkinux.
|
||||
- Feishu/Sessions announce group targets: normalize `group:` and `channel:` Feishu targets to `chat_id` routing so `sessions_send` announce delivery no longer sends group chat IDs via `user_id` API params. Fixes #31426.
|
||||
- Windows/Plugin install: avoid `spawn EINVAL` on Windows npm/npx invocations by resolving to `node` + npm CLI scripts instead of spawning `.cmd` directly. Landed from contributor PR #31147 by @codertony. Thanks @codertony.
|
||||
- Web UI/Cron: include configured agent model defaults/fallbacks in cron model suggestions so scheduled-job model autocomplete reflects configured models. (#29709) Thanks @Sid-Qin.
|
||||
- Cron/Delivery: disable the agent messaging tool when `delivery.mode` is `"none"` so cron output is not sent to Telegram or other channels. (#21808) Thanks @lailoo.
|
||||
|
||||
@@ -40,7 +40,7 @@ New install? Start here: [Getting started](https://docs.openclaw.ai/start/gettin
|
||||
|
||||
- **[OpenAI](https://openai.com/)** (ChatGPT/Codex)
|
||||
|
||||
Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.6** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.openclaw.ai/start/onboarding).
|
||||
Model note: while many providers/models are supported, for the best experience and lower prompt-injection risk use the strongest latest-generation model available to you. See [Onboarding](https://docs.openclaw.ai/start/onboarding).
|
||||
|
||||
## Models (selection + auth)
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ These are frequently reported but are typically closed with no code change:
|
||||
- Reports that only show differences in heuristic detection/parity (for example obfuscation-pattern detection on one exec path but not another, such as `node.invoke -> system.run` parity gaps) without demonstrating bypass of auth, approvals, allowlist enforcement, sandboxing, or other documented trust boundaries.
|
||||
- ReDoS/DoS claims that require trusted operator configuration input (for example catastrophic regex in `sessionFilter` or `logging.redactPatterns`) without a trust-boundary bypass.
|
||||
- Archive/install extraction claims that require pre-existing local filesystem priming in trusted state (for example planting symlink/hardlink aliases under destination directories such as skills/tools paths) without showing an untrusted path that can create/control that primitive.
|
||||
- Reports that depend on replacing or rewriting an already-approved executable path on a trusted host (same-path inode/content swap) without showing an untrusted path to perform that write.
|
||||
- Reports that depend on pre-existing symlinked skill/workspace filesystem state (for example symlink chains involving `skills/*/SKILL.md`) without showing an untrusted path that can create/control that state.
|
||||
- Missing HSTS findings on default local/loopback deployments.
|
||||
- Slack webhook signature findings when HTTP mode already uses signing-secret verification.
|
||||
- Discord inbound webhook signature findings for paths not used by this repo's Discord integration.
|
||||
@@ -114,6 +116,8 @@ Plugins/extensions are part of OpenClaw's trusted computing base for a gateway.
|
||||
- Prompt-injection-only attacks (without a policy/auth/sandbox boundary bypass)
|
||||
- Reports that require write access to trusted local state (`~/.openclaw`, workspace files like `MEMORY.md` / `memory/*.md`)
|
||||
- Reports where exploitability depends on attacker-controlled pre-existing symlink/hardlink filesystem state in trusted local paths (for example extraction/install target trees) unless a separate untrusted boundary bypass is shown that creates that state.
|
||||
- Reports whose only claim is sandbox/workspace read expansion through trusted local skill/workspace symlink state (for example `skills/*/SKILL.md` symlink chains) unless a separate untrusted boundary bypass is shown that creates/controls that state.
|
||||
- Reports whose only claim is post-approval executable identity drift on a trusted host via same-path file replacement/rewrite unless a separate untrusted boundary bypass is shown for that host write primitive.
|
||||
- Reports where the only demonstrated impact is an already-authorized sender intentionally invoking a local-action command (for example `/export-session` writing to an absolute host path) without bypassing auth, sandbox, or another documented boundary
|
||||
- Reports where the only claim is that a trusted-installed/enabled plugin can execute with gateway/host privileges (documented trust model behavior).
|
||||
- Any report whose only claim is that an operator-enabled `dangerous*`/`dangerously*` config option weakens defaults (these are explicit break-glass tradeoffs by design)
|
||||
|
||||
1
changelog/fragments/pr-21208.md
Normal file
1
changelog/fragments/pr-21208.md
Normal file
@@ -0,0 +1 @@
|
||||
- Tlon plugin: sync upstream account/settings workflows, restore SSRF-safe media + SSE fetch paths, and improve invite/approval handling reliability. (#21208) (thanks @arthyn)
|
||||
@@ -258,7 +258,9 @@ Triggered when the gateway starts:
|
||||
Triggered when messages are received or sent:
|
||||
|
||||
- **`message`**: All message events (general listener)
|
||||
- **`message:received`**: When an inbound message is received from any channel
|
||||
- **`message:received`**: When an inbound message is received from any channel. Fires early in processing before media understanding. Content may contain raw placeholders like `<media:audio>` for media attachments that haven't been processed yet.
|
||||
- **`message:transcribed`**: When a message has been fully processed, including audio transcription and link understanding. At this point, `transcript` contains the full transcript text for audio messages. Use this hook when you need access to transcribed audio content.
|
||||
- **`message:preprocessed`**: Fires for every message after all media + link understanding completes, giving hooks access to the fully enriched body (transcripts, image descriptions, link summaries) before the agent sees it.
|
||||
- **`message:sent`**: When an outbound message is successfully sent
|
||||
|
||||
#### Message Event Context
|
||||
@@ -297,6 +299,30 @@ Message events include rich context about the message:
|
||||
accountId?: string, // Provider account ID
|
||||
conversationId?: string, // Chat/conversation ID
|
||||
messageId?: string, // Message ID returned by the provider
|
||||
isGroup?: boolean, // Whether this outbound message belongs to a group/channel context
|
||||
groupId?: string, // Group/channel identifier for correlation with message:received
|
||||
}
|
||||
|
||||
// message:transcribed context
|
||||
{
|
||||
body?: string, // Raw inbound body before enrichment
|
||||
bodyForAgent?: string, // Enriched body visible to the agent
|
||||
transcript: string, // Audio transcript text
|
||||
channelId: string, // Channel (e.g., "telegram", "whatsapp")
|
||||
conversationId?: string,
|
||||
messageId?: string,
|
||||
}
|
||||
|
||||
// message:preprocessed context
|
||||
{
|
||||
body?: string, // Raw inbound body
|
||||
bodyForAgent?: string, // Final enriched body after media/link understanding
|
||||
transcript?: string, // Transcript when audio was present
|
||||
channelId: string, // Channel (e.g., "telegram", "whatsapp")
|
||||
conversationId?: string,
|
||||
messageId?: string,
|
||||
isGroup?: boolean,
|
||||
groupId?: string,
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -159,7 +159,7 @@ Mapping options (summary):
|
||||
## Responses
|
||||
|
||||
- `200` for `/hooks/wake`
|
||||
- `202` for `/hooks/agent` (async run started)
|
||||
- `200` for `/hooks/agent` (async run accepted)
|
||||
- `401` on auth failure
|
||||
- `429` after repeated auth failures from the same client (check `Retry-After`)
|
||||
- `400` on invalid payload
|
||||
|
||||
@@ -944,6 +944,7 @@ Auto-join example:
|
||||
Notes:
|
||||
|
||||
- `voice.tts` overrides `messages.tts` for voice playback only.
|
||||
- Voice transcript turns derive owner status from Discord `allowFrom` (or `dm.allowFrom`); non-owner speakers cannot access owner-only tools (for example `gateway` and `cron`).
|
||||
- Voice is enabled by default; set `channels.discord.voice.enabled=false` to disable it.
|
||||
- `voice.daveEncryption` and `voice.decryptionFailureTolerance` pass through to `@discordjs/voice` join options.
|
||||
- `@discordjs/voice` defaults are `daveEncryption=true` and `decryptionFailureTolerance=24` if unset.
|
||||
|
||||
@@ -230,23 +230,31 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
## Feature reference
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Live stream preview (message edits)">
|
||||
OpenClaw can stream partial replies by sending a temporary Telegram message and editing it as text arrives.
|
||||
<Accordion title="Live stream preview (native drafts + message edits)">
|
||||
OpenClaw can stream partial replies in real time:
|
||||
|
||||
- direct chats: Telegram native draft streaming via `sendMessageDraft`
|
||||
- groups/topics: preview message + `editMessageText`
|
||||
|
||||
Requirement:
|
||||
|
||||
- `channels.telegram.streaming` is `off | partial | block | progress` (default: `off`)
|
||||
- `channels.telegram.streaming` is `off | partial | block | progress` (default: `partial`)
|
||||
- `progress` maps to `partial` on Telegram (compat with cross-channel naming)
|
||||
- legacy `channels.telegram.streamMode` and boolean `streaming` values are auto-mapped
|
||||
|
||||
This works in direct chats and groups/topics.
|
||||
Telegram enabled `sendMessageDraft` for all bots in Bot API 9.5 (March 1, 2026).
|
||||
|
||||
For text-only replies, OpenClaw keeps the same preview message and performs a final edit in place (no second message).
|
||||
For text-only replies:
|
||||
|
||||
- DM: OpenClaw updates the draft in place (no extra preview message)
|
||||
- group/topic: OpenClaw keeps the same preview message and performs a final edit in place (no second message)
|
||||
|
||||
For complex replies (for example media payloads), OpenClaw falls back to normal final delivery and then cleans up the preview message.
|
||||
|
||||
Preview streaming is separate from block streaming. When block streaming is explicitly enabled for Telegram, OpenClaw skips the preview stream to avoid double-streaming.
|
||||
|
||||
If native draft transport is unavailable/rejected, OpenClaw automatically falls back to `sendMessage` + `editMessageText`.
|
||||
|
||||
Telegram-only reasoning stream:
|
||||
|
||||
- `/reasoning stream` sends reasoning to the live preview while generating
|
||||
@@ -751,7 +759,7 @@ Primary reference:
|
||||
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
|
||||
- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
||||
- `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true).
|
||||
- `channels.telegram.streaming`: `off | partial | block | progress` (live stream preview; default: `off`; `progress` maps to `partial`; `block` is legacy preview mode compatibility).
|
||||
- `channels.telegram.streaming`: `off | partial | block | progress` (live stream preview; default: `partial`; `progress` maps to `partial`; `block` is legacy preview mode compatibility). In DMs, `partial` uses native `sendMessageDraft` when available.
|
||||
- `channels.telegram.mediaMaxMb`: inbound Telegram media download/processing cap (MB).
|
||||
- `channels.telegram.retry`: retry policy for Telegram send helpers (CLI/tools/actions) on recoverable outbound API errors (attempts, minDelayMs, maxDelayMs, jitter).
|
||||
- `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to enabled on Node 22+, with WSL2 defaulting to disabled.
|
||||
|
||||
@@ -11,8 +11,8 @@ Tlon is a decentralized messenger built on Urbit. OpenClaw connects to your Urbi
|
||||
respond to DMs and group chat messages. Group replies require an @ mention by default and can
|
||||
be further restricted via allowlists.
|
||||
|
||||
Status: supported via plugin. DMs, group mentions, thread replies, and text-only media fallback
|
||||
(URL appended to caption). Reactions, polls, and native media uploads are not supported.
|
||||
Status: supported via plugin. DMs, group mentions, thread replies, rich text formatting, and
|
||||
image uploads are supported. Reactions and polls are not yet supported.
|
||||
|
||||
## Plugin required
|
||||
|
||||
@@ -50,27 +50,38 @@ Minimal config (single account):
|
||||
ship: "~sampel-palnet",
|
||||
url: "https://your-ship-host",
|
||||
code: "lidlut-tabwed-pillex-ridrup",
|
||||
ownerShip: "~your-main-ship", // recommended: your ship, always allowed
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Private/LAN ship URLs (advanced):
|
||||
## Private/LAN ships
|
||||
|
||||
By default, OpenClaw blocks private/internal hostnames and IP ranges for this plugin (SSRF hardening).
|
||||
If your ship URL is on a private network (for example `http://192.168.1.50:8080` or `http://localhost:8080`),
|
||||
By default, OpenClaw blocks private/internal hostnames and IP ranges for SSRF protection.
|
||||
If your ship is running on a private network (localhost, LAN IP, or internal hostname),
|
||||
you must explicitly opt in:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
tlon: {
|
||||
url: "http://localhost:8080",
|
||||
allowPrivateNetwork: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
This applies to URLs like:
|
||||
|
||||
- `http://localhost:8080`
|
||||
- `http://192.168.x.x:8080`
|
||||
- `http://my-ship.local:8080`
|
||||
|
||||
⚠️ Only enable this if you trust your local network. This setting disables SSRF protections
|
||||
for requests to your ship URL.
|
||||
|
||||
## Group channels
|
||||
|
||||
Auto-discovery is enabled by default. You can also pin channels manually:
|
||||
@@ -99,7 +110,7 @@ Disable auto-discovery:
|
||||
|
||||
## Access control
|
||||
|
||||
DM allowlist (empty = allow all):
|
||||
DM allowlist (empty = no DMs allowed, use `ownerShip` for approval flow):
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -134,6 +145,56 @@ Group authorization (restricted by default):
|
||||
}
|
||||
```
|
||||
|
||||
## Owner and approval system
|
||||
|
||||
Set an owner ship to receive approval requests when unauthorized users try to interact:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
tlon: {
|
||||
ownerShip: "~your-main-ship",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The owner ship is **automatically authorized everywhere** — DM invites are auto-accepted and
|
||||
channel messages are always allowed. You don't need to add the owner to `dmAllowlist` or
|
||||
`defaultAuthorizedShips`.
|
||||
|
||||
When set, the owner receives DM notifications for:
|
||||
|
||||
- DM requests from ships not in the allowlist
|
||||
- Mentions in channels without authorization
|
||||
- Group invite requests
|
||||
|
||||
## Auto-accept settings
|
||||
|
||||
Auto-accept DM invites (for ships in dmAllowlist):
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
tlon: {
|
||||
autoAcceptDmInvites: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Auto-accept group invites:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
tlon: {
|
||||
autoAcceptGroupInvites: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Delivery targets (CLI/cron)
|
||||
|
||||
Use these with `openclaw message send` or cron delivery:
|
||||
@@ -141,8 +202,75 @@ Use these with `openclaw message send` or cron delivery:
|
||||
- DM: `~sampel-palnet` or `dm/~sampel-palnet`
|
||||
- Group: `chat/~host-ship/channel` or `group:~host-ship/channel`
|
||||
|
||||
## Bundled skill
|
||||
|
||||
The Tlon plugin includes a bundled skill ([`@tloncorp/tlon-skill`](https://github.com/tloncorp/tlon-skill))
|
||||
that provides CLI access to Tlon operations:
|
||||
|
||||
- **Contacts**: get/update profiles, list contacts
|
||||
- **Channels**: list, create, post messages, fetch history
|
||||
- **Groups**: list, create, manage members
|
||||
- **DMs**: send messages, react to messages
|
||||
- **Reactions**: add/remove emoji reactions to posts and DMs
|
||||
- **Settings**: manage plugin permissions via slash commands
|
||||
|
||||
The skill is automatically available when the plugin is installed.
|
||||
|
||||
## Capabilities
|
||||
|
||||
| Feature | Status |
|
||||
| --------------- | --------------------------------------- |
|
||||
| Direct messages | ✅ Supported |
|
||||
| Groups/channels | ✅ Supported (mention-gated by default) |
|
||||
| Threads | ✅ Supported (auto-replies in thread) |
|
||||
| Rich text | ✅ Markdown converted to Tlon format |
|
||||
| Images | ✅ Uploaded to Tlon storage |
|
||||
| Reactions | ✅ Via [bundled skill](#bundled-skill) |
|
||||
| Polls | ❌ Not yet supported |
|
||||
| Native commands | ✅ Supported (owner-only by default) |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Run this ladder first:
|
||||
|
||||
```bash
|
||||
openclaw status
|
||||
openclaw gateway status
|
||||
openclaw logs --follow
|
||||
openclaw doctor
|
||||
```
|
||||
|
||||
Common failures:
|
||||
|
||||
- **DMs ignored**: sender not in `dmAllowlist` and no `ownerShip` configured for approval flow.
|
||||
- **Group messages ignored**: channel not discovered or sender not authorized.
|
||||
- **Connection errors**: check ship URL is reachable; enable `allowPrivateNetwork` for local ships.
|
||||
- **Auth errors**: verify login code is current (codes rotate).
|
||||
|
||||
## Configuration reference
|
||||
|
||||
Full configuration: [Configuration](/gateway/configuration)
|
||||
|
||||
Provider options:
|
||||
|
||||
- `channels.tlon.enabled`: enable/disable channel startup.
|
||||
- `channels.tlon.ship`: bot's Urbit ship name (e.g. `~sampel-palnet`).
|
||||
- `channels.tlon.url`: ship URL (e.g. `https://sampel-palnet.tlon.network`).
|
||||
- `channels.tlon.code`: ship login code.
|
||||
- `channels.tlon.allowPrivateNetwork`: allow localhost/LAN URLs (SSRF bypass).
|
||||
- `channels.tlon.ownerShip`: owner ship for approval system (always authorized).
|
||||
- `channels.tlon.dmAllowlist`: ships allowed to DM (empty = none).
|
||||
- `channels.tlon.autoAcceptDmInvites`: auto-accept DMs from allowlisted ships.
|
||||
- `channels.tlon.autoAcceptGroupInvites`: auto-accept all group invites.
|
||||
- `channels.tlon.autoDiscoverChannels`: auto-discover group channels (default: true).
|
||||
- `channels.tlon.groupChannels`: manually pinned channel nests.
|
||||
- `channels.tlon.defaultAuthorizedShips`: ships authorized for all channels.
|
||||
- `channels.tlon.authorization.channelRules`: per-channel auth rules.
|
||||
- `channels.tlon.showModelSignature`: append model name to messages.
|
||||
|
||||
## Notes
|
||||
|
||||
- Group replies require a mention (e.g. `~your-bot-ship`) to respond.
|
||||
- Thread replies: if the inbound message is in a thread, OpenClaw replies in-thread.
|
||||
- Media: `sendMedia` falls back to text + URL (no native upload).
|
||||
- Rich text: Markdown formatting (bold, italic, code, headers, lists) is converted to Tlon's native format.
|
||||
- Images: URLs are uploaded to Tlon storage and embedded as image blocks.
|
||||
|
||||
@@ -828,7 +828,7 @@ Tip: when calling `config.set`/`config.apply`/`config.patch` directly, pass `bas
|
||||
|
||||
See [/concepts/models](/concepts/models) for fallback behavior and scanning strategy.
|
||||
|
||||
Preferred Anthropic auth (setup-token):
|
||||
Anthropic setup-token (supported):
|
||||
|
||||
```bash
|
||||
claude setup-token
|
||||
@@ -836,6 +836,10 @@ openclaw models auth setup-token --provider anthropic
|
||||
openclaw models status
|
||||
```
|
||||
|
||||
Policy note: this is technical compatibility. Anthropic has blocked some
|
||||
subscription usage outside Claude Code in the past; verify current Anthropic
|
||||
terms before relying on setup-token in production.
|
||||
|
||||
### `models` (root)
|
||||
|
||||
`openclaw models` is an alias for `models status`.
|
||||
|
||||
@@ -77,3 +77,4 @@ Notes:
|
||||
|
||||
- `setup-token` prompts for a setup-token value (generate it with `claude setup-token` on any machine).
|
||||
- `paste-token` accepts a token string generated elsewhere or from automation.
|
||||
- Anthropic policy note: setup-token support is technical compatibility. Anthropic has blocked some subscription usage outside Claude Code in the past, so verify current terms before using it broadly.
|
||||
|
||||
@@ -109,6 +109,8 @@ Defaults:
|
||||
6. Otherwise memory search stays disabled until configured.
|
||||
- Local mode uses node-llama-cpp and may require `pnpm approve-builds`.
|
||||
- Uses sqlite-vec (when available) to accelerate vector search inside SQLite.
|
||||
- `memorySearch.provider = "ollama"` is also supported for local/self-hosted
|
||||
Ollama embeddings (`/api/embeddings`), but it is not auto-selected.
|
||||
|
||||
Remote embeddings **require** an API key for the embedding provider. OpenClaw
|
||||
resolves keys from auth profiles, `models.providers.*.apiKey`, or environment
|
||||
@@ -116,7 +118,9 @@ variables. Codex OAuth only covers chat/completions and does **not** satisfy
|
||||
embeddings for memory search. For Gemini, use `GEMINI_API_KEY` or
|
||||
`models.providers.google.apiKey`. For Voyage, use `VOYAGE_API_KEY` or
|
||||
`models.providers.voyage.apiKey`. For Mistral, use `MISTRAL_API_KEY` or
|
||||
`models.providers.mistral.apiKey`.
|
||||
`models.providers.mistral.apiKey`. Ollama typically does not require a real API
|
||||
key (a placeholder like `OLLAMA_API_KEY=ollama-local` is enough when needed by
|
||||
local policy).
|
||||
When using a custom OpenAI-compatible endpoint,
|
||||
set `memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`).
|
||||
|
||||
@@ -331,7 +335,7 @@ If you don't want to set an API key, use `memorySearch.provider = "local"` or se
|
||||
|
||||
Fallbacks:
|
||||
|
||||
- `memorySearch.fallback` can be `openai`, `gemini`, `voyage`, `mistral`, `local`, or `none`.
|
||||
- `memorySearch.fallback` can be `openai`, `gemini`, `voyage`, `mistral`, `ollama`, `local`, or `none`.
|
||||
- The fallback provider is only used when the primary embedding provider fails.
|
||||
|
||||
Batch indexing (OpenAI + Gemini + Voyage):
|
||||
|
||||
@@ -83,6 +83,9 @@ When a profile fails due to auth/rate‑limit errors (or a timeout that looks
|
||||
like rate limiting), OpenClaw marks it in cooldown and moves to the next profile.
|
||||
Format/invalid‑request errors (for example Cloud Code Assist tool call ID
|
||||
validation failures) are treated as failover‑worthy and use the same cooldowns.
|
||||
OpenAI-compatible stop-reason errors such as `Unhandled stop reason: error`,
|
||||
`stop reason: error`, and `reason: error` are classified as timeout/failover
|
||||
signals.
|
||||
|
||||
Cooldowns use exponential backoff:
|
||||
|
||||
|
||||
@@ -60,6 +60,8 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no**
|
||||
- Optional rotation: `ANTHROPIC_API_KEYS`, `ANTHROPIC_API_KEY_1`, `ANTHROPIC_API_KEY_2`, plus `OPENCLAW_LIVE_ANTHROPIC_KEY` (single override)
|
||||
- Example model: `anthropic/claude-opus-4-6`
|
||||
- CLI: `openclaw onboard --auth-choice token` (paste setup-token) or `openclaw models auth paste-token --provider anthropic`
|
||||
- Policy note: setup-token support is technical compatibility; Anthropic has blocked some subscription usage outside Claude Code in the past. Verify current Anthropic terms and decide based on your risk tolerance.
|
||||
- Recommendation: Anthropic API key auth is the safer, recommended path over subscription setup-token auth.
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -75,6 +77,7 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no**
|
||||
- CLI: `openclaw onboard --auth-choice openai-codex` or `openclaw models auth login --provider openai-codex`
|
||||
- Default transport is `auto` (WebSocket-first, SSE fallback)
|
||||
- Override per model via `agents.defaults.models["openai-codex/<model>"].params.transport` (`"sse"`, `"websocket"`, or `"auto"`)
|
||||
- Policy note: OpenAI Codex OAuth is explicitly supported for external tools/workflows like OpenClaw.
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -121,7 +124,7 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no**
|
||||
|
||||
- Provider: `zai`
|
||||
- Auth: `ZAI_API_KEY`
|
||||
- Example model: `zai/glm-4.7`
|
||||
- Example model: `zai/glm-5`
|
||||
- CLI: `openclaw onboard --auth-choice zai-api-key`
|
||||
- Aliases: `z.ai/*` and `z-ai/*` normalize to `zai/*`
|
||||
|
||||
@@ -175,14 +178,20 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider:
|
||||
|
||||
Kimi K2 model IDs:
|
||||
|
||||
{/_moonshot-kimi-k2-model-refs:start_/ && null}
|
||||
<!-- markdownlint-disable MD037 -->
|
||||
|
||||
{/_ moonshot-kimi-k2-model-refs:start _/ && null}
|
||||
|
||||
<!-- markdownlint-enable MD037 -->
|
||||
|
||||
- `moonshot/kimi-k2.5`
|
||||
- `moonshot/kimi-k2-0905-preview`
|
||||
- `moonshot/kimi-k2-turbo-preview`
|
||||
- `moonshot/kimi-k2-thinking`
|
||||
- `moonshot/kimi-k2-thinking-turbo`
|
||||
{/_moonshot-kimi-k2-model-refs:end_/ && null}
|
||||
<!-- markdownlint-disable MD037 -->
|
||||
{/_ moonshot-kimi-k2-model-refs:end _/ && null}
|
||||
<!-- markdownlint-enable MD037 -->
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -307,13 +316,13 @@ Synthetic provides Anthropic-compatible models behind the `synthetic` provider:
|
||||
|
||||
- Provider: `synthetic`
|
||||
- Auth: `SYNTHETIC_API_KEY`
|
||||
- Example model: `synthetic/hf:MiniMaxAI/MiniMax-M2.1`
|
||||
- Example model: `synthetic/hf:MiniMaxAI/MiniMax-M2.5`
|
||||
- CLI: `openclaw onboard --auth-choice synthetic-api-key`
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: { model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.1" } },
|
||||
defaults: { model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.5" } },
|
||||
},
|
||||
models: {
|
||||
mode: "merge",
|
||||
@@ -322,7 +331,7 @@ Synthetic provides Anthropic-compatible models behind the `synthetic` provider:
|
||||
baseUrl: "https://api.synthetic.new/anthropic",
|
||||
apiKey: "${SYNTHETIC_API_KEY}",
|
||||
api: "anthropic-messages",
|
||||
models: [{ id: "hf:MiniMaxAI/MiniMax-M2.1", name: "MiniMax M2.1" }],
|
||||
models: [{ id: "hf:MiniMaxAI/MiniMax-M2.5", name: "MiniMax M2.5" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -396,8 +405,8 @@ Example (OpenAI‑compatible):
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "lmstudio/minimax-m2.1-gs32" },
|
||||
models: { "lmstudio/minimax-m2.1-gs32": { alias: "Minimax" } },
|
||||
model: { primary: "lmstudio/minimax-m2.5-gs32" },
|
||||
models: { "lmstudio/minimax-m2.5-gs32": { alias: "Minimax" } },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
@@ -408,8 +417,8 @@ Example (OpenAI‑compatible):
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "minimax-m2.1-gs32",
|
||||
name: "MiniMax M2.1",
|
||||
id: "minimax-m2.5-gs32",
|
||||
name: "MiniMax M2.5",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
@@ -433,6 +442,9 @@ Notes:
|
||||
- `contextWindow: 200000`
|
||||
- `maxTokens: 8192`
|
||||
- Recommended: set explicit values that match your proxy/model limits.
|
||||
- For `api: "openai-completions"` on non-native endpoints (any non-empty `baseUrl` whose host is not `api.openai.com`), OpenClaw forces `compat.supportsDeveloperRole: false` to avoid provider 400 errors for unsupported `developer` roles.
|
||||
- If `baseUrl` is empty/omitted, OpenClaw keeps the default OpenAI behavior (which resolves to `api.openai.com`).
|
||||
- For safety, an explicit `compat.supportsDeveloperRole: true` is still overridden on non-native `openai-completions` endpoints.
|
||||
|
||||
## CLI examples
|
||||
|
||||
|
||||
@@ -28,10 +28,11 @@ Related:
|
||||
- `agents.defaults.imageModel` is used **only when** the primary model can’t accept images.
|
||||
- Per-agent defaults can override `agents.defaults.model` via `agents.list[].model` plus bindings (see [/concepts/multi-agent](/concepts/multi-agent)).
|
||||
|
||||
## Quick model picks (anecdotal)
|
||||
## Quick model policy
|
||||
|
||||
- **GLM**: a bit better for coding/tool calling.
|
||||
- **MiniMax**: better for writing and vibes.
|
||||
- Set your primary to the strongest latest-generation model available to you.
|
||||
- Use fallbacks for cost/latency-sensitive tasks and lower-stakes chat.
|
||||
- For tool-enabled agents or untrusted inputs, avoid older/weaker model tiers.
|
||||
|
||||
## Setup wizard (recommended)
|
||||
|
||||
@@ -42,8 +43,7 @@ openclaw onboard
|
||||
```
|
||||
|
||||
It can set up model + auth for common providers, including **OpenAI Code (Codex)
|
||||
subscription** (OAuth) and **Anthropic** (API key recommended; `claude
|
||||
setup-token` also supported).
|
||||
subscription** (OAuth) and **Anthropic** (API key or `claude setup-token`).
|
||||
|
||||
## Config keys (overview)
|
||||
|
||||
@@ -160,7 +160,9 @@ JSON includes `auth.oauth` (warn window + profiles) and `auth.providers`
|
||||
(effective auth per provider).
|
||||
Use `--check` for automation (exit `1` when missing/expired, `2` when expiring).
|
||||
|
||||
Preferred Anthropic auth is the Claude Code CLI setup-token (run anywhere; paste on the gateway host if needed):
|
||||
Auth choice is provider/account dependent. For always-on gateway hosts, API keys are usually the most predictable; subscription token flows are also supported.
|
||||
|
||||
Example (Anthropic setup-token):
|
||||
|
||||
```bash
|
||||
claude setup-token
|
||||
|
||||
@@ -10,7 +10,9 @@ title: "OAuth"
|
||||
|
||||
# OAuth
|
||||
|
||||
OpenClaw supports “subscription auth” via OAuth for providers that offer it (notably **OpenAI Codex (ChatGPT OAuth)**). For Anthropic subscriptions, use the **setup-token** flow. This page explains:
|
||||
OpenClaw supports “subscription auth” via OAuth for providers that offer it (notably **OpenAI Codex (ChatGPT OAuth)**). For Anthropic subscriptions, use the **setup-token** flow. Anthropic subscription use outside Claude Code has been restricted for some users in the past, so treat it as a user-choice risk and verify current Anthropic policy yourself. OpenAI Codex OAuth is explicitly supported for use in external tools like OpenClaw. This page explains:
|
||||
|
||||
For Anthropic in production, API key auth is the safer recommended path over subscription setup-token auth.
|
||||
|
||||
- how the OAuth **token exchange** works (PKCE)
|
||||
- where tokens are **stored** (and why)
|
||||
@@ -54,6 +56,12 @@ For static secret refs and runtime snapshot activation behavior, see [Secrets Ma
|
||||
|
||||
## Anthropic setup-token (subscription auth)
|
||||
|
||||
<Warning>
|
||||
Anthropic setup-token support is technical compatibility, not a policy guarantee.
|
||||
Anthropic has blocked some subscription usage outside Claude Code in the past.
|
||||
Decide for yourself whether to use subscription auth, and verify Anthropic's current terms.
|
||||
</Warning>
|
||||
|
||||
Run `claude setup-token` on any machine, then paste it into OpenClaw:
|
||||
|
||||
```bash
|
||||
@@ -76,7 +84,7 @@ openclaw models status
|
||||
|
||||
OpenClaw’s interactive login flows are implemented in `@mariozechner/pi-ai` and wired into the wizards/commands.
|
||||
|
||||
### Anthropic (Claude Pro/Max) setup-token
|
||||
### Anthropic setup-token
|
||||
|
||||
Flow shape:
|
||||
|
||||
@@ -88,6 +96,8 @@ The wizard path is `openclaw onboard` → auth choice `setup-token` (Anthropic).
|
||||
|
||||
### OpenAI Codex (ChatGPT OAuth)
|
||||
|
||||
OpenAI Codex OAuth is explicitly supported for use outside the Codex CLI, including OpenClaw workflows.
|
||||
|
||||
Flow shape (PKCE):
|
||||
|
||||
1. generate PKCE verifier/challenge + random `state`
|
||||
|
||||
@@ -138,7 +138,7 @@ Legacy key migration:
|
||||
|
||||
Telegram:
|
||||
|
||||
- Uses Bot API `sendMessage` + `editMessageText`.
|
||||
- Uses Bot API `sendMessageDraft` in DMs when available, and `sendMessage` + `editMessageText` for group/topic preview updates.
|
||||
- Preview streaming is skipped when Telegram block streaming is explicitly enabled (to avoid double-streaming).
|
||||
- `/reasoning stream` can write reasoning to preview.
|
||||
|
||||
|
||||
@@ -462,7 +462,7 @@ const needsNonImageSanitize =
|
||||
"id": "anthropic/claude-opus-4.6",
|
||||
"name": "Anthropic: Claude Opus 4.6"
|
||||
},
|
||||
{ "id": "minimax/minimax-m2.1:free", "name": "Minimax: Minimax M2.1" }
|
||||
{ "id": "minimax/minimax-m2.5:free", "name": "Minimax: Minimax M2.5" }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,23 +8,26 @@ title: "Authentication"
|
||||
|
||||
# Authentication
|
||||
|
||||
OpenClaw supports OAuth and API keys for model providers. For Anthropic
|
||||
accounts, we recommend using an **API key**. For Claude subscription access,
|
||||
use the long‑lived token created by `claude setup-token`.
|
||||
OpenClaw supports OAuth and API keys for model providers. For always-on gateway
|
||||
hosts, API keys are usually the most predictable option. Subscription/OAuth
|
||||
flows are also supported when they match your provider account model.
|
||||
|
||||
See [/concepts/oauth](/concepts/oauth) for the full OAuth flow and storage
|
||||
layout.
|
||||
For SecretRef-based auth (`env`/`file`/`exec` providers), see [Secrets Management](/gateway/secrets).
|
||||
|
||||
## Recommended Anthropic setup (API key)
|
||||
## Recommended setup (API key, any provider)
|
||||
|
||||
If you’re using Anthropic directly, use an API key.
|
||||
If you’re running a long-lived gateway, start with an API key for your chosen
|
||||
provider.
|
||||
For Anthropic specifically, API key auth is the safe path and is recommended
|
||||
over subscription setup-token auth.
|
||||
|
||||
1. Create an API key in the Anthropic Console.
|
||||
1. Create an API key in your provider console.
|
||||
2. Put it on the **gateway host** (the machine running `openclaw gateway`).
|
||||
|
||||
```bash
|
||||
export ANTHROPIC_API_KEY="..."
|
||||
export <PROVIDER>_API_KEY="..."
|
||||
openclaw models status
|
||||
```
|
||||
|
||||
@@ -33,7 +36,7 @@ openclaw models status
|
||||
|
||||
```bash
|
||||
cat >> ~/.openclaw/.env <<'EOF'
|
||||
ANTHROPIC_API_KEY=...
|
||||
<PROVIDER>_API_KEY=...
|
||||
EOF
|
||||
```
|
||||
|
||||
@@ -52,8 +55,8 @@ See [Help](/help) for details on env inheritance (`env.shellEnv`,
|
||||
|
||||
## Anthropic: setup-token (subscription auth)
|
||||
|
||||
For Anthropic, the recommended path is an **API key**. If you’re using a Claude
|
||||
subscription, the setup-token flow is also supported. Run it on the **gateway host**:
|
||||
If you’re using a Claude subscription, the setup-token flow is supported. Run
|
||||
it on the **gateway host**:
|
||||
|
||||
```bash
|
||||
claude setup-token
|
||||
@@ -79,6 +82,12 @@ This credential is only authorized for use with Claude Code and cannot be used f
|
||||
|
||||
…use an Anthropic API key instead.
|
||||
|
||||
<Warning>
|
||||
Anthropic setup-token support is technical compatibility only. Anthropic has blocked
|
||||
some subscription usage outside Claude Code in the past. Use it only if you decide
|
||||
the policy risk is acceptable, and verify Anthropic's current terms yourself.
|
||||
</Warning>
|
||||
|
||||
Manual token entry (any provider; writes `auth-profiles.json` + updates config):
|
||||
|
||||
```bash
|
||||
@@ -164,5 +173,5 @@ is missing, rerun `claude setup-token` and paste the token again.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Claude Max or Pro subscription (for `claude setup-token`)
|
||||
- Anthropic subscription account (for `claude setup-token`)
|
||||
- Claude Code CLI installed (`claude` command available)
|
||||
|
||||
@@ -527,7 +527,13 @@ Only enable direct mutable name/email/nick matching with each channel's `dangero
|
||||
}
|
||||
```
|
||||
|
||||
### Anthropic subscription + API key, MiniMax fallback
|
||||
### Anthropic setup-token + API key, MiniMax fallback
|
||||
|
||||
<Warning>
|
||||
Anthropic setup-token usage outside Claude Code has been restricted for some
|
||||
users in the past. Treat this as user-choice risk and verify current Anthropic
|
||||
terms before depending on subscription auth.
|
||||
</Warning>
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -560,7 +566,7 @@ Only enable direct mutable name/email/nick matching with each channel's `dangero
|
||||
workspace: "~/.openclaw/workspace",
|
||||
model: {
|
||||
primary: "anthropic/claude-opus-4-6",
|
||||
fallbacks: ["minimax/MiniMax-M2.1"],
|
||||
fallbacks: ["minimax/MiniMax-M2.5"],
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -597,7 +603,7 @@ Only enable direct mutable name/email/nick matching with each channel's `dangero
|
||||
{
|
||||
agent: {
|
||||
workspace: "~/.openclaw/workspace",
|
||||
model: { primary: "lmstudio/minimax-m2.1-gs32" },
|
||||
model: { primary: "lmstudio/minimax-m2.5-gs32" },
|
||||
},
|
||||
models: {
|
||||
mode: "merge",
|
||||
@@ -608,8 +614,8 @@ Only enable direct mutable name/email/nick matching with each channel's `dangero
|
||||
api: "openai-responses",
|
||||
models: [
|
||||
{
|
||||
id: "minimax-m2.1-gs32",
|
||||
name: "MiniMax M2.1 GS32",
|
||||
id: "minimax-m2.5-gs32",
|
||||
name: "MiniMax M2.5 GS32",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
|
||||
@@ -825,11 +825,11 @@ Time format in system prompt. Default: `auto` (OS preference).
|
||||
defaults: {
|
||||
models: {
|
||||
"anthropic/claude-opus-4-6": { alias: "opus" },
|
||||
"minimax/MiniMax-M2.1": { alias: "minimax" },
|
||||
"minimax/MiniMax-M2.5": { alias: "minimax" },
|
||||
},
|
||||
model: {
|
||||
primary: "anthropic/claude-opus-4-6",
|
||||
fallbacks: ["minimax/MiniMax-M2.1"],
|
||||
fallbacks: ["minimax/MiniMax-M2.5"],
|
||||
},
|
||||
imageModel: {
|
||||
primary: "openrouter/qwen/qwen-2.5-vl-72b-instruct:free",
|
||||
@@ -1895,7 +1895,7 @@ Notes:
|
||||
agents: {
|
||||
defaults: {
|
||||
subagents: {
|
||||
model: "minimax/MiniMax-M2.1",
|
||||
model: "minimax/MiniMax-M2.5",
|
||||
maxConcurrent: 1,
|
||||
runTimeoutSeconds: 900,
|
||||
archiveAfterMinutes: 60,
|
||||
@@ -1961,6 +1961,7 @@ OpenClaw uses the pi-coding-agent model catalog. Add custom providers via `model
|
||||
- `models.providers.*.baseUrl`: upstream API base URL.
|
||||
- `models.providers.*.headers`: extra static headers for proxy/tenant routing.
|
||||
- `models.providers.*.models`: explicit provider model catalog entries.
|
||||
- `models.providers.*.models.*.compat.supportsDeveloperRole`: optional compatibility hint. For `api: "openai-completions"` with a non-empty non-native `baseUrl` (host not `api.openai.com`), OpenClaw forces this to `false` at runtime. Empty/omitted `baseUrl` keeps default OpenAI behavior.
|
||||
- `models.bedrockDiscovery`: Bedrock auto-discovery settings root.
|
||||
- `models.bedrockDiscovery.enabled`: turn discovery polling on/off.
|
||||
- `models.bedrockDiscovery.region`: AWS region for discovery.
|
||||
@@ -2111,8 +2112,8 @@ Anthropic-compatible, built-in provider. Shortcut: `openclaw onboard --auth-choi
|
||||
env: { SYNTHETIC_API_KEY: "sk-..." },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.1" },
|
||||
models: { "synthetic/hf:MiniMaxAI/MiniMax-M2.1": { alias: "MiniMax M2.1" } },
|
||||
model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.5" },
|
||||
models: { "synthetic/hf:MiniMaxAI/MiniMax-M2.5": { alias: "MiniMax M2.5" } },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
@@ -2124,8 +2125,8 @@ Anthropic-compatible, built-in provider. Shortcut: `openclaw onboard --auth-choi
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "hf:MiniMaxAI/MiniMax-M2.1",
|
||||
name: "MiniMax M2.1",
|
||||
id: "hf:MiniMaxAI/MiniMax-M2.5",
|
||||
name: "MiniMax M2.5",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
@@ -2143,15 +2144,15 @@ Base URL should omit `/v1` (Anthropic client appends it). Shortcut: `openclaw on
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="MiniMax M2.1 (direct)">
|
||||
<Accordion title="MiniMax M2.5 (direct)">
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "minimax/MiniMax-M2.1" },
|
||||
model: { primary: "minimax/MiniMax-M2.5" },
|
||||
models: {
|
||||
"minimax/MiniMax-M2.1": { alias: "Minimax" },
|
||||
"minimax/MiniMax-M2.5": { alias: "Minimax" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2164,8 +2165,8 @@ Base URL should omit `/v1` (Anthropic client appends it). Shortcut: `openclaw on
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "MiniMax-M2.1",
|
||||
name: "MiniMax M2.1",
|
||||
id: "MiniMax-M2.5",
|
||||
name: "MiniMax M2.5",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 15, output: 60, cacheRead: 2, cacheWrite: 10 },
|
||||
@@ -2185,7 +2186,7 @@ Set `MINIMAX_API_KEY`. Shortcut: `openclaw onboard --auth-choice minimax-api`.
|
||||
|
||||
<Accordion title="Local models (LM Studio)">
|
||||
|
||||
See [Local Models](/gateway/local-models). TL;DR: run MiniMax M2.1 via LM Studio Responses API on serious hardware; keep hosted models merged for fallback.
|
||||
See [Local Models](/gateway/local-models). TL;DR: run MiniMax M2.5 via LM Studio Responses API on serious hardware; keep hosted models merged for fallback.
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -2731,6 +2732,26 @@ Notes:
|
||||
|
||||
---
|
||||
|
||||
## CLI
|
||||
|
||||
```json5
|
||||
{
|
||||
cli: {
|
||||
banner: {
|
||||
taglineMode: "off", // random | default | off
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- `cli.banner.taglineMode` controls banner tagline style:
|
||||
- `"random"` (default): rotating funny/seasonal taglines.
|
||||
- `"default"`: fixed neutral tagline (`All your chats, one OpenClaw.`).
|
||||
- `"off"`: no tagline text (banner title/version still shown).
|
||||
- To hide the entire banner (not just taglines), set env `OPENCLAW_HIDE_BANNER=1`.
|
||||
|
||||
---
|
||||
|
||||
## Wizard
|
||||
|
||||
Metadata written by CLI wizards (`onboard`, `configure`, `doctor`):
|
||||
|
||||
@@ -11,18 +11,18 @@ title: "Local Models"
|
||||
|
||||
Local is doable, but OpenClaw expects large context + strong defenses against prompt injection. Small cards truncate context and leak safety. Aim high: **≥2 maxed-out Mac Studios or equivalent GPU rig (~$30k+)**. A single **24 GB** GPU works only for lighter prompts with higher latency. Use the **largest / full-size model variant you can run**; aggressively quantized or “small” checkpoints raise prompt-injection risk (see [Security](/gateway/security)).
|
||||
|
||||
## Recommended: LM Studio + MiniMax M2.1 (Responses API, full-size)
|
||||
## Recommended: LM Studio + MiniMax M2.5 (Responses API, full-size)
|
||||
|
||||
Best current local stack. Load MiniMax M2.1 in LM Studio, enable the local server (default `http://127.0.0.1:1234`), and use Responses API to keep reasoning separate from final text.
|
||||
Best current local stack. Load MiniMax M2.5 in LM Studio, enable the local server (default `http://127.0.0.1:1234`), and use Responses API to keep reasoning separate from final text.
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "lmstudio/minimax-m2.1-gs32" },
|
||||
model: { primary: "lmstudio/minimax-m2.5-gs32" },
|
||||
models: {
|
||||
"anthropic/claude-opus-4-6": { alias: "Opus" },
|
||||
"lmstudio/minimax-m2.1-gs32": { alias: "Minimax" },
|
||||
"lmstudio/minimax-m2.5-gs32": { alias: "Minimax" },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -35,8 +35,8 @@ Best current local stack. Load MiniMax M2.1 in LM Studio, enable the local serve
|
||||
api: "openai-responses",
|
||||
models: [
|
||||
{
|
||||
id: "minimax-m2.1-gs32",
|
||||
name: "MiniMax M2.1 GS32",
|
||||
id: "minimax-m2.5-gs32",
|
||||
name: "MiniMax M2.5 GS32",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
@@ -53,7 +53,7 @@ Best current local stack. Load MiniMax M2.1 in LM Studio, enable the local serve
|
||||
**Setup checklist**
|
||||
|
||||
- Install LM Studio: [https://lmstudio.ai](https://lmstudio.ai)
|
||||
- In LM Studio, download the **largest MiniMax M2.1 build available** (avoid “small”/heavily quantized variants), start the server, confirm `http://127.0.0.1:1234/v1/models` lists it.
|
||||
- In LM Studio, download the **largest MiniMax M2.5 build available** (avoid “small”/heavily quantized variants), start the server, confirm `http://127.0.0.1:1234/v1/models` lists it.
|
||||
- Keep the model loaded; cold-load adds startup latency.
|
||||
- Adjust `contextWindow`/`maxTokens` if your LM Studio build differs.
|
||||
- For WhatsApp, stick to Responses API so only final text is sent.
|
||||
@@ -68,11 +68,11 @@ Keep hosted models configured even when running local; use `models.mode: "merge"
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "anthropic/claude-sonnet-4-5",
|
||||
fallbacks: ["lmstudio/minimax-m2.1-gs32", "anthropic/claude-opus-4-6"],
|
||||
fallbacks: ["lmstudio/minimax-m2.5-gs32", "anthropic/claude-opus-4-6"],
|
||||
},
|
||||
models: {
|
||||
"anthropic/claude-sonnet-4-5": { alias: "Sonnet" },
|
||||
"lmstudio/minimax-m2.1-gs32": { alias: "MiniMax Local" },
|
||||
"lmstudio/minimax-m2.5-gs32": { alias: "MiniMax Local" },
|
||||
"anthropic/claude-opus-4-6": { alias: "Opus" },
|
||||
},
|
||||
},
|
||||
@@ -86,8 +86,8 @@ Keep hosted models configured even when running local; use `models.mode: "merge"
|
||||
api: "openai-responses",
|
||||
models: [
|
||||
{
|
||||
id: "minimax-m2.1-gs32",
|
||||
name: "MiniMax M2.1 GS32",
|
||||
id: "minimax-m2.5-gs32",
|
||||
name: "MiniMax M2.5 GS32",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
|
||||
@@ -224,39 +224,40 @@ When the audit prints findings, treat this as a priority order:
|
||||
|
||||
High-signal `checkId` values you will most likely see in real deployments (not exhaustive):
|
||||
|
||||
| `checkId` | Severity | Why it matters | Primary fix key/path | Auto-fix |
|
||||
| -------------------------------------------------- | ------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | -------- |
|
||||
| `fs.state_dir.perms_world_writable` | critical | Other users/processes can modify full OpenClaw state | filesystem perms on `~/.openclaw` | yes |
|
||||
| `fs.config.perms_writable` | critical | Others can change auth/tool policy/config | filesystem perms on `~/.openclaw/openclaw.json` | yes |
|
||||
| `fs.config.perms_world_readable` | critical | Config can expose tokens/settings | filesystem perms on config file | yes |
|
||||
| `gateway.bind_no_auth` | critical | Remote bind without shared secret | `gateway.bind`, `gateway.auth.*` | no |
|
||||
| `gateway.loopback_no_auth` | critical | Reverse-proxied loopback may become unauthenticated | `gateway.auth.*`, proxy setup | no |
|
||||
| `gateway.http.no_auth` | warn/critical | Gateway HTTP APIs reachable with `auth.mode="none"` | `gateway.auth.mode`, `gateway.http.endpoints.*` | no |
|
||||
| `gateway.tools_invoke_http.dangerous_allow` | warn/critical | Re-enables dangerous tools over HTTP API | `gateway.tools.allow` | no |
|
||||
| `gateway.nodes.allow_commands_dangerous` | warn/critical | Enables high-impact node commands (camera/screen/contacts/calendar/SMS) | `gateway.nodes.allowCommands` | no |
|
||||
| `gateway.tailscale_funnel` | critical | Public internet exposure | `gateway.tailscale.mode` | no |
|
||||
| `gateway.control_ui.allowed_origins_required` | critical | Non-loopback Control UI without explicit browser-origin allowlist | `gateway.controlUi.allowedOrigins` | no |
|
||||
| `gateway.control_ui.host_header_origin_fallback` | warn/critical | Enables Host-header origin fallback (DNS rebinding hardening downgrade) | `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` | no |
|
||||
| `gateway.control_ui.insecure_auth` | warn | Insecure-auth compatibility toggle enabled | `gateway.controlUi.allowInsecureAuth` | no |
|
||||
| `gateway.control_ui.device_auth_disabled` | critical | Disables device identity check | `gateway.controlUi.dangerouslyDisableDeviceAuth` | no |
|
||||
| `gateway.real_ip_fallback_enabled` | warn/critical | Trusting `X-Real-IP` fallback can enable source-IP spoofing via proxy misconfig | `gateway.allowRealIpFallback`, `gateway.trustedProxies` | no |
|
||||
| `discovery.mdns_full_mode` | warn/critical | mDNS full mode advertises `cliPath`/`sshPort` metadata on local network | `discovery.mdns.mode`, `gateway.bind` | no |
|
||||
| `config.insecure_or_dangerous_flags` | warn | Any insecure/dangerous debug flags enabled | multiple keys (see finding detail) | no |
|
||||
| `hooks.token_too_short` | warn | Easier brute force on hook ingress | `hooks.token` | no |
|
||||
| `hooks.request_session_key_enabled` | warn/critical | External caller can choose sessionKey | `hooks.allowRequestSessionKey` | no |
|
||||
| `hooks.request_session_key_prefixes_missing` | warn/critical | No bound on external session key shapes | `hooks.allowedSessionKeyPrefixes` | no |
|
||||
| `logging.redact_off` | warn | Sensitive values leak to logs/status | `logging.redactSensitive` | yes |
|
||||
| `sandbox.docker_config_mode_off` | warn | Sandbox Docker config present but inactive | `agents.*.sandbox.mode` | no |
|
||||
| `sandbox.dangerous_network_mode` | critical | Sandbox Docker network uses `host` or `container:*` namespace-join mode | `agents.*.sandbox.docker.network` | no |
|
||||
| `tools.exec.host_sandbox_no_sandbox_defaults` | warn | `exec host=sandbox` resolves to host exec when sandbox is off | `tools.exec.host`, `agents.defaults.sandbox.mode` | no |
|
||||
| `tools.exec.host_sandbox_no_sandbox_agents` | warn | Per-agent `exec host=sandbox` resolves to host exec when sandbox is off | `agents.list[].tools.exec.host`, `agents.list[].sandbox.mode` | no |
|
||||
| `tools.exec.safe_bins_interpreter_unprofiled` | warn | Interpreter/runtime bins in `safeBins` without explicit profiles broaden exec risk | `tools.exec.safeBins`, `tools.exec.safeBinProfiles`, `agents.list[].tools.exec.*` | no |
|
||||
| `security.exposure.open_groups_with_elevated` | critical | Open groups + elevated tools create high-impact prompt-injection paths | `channels.*.groupPolicy`, `tools.elevated.*` | no |
|
||||
| `security.exposure.open_groups_with_runtime_or_fs` | critical/warn | Open groups can reach command/file tools without sandbox/workspace guards | `channels.*.groupPolicy`, `tools.profile/deny`, `tools.fs.workspaceOnly`, `agents.*.sandbox.mode` | no |
|
||||
| `security.trust_model.multi_user_heuristic` | warn | Config looks multi-user while gateway trust model is personal-assistant | split trust boundaries, or shared-user hardening (`sandbox.mode`, tool deny/workspace scoping) | no |
|
||||
| `tools.profile_minimal_overridden` | warn | Agent overrides bypass global minimal profile | `agents.list[].tools.profile` | no |
|
||||
| `plugins.tools_reachable_permissive_policy` | warn | Extension tools reachable in permissive contexts | `tools.profile` + tool allow/deny | no |
|
||||
| `models.small_params` | critical/info | Small models + unsafe tool surfaces raise injection risk | model choice + sandbox/tool policy | no |
|
||||
| `checkId` | Severity | Why it matters | Primary fix key/path | Auto-fix |
|
||||
| -------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | -------- |
|
||||
| `fs.state_dir.perms_world_writable` | critical | Other users/processes can modify full OpenClaw state | filesystem perms on `~/.openclaw` | yes |
|
||||
| `fs.config.perms_writable` | critical | Others can change auth/tool policy/config | filesystem perms on `~/.openclaw/openclaw.json` | yes |
|
||||
| `fs.config.perms_world_readable` | critical | Config can expose tokens/settings | filesystem perms on config file | yes |
|
||||
| `gateway.bind_no_auth` | critical | Remote bind without shared secret | `gateway.bind`, `gateway.auth.*` | no |
|
||||
| `gateway.loopback_no_auth` | critical | Reverse-proxied loopback may become unauthenticated | `gateway.auth.*`, proxy setup | no |
|
||||
| `gateway.http.no_auth` | warn/critical | Gateway HTTP APIs reachable with `auth.mode="none"` | `gateway.auth.mode`, `gateway.http.endpoints.*` | no |
|
||||
| `gateway.tools_invoke_http.dangerous_allow` | warn/critical | Re-enables dangerous tools over HTTP API | `gateway.tools.allow` | no |
|
||||
| `gateway.nodes.allow_commands_dangerous` | warn/critical | Enables high-impact node commands (camera/screen/contacts/calendar/SMS) | `gateway.nodes.allowCommands` | no |
|
||||
| `gateway.tailscale_funnel` | critical | Public internet exposure | `gateway.tailscale.mode` | no |
|
||||
| `gateway.control_ui.allowed_origins_required` | critical | Non-loopback Control UI without explicit browser-origin allowlist | `gateway.controlUi.allowedOrigins` | no |
|
||||
| `gateway.control_ui.host_header_origin_fallback` | warn/critical | Enables Host-header origin fallback (DNS rebinding hardening downgrade) | `gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback` | no |
|
||||
| `gateway.control_ui.insecure_auth` | warn | Insecure-auth compatibility toggle enabled | `gateway.controlUi.allowInsecureAuth` | no |
|
||||
| `gateway.control_ui.device_auth_disabled` | critical | Disables device identity check | `gateway.controlUi.dangerouslyDisableDeviceAuth` | no |
|
||||
| `gateway.real_ip_fallback_enabled` | warn/critical | Trusting `X-Real-IP` fallback can enable source-IP spoofing via proxy misconfig | `gateway.allowRealIpFallback`, `gateway.trustedProxies` | no |
|
||||
| `discovery.mdns_full_mode` | warn/critical | mDNS full mode advertises `cliPath`/`sshPort` metadata on local network | `discovery.mdns.mode`, `gateway.bind` | no |
|
||||
| `config.insecure_or_dangerous_flags` | warn | Any insecure/dangerous debug flags enabled | multiple keys (see finding detail) | no |
|
||||
| `hooks.token_too_short` | warn | Easier brute force on hook ingress | `hooks.token` | no |
|
||||
| `hooks.request_session_key_enabled` | warn/critical | External caller can choose sessionKey | `hooks.allowRequestSessionKey` | no |
|
||||
| `hooks.request_session_key_prefixes_missing` | warn/critical | No bound on external session key shapes | `hooks.allowedSessionKeyPrefixes` | no |
|
||||
| `logging.redact_off` | warn | Sensitive values leak to logs/status | `logging.redactSensitive` | yes |
|
||||
| `sandbox.docker_config_mode_off` | warn | Sandbox Docker config present but inactive | `agents.*.sandbox.mode` | no |
|
||||
| `sandbox.dangerous_network_mode` | critical | Sandbox Docker network uses `host` or `container:*` namespace-join mode | `agents.*.sandbox.docker.network` | no |
|
||||
| `tools.exec.host_sandbox_no_sandbox_defaults` | warn | `exec host=sandbox` resolves to host exec when sandbox is off | `tools.exec.host`, `agents.defaults.sandbox.mode` | no |
|
||||
| `tools.exec.host_sandbox_no_sandbox_agents` | warn | Per-agent `exec host=sandbox` resolves to host exec when sandbox is off | `agents.list[].tools.exec.host`, `agents.list[].sandbox.mode` | no |
|
||||
| `tools.exec.safe_bins_interpreter_unprofiled` | warn | Interpreter/runtime bins in `safeBins` without explicit profiles broaden exec risk | `tools.exec.safeBins`, `tools.exec.safeBinProfiles`, `agents.list[].tools.exec.*` | no |
|
||||
| `skills.workspace.symlink_escape` | warn | Workspace `skills/**/SKILL.md` resolves outside workspace root (symlink-chain drift) | workspace `skills/**` filesystem state | no |
|
||||
| `security.exposure.open_groups_with_elevated` | critical | Open groups + elevated tools create high-impact prompt-injection paths | `channels.*.groupPolicy`, `tools.elevated.*` | no |
|
||||
| `security.exposure.open_groups_with_runtime_or_fs` | critical/warn | Open groups can reach command/file tools without sandbox/workspace guards | `channels.*.groupPolicy`, `tools.profile/deny`, `tools.fs.workspaceOnly`, `agents.*.sandbox.mode` | no |
|
||||
| `security.trust_model.multi_user_heuristic` | warn | Config looks multi-user while gateway trust model is personal-assistant | split trust boundaries, or shared-user hardening (`sandbox.mode`, tool deny/workspace scoping) | no |
|
||||
| `tools.profile_minimal_overridden` | warn | Agent overrides bypass global minimal profile | `agents.list[].tools.profile` | no |
|
||||
| `plugins.tools_reachable_permissive_policy` | warn | Extension tools reachable in permissive contexts | `tools.profile` + tool allow/deny | no |
|
||||
| `models.small_params` | critical/info | Small models + unsafe tool surfaces raise injection risk | model choice + sandbox/tool policy | no |
|
||||
|
||||
## Control UI over HTTP
|
||||
|
||||
@@ -515,7 +516,7 @@ Even with strong system prompts, **prompt injection is not solved**. System prom
|
||||
- Run sensitive tool execution in a sandbox; keep secrets out of the agent’s reachable filesystem.
|
||||
- Note: sandboxing is opt-in. If sandbox mode is off, exec runs on the gateway host even though tools.exec.host defaults to sandbox, and host exec does not require approvals unless you set host=gateway and configure exec approvals.
|
||||
- Limit high-risk tools (`exec`, `browser`, `web_fetch`, `web_search`) to trusted agents or explicit allowlists.
|
||||
- **Model choice matters:** older/legacy models can be less robust against prompt injection and tool misuse. Prefer modern, instruction-hardened models for any bot with tools. We recommend Anthropic Opus 4.6 (or the latest Opus) because it’s strong at recognizing prompt injections (see [“A step forward on safety”](https://www.anthropic.com/news/claude-opus-4-5)).
|
||||
- **Model choice matters:** older/smaller/legacy models are significantly less robust against prompt injection and tool misuse. For tool-enabled agents, use the strongest latest-generation, instruction-hardened model available.
|
||||
|
||||
Red flags to treat as untrusted:
|
||||
|
||||
@@ -566,10 +567,14 @@ tool calls. Reduce the blast radius by:
|
||||
|
||||
Prompt injection resistance is **not** uniform across model tiers. Smaller/cheaper models are generally more susceptible to tool misuse and instruction hijacking, especially under adversarial prompts.
|
||||
|
||||
<Warning>
|
||||
For tool-enabled agents or agents that read untrusted content, prompt-injection risk with older/smaller models is often too high. Do not run those workloads on weak model tiers.
|
||||
</Warning>
|
||||
|
||||
Recommendations:
|
||||
|
||||
- **Use the latest generation, best-tier model** for any bot that can run tools or touch files/networks.
|
||||
- **Avoid weaker tiers** (for example, Sonnet or Haiku) for tool-enabled agents or untrusted inboxes.
|
||||
- **Do not use older/weaker/smaller tiers** for tool-enabled agents or untrusted inboxes; the prompt-injection risk is too high.
|
||||
- If you must use a smaller model, **reduce blast radius** (read-only tools, strong sandboxing, minimal filesystem access, strict allowlists).
|
||||
- When running small models, **enable sandboxing for all sessions** and **disable web_search/web_fetch/browser** unless inputs are tightly controlled.
|
||||
- For chat-only personal assistants with trusted input and no tools, smaller models are usually fine.
|
||||
|
||||
@@ -101,6 +101,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
- [I set `gateway.bind: "lan"` (or `"tailnet"`) and now nothing listens / the UI says unauthorized](#i-set-gatewaybind-lan-or-tailnet-and-now-nothing-listens-the-ui-says-unauthorized)
|
||||
- [Why do I need a token on localhost now?](#why-do-i-need-a-token-on-localhost-now)
|
||||
- [Do I have to restart after changing config?](#do-i-have-to-restart-after-changing-config)
|
||||
- [How do I disable funny CLI taglines?](#how-do-i-disable-funny-cli-taglines)
|
||||
- [How do I enable web search (and web fetch)?](#how-do-i-enable-web-search-and-web-fetch)
|
||||
- [config.apply wiped my config. How do I recover and avoid this?](#configapply-wiped-my-config-how-do-i-recover-and-avoid-this)
|
||||
- [How do I run a central Gateway with specialized workers across devices?](#how-do-i-run-a-central-gateway-with-specialized-workers-across-devices)
|
||||
@@ -147,7 +148,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
||||
- [How do I switch models on the fly (without restarting)?](#how-do-i-switch-models-on-the-fly-without-restarting)
|
||||
- [Can I use GPT 5.2 for daily tasks and Codex 5.3 for coding](#can-i-use-gpt-52-for-daily-tasks-and-codex-53-for-coding)
|
||||
- [Why do I see "Model … is not allowed" and then no reply?](#why-do-i-see-model-is-not-allowed-and-then-no-reply)
|
||||
- [Why do I see "Unknown model: minimax/MiniMax-M2.1"?](#why-do-i-see-unknown-model-minimaxminimaxm21)
|
||||
- [Why do I see "Unknown model: minimax/MiniMax-M2.5"?](#why-do-i-see-unknown-model-minimaxminimaxm25)
|
||||
- [Can I use MiniMax as my default and OpenAI for complex tasks?](#can-i-use-minimax-as-my-default-and-openai-for-complex-tasks)
|
||||
- [Are opus / sonnet / gpt built-in shortcuts?](#are-opus-sonnet-gpt-builtin-shortcuts)
|
||||
- [How do I define/override model shortcuts (aliases)?](#how-do-i-defineoverride-model-shortcuts-aliases)
|
||||
@@ -688,7 +689,7 @@ Docs: [Update](/cli/update), [Updating](/install/updating).
|
||||
|
||||
`openclaw onboard` is the recommended setup path. In **local mode** it walks you through:
|
||||
|
||||
- **Model/auth setup** (Anthropic **setup-token** recommended for Claude subscriptions, OpenAI Codex OAuth supported, API keys optional, LM Studio local models supported)
|
||||
- **Model/auth setup** (provider OAuth/setup-token flows and API keys supported, plus local model options such as LM Studio)
|
||||
- **Workspace** location + bootstrap files
|
||||
- **Gateway settings** (bind/port/auth/tailscale)
|
||||
- **Providers** (WhatsApp, Telegram, Discord, Mattermost (plugin), Signal, iMessage)
|
||||
@@ -703,6 +704,10 @@ No. You can run OpenClaw with **API keys** (Anthropic/OpenAI/others) or with
|
||||
**local-only models** so your data stays on your device. Subscriptions (Claude
|
||||
Pro/Max or OpenAI Codex) are optional ways to authenticate those providers.
|
||||
|
||||
If you choose Anthropic subscription auth, decide for yourself whether to use it:
|
||||
Anthropic has blocked some subscription usage outside Claude Code in the past.
|
||||
OpenAI Codex OAuth is explicitly supported for external tools like OpenClaw.
|
||||
|
||||
Docs: [Anthropic](/providers/anthropic), [OpenAI](/providers/openai),
|
||||
[Local models](/gateway/local-models), [Models](/concepts/models).
|
||||
|
||||
@@ -712,9 +717,9 @@ Yes. You can authenticate with a **setup-token**
|
||||
instead of an API key. This is the subscription path.
|
||||
|
||||
Claude Pro/Max subscriptions **do not include an API key**, so this is the
|
||||
correct approach for subscription accounts. Important: you must verify with
|
||||
Anthropic that this usage is allowed under their subscription policy and terms.
|
||||
If you want the most explicit, supported path, use an Anthropic API key.
|
||||
technical path for subscription accounts. But this is your decision: Anthropic
|
||||
has blocked some subscription usage outside Claude Code in the past.
|
||||
If you want the clearest and safest supported path for production, use an Anthropic API key.
|
||||
|
||||
### How does Anthropic setuptoken auth work
|
||||
|
||||
@@ -734,12 +739,15 @@ Copy the token it prints, then choose **Anthropic token (paste setup-token)** in
|
||||
|
||||
Yes - via **setup-token**. OpenClaw no longer reuses Claude Code CLI OAuth tokens; use a setup-token or an Anthropic API key. Generate the token anywhere and paste it on the gateway host. See [Anthropic](/providers/anthropic) and [OAuth](/concepts/oauth).
|
||||
|
||||
Note: Claude subscription access is governed by Anthropic's terms. For production or multi-user workloads, API keys are usually the safer choice.
|
||||
Important: this is technical compatibility, not a policy guarantee. Anthropic
|
||||
has blocked some subscription usage outside Claude Code in the past.
|
||||
You need to decide whether to use it and verify Anthropic's current terms.
|
||||
For production or multi-user workloads, Anthropic API key auth is the safer, recommended choice.
|
||||
|
||||
### Why am I seeing HTTP 429 ratelimiterror from Anthropic
|
||||
|
||||
That means your **Anthropic quota/rate limit** is exhausted for the current window. If you
|
||||
use a **Claude subscription** (setup-token or Claude Code OAuth), wait for the window to
|
||||
use a **Claude subscription** (setup-token), wait for the window to
|
||||
reset or upgrade your plan. If you use an **Anthropic API key**, check the Anthropic Console
|
||||
for usage/billing and raise limits as needed.
|
||||
|
||||
@@ -763,8 +771,9 @@ OpenClaw supports **OpenAI Code (Codex)** via OAuth (ChatGPT sign-in). The wizar
|
||||
|
||||
### Do you support OpenAI subscription auth Codex OAuth
|
||||
|
||||
Yes. OpenClaw fully supports **OpenAI Code (Codex) subscription OAuth**. The onboarding wizard
|
||||
can run the OAuth flow for you.
|
||||
Yes. OpenClaw fully supports **OpenAI Code (Codex) subscription OAuth**.
|
||||
OpenAI explicitly allows subscription OAuth usage in external tools/workflows
|
||||
like OpenClaw. The onboarding wizard can run the OAuth flow for you.
|
||||
|
||||
See [OAuth](/concepts/oauth), [Model providers](/concepts/model-providers), and [Wizard](/start/wizard).
|
||||
|
||||
@@ -781,7 +790,7 @@ This stores OAuth tokens in auth profiles on the gateway host. Details: [Model p
|
||||
|
||||
### Is a local model OK for casual chats
|
||||
|
||||
Usually no. OpenClaw needs large context + strong safety; small cards truncate and leak. If you must, run the **largest** MiniMax M2.1 build you can locally (LM Studio) and see [/gateway/local-models](/gateway/local-models). Smaller/quantized models increase prompt-injection risk - see [Security](/gateway/security).
|
||||
Usually no. OpenClaw needs large context + strong safety; small cards truncate and leak. If you must, run the **largest** MiniMax M2.5 build you can locally (LM Studio) and see [/gateway/local-models](/gateway/local-models). Smaller/quantized models increase prompt-injection risk - see [Security](/gateway/security).
|
||||
|
||||
### How do I keep hosted model traffic in a specific region
|
||||
|
||||
@@ -1290,12 +1299,13 @@ It prefers OpenAI if an OpenAI key resolves, otherwise Gemini if a Gemini key
|
||||
resolves, then Voyage, then Mistral. If no remote key is available, memory
|
||||
search stays disabled until you configure it. If you have a local model path
|
||||
configured and present, OpenClaw
|
||||
prefers `local`.
|
||||
prefers `local`. Ollama is supported when you explicitly set
|
||||
`memorySearch.provider = "ollama"`.
|
||||
|
||||
If you'd rather stay local, set `memorySearch.provider = "local"` (and optionally
|
||||
`memorySearch.fallback = "none"`). If you want Gemini embeddings, set
|
||||
`memorySearch.provider = "gemini"` and provide `GEMINI_API_KEY` (or
|
||||
`memorySearch.remote.apiKey`). We support **OpenAI, Gemini, Voyage, Mistral, or local** embedding
|
||||
`memorySearch.remote.apiKey`). We support **OpenAI, Gemini, Voyage, Mistral, Ollama, or local** embedding
|
||||
models - see [Memory](/concepts/memory) for the setup details.
|
||||
|
||||
### Does memory persist forever What are the limits
|
||||
@@ -1458,6 +1468,25 @@ The Gateway watches the config and supports hot-reload:
|
||||
- `gateway.reload.mode: "hybrid"` (default): hot-apply safe changes, restart for critical ones
|
||||
- `hot`, `restart`, `off` are also supported
|
||||
|
||||
### How do I disable funny CLI taglines
|
||||
|
||||
Set `cli.banner.taglineMode` in config:
|
||||
|
||||
```json5
|
||||
{
|
||||
cli: {
|
||||
banner: {
|
||||
taglineMode: "off", // random | default | off
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- `off`: hides tagline text but keeps the banner title/version line.
|
||||
- `default`: uses `All your chats, one OpenClaw.` every time.
|
||||
- `random`: rotating funny/seasonal taglines (default behavior).
|
||||
- If you want no banner at all, set env `OPENCLAW_HIDE_BANNER=1`.
|
||||
|
||||
### How do I enable web search and web fetch
|
||||
|
||||
`web_fetch` works without an API key. `web_search` requires a Brave Search API
|
||||
@@ -2028,12 +2057,11 @@ Models are referenced as `provider/model` (example: `anthropic/claude-opus-4-6`)
|
||||
|
||||
### What model do you recommend
|
||||
|
||||
**Recommended default:** `anthropic/claude-opus-4-6`.
|
||||
**Good alternative:** `anthropic/claude-sonnet-4-5`.
|
||||
**Reliable (less character):** `openai/gpt-5.2` - nearly as good as Opus, just less personality.
|
||||
**Budget:** `zai/glm-4.7`.
|
||||
**Recommended default:** use the strongest latest-generation model available in your provider stack.
|
||||
**For tool-enabled or untrusted-input agents:** prioritize model strength over cost.
|
||||
**For routine/low-stakes chat:** use cheaper fallback models and route by agent role.
|
||||
|
||||
MiniMax M2.1 has its own docs: [MiniMax](/providers/minimax) and
|
||||
MiniMax M2.5 has its own docs: [MiniMax](/providers/minimax) and
|
||||
[Local models](/gateway/local-models).
|
||||
|
||||
Rule of thumb: use the **best model you can afford** for high-stakes work, and a cheaper
|
||||
@@ -2077,8 +2105,9 @@ Docs: [Models](/concepts/models), [Configure](/cli/configure), [Config](/cli/con
|
||||
|
||||
### What do OpenClaw, Flawd, and Krill use for models
|
||||
|
||||
- **OpenClaw + Flawd:** Anthropic Opus (`anthropic/claude-opus-4-6`) - see [Anthropic](/providers/anthropic).
|
||||
- **Krill:** MiniMax M2.1 (`minimax/MiniMax-M2.1`) - see [MiniMax](/providers/minimax).
|
||||
- These deployments can differ and may change over time; there is no fixed provider recommendation.
|
||||
- Check the current runtime setting on each gateway with `openclaw models status`.
|
||||
- For security-sensitive/tool-enabled agents, use the strongest latest-generation model available.
|
||||
|
||||
### How do I switch models on the fly without restarting
|
||||
|
||||
@@ -2145,7 +2174,7 @@ Model "provider/model" is not allowed. Use /model to list available models.
|
||||
That error is returned **instead of** a normal reply. Fix: add the model to
|
||||
`agents.defaults.models`, remove the allowlist, or pick a model from `/model list`.
|
||||
|
||||
### Why do I see Unknown model minimaxMiniMaxM21
|
||||
### Why do I see Unknown model minimaxMiniMaxM25
|
||||
|
||||
This means the **provider isn't configured** (no MiniMax provider config or auth
|
||||
profile was found), so the model can't be resolved. A fix for this detection is
|
||||
@@ -2156,8 +2185,8 @@ Fix checklist:
|
||||
1. Upgrade to **2026.1.12** (or run from source `main`), then restart the gateway.
|
||||
2. Make sure MiniMax is configured (wizard or JSON), or that a MiniMax API key
|
||||
exists in env/auth profiles so the provider can be injected.
|
||||
3. Use the exact model id (case-sensitive): `minimax/MiniMax-M2.1` or
|
||||
`minimax/MiniMax-M2.1-lightning`.
|
||||
3. Use the exact model id (case-sensitive): `minimax/MiniMax-M2.5` or
|
||||
`minimax/MiniMax-M2.5-highspeed` (legacy: `minimax/MiniMax-M2.5-Lightning`).
|
||||
4. Run:
|
||||
|
||||
```bash
|
||||
@@ -2180,9 +2209,9 @@ Fallbacks are for **errors**, not "hard tasks," so use `/model` or a separate ag
|
||||
env: { MINIMAX_API_KEY: "sk-...", OPENAI_API_KEY: "sk-..." },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "minimax/MiniMax-M2.1" },
|
||||
model: { primary: "minimax/MiniMax-M2.5" },
|
||||
models: {
|
||||
"minimax/MiniMax-M2.1": { alias: "minimax" },
|
||||
"minimax/MiniMax-M2.5": { alias: "minimax" },
|
||||
"openai/gpt-5.2": { alias: "gpt" },
|
||||
},
|
||||
},
|
||||
@@ -2260,8 +2289,8 @@ Z.AI (GLM models):
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "zai/glm-4.7" },
|
||||
models: { "zai/glm-4.7": {} },
|
||||
model: { primary: "zai/glm-5" },
|
||||
models: { "zai/glm-5": {} },
|
||||
},
|
||||
},
|
||||
env: { ZAI_API_KEY: "..." },
|
||||
|
||||
@@ -136,7 +136,7 @@ Live tests are split into two layers so we can isolate failures:
|
||||
- `pnpm test:live` (or `OPENCLAW_LIVE_TEST=1` if invoking Vitest directly)
|
||||
- Set `OPENCLAW_LIVE_MODELS=modern` (or `all`, alias for modern) to actually run this suite; otherwise it skips to keep `pnpm test:live` focused on gateway smoke
|
||||
- How to select models:
|
||||
- `OPENCLAW_LIVE_MODELS=modern` to run the modern allowlist (Opus/Sonnet/Haiku 4.5, GPT-5.x + Codex, Gemini 3, GLM 4.7, MiniMax M2.1, Grok 4)
|
||||
- `OPENCLAW_LIVE_MODELS=modern` to run the modern allowlist (Opus/Sonnet/Haiku 4.5, GPT-5.x + Codex, Gemini 3, GLM 4.7, MiniMax M2.5, Grok 4)
|
||||
- `OPENCLAW_LIVE_MODELS=all` is an alias for the modern allowlist
|
||||
- or `OPENCLAW_LIVE_MODELS="openai/gpt-5.2,anthropic/claude-opus-4-6,..."` (comma allowlist)
|
||||
- How to select providers:
|
||||
@@ -167,7 +167,7 @@ Live tests are split into two layers so we can isolate failures:
|
||||
- How to enable:
|
||||
- `pnpm test:live` (or `OPENCLAW_LIVE_TEST=1` if invoking Vitest directly)
|
||||
- How to select models:
|
||||
- Default: modern allowlist (Opus/Sonnet/Haiku 4.5, GPT-5.x + Codex, Gemini 3, GLM 4.7, MiniMax M2.1, Grok 4)
|
||||
- Default: modern allowlist (Opus/Sonnet/Haiku 4.5, GPT-5.x + Codex, Gemini 3, GLM 4.7, MiniMax M2.5, Grok 4)
|
||||
- `OPENCLAW_LIVE_GATEWAY_MODELS=all` is an alias for the modern allowlist
|
||||
- Or set `OPENCLAW_LIVE_GATEWAY_MODELS="provider/model"` (or comma list) to narrow
|
||||
- How to select providers (avoid “OpenRouter everything”):
|
||||
@@ -251,7 +251,7 @@ Narrow, explicit allowlists are fastest and least flaky:
|
||||
- `OPENCLAW_LIVE_GATEWAY_MODELS="openai/gpt-5.2" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts`
|
||||
|
||||
- Tool calling across several providers:
|
||||
- `OPENCLAW_LIVE_GATEWAY_MODELS="openai/gpt-5.2,anthropic/claude-opus-4-6,google/gemini-3-flash-preview,zai/glm-4.7,minimax/minimax-m2.1" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts`
|
||||
- `OPENCLAW_LIVE_GATEWAY_MODELS="openai/gpt-5.2,anthropic/claude-opus-4-6,google/gemini-3-flash-preview,zai/glm-4.7,minimax/minimax-m2.5" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts`
|
||||
|
||||
- Google focus (Gemini API key + Antigravity):
|
||||
- Gemini (API key): `OPENCLAW_LIVE_GATEWAY_MODELS="google/gemini-3-flash-preview" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts`
|
||||
@@ -280,10 +280,10 @@ This is the “common models” run we expect to keep working:
|
||||
- Google (Gemini API): `google/gemini-3-pro-preview` and `google/gemini-3-flash-preview` (avoid older Gemini 2.x models)
|
||||
- Google (Antigravity): `google-antigravity/claude-opus-4-6-thinking` and `google-antigravity/gemini-3-flash`
|
||||
- Z.AI (GLM): `zai/glm-4.7`
|
||||
- MiniMax: `minimax/minimax-m2.1`
|
||||
- MiniMax: `minimax/minimax-m2.5`
|
||||
|
||||
Run gateway smoke with tools + image:
|
||||
`OPENCLAW_LIVE_GATEWAY_MODELS="openai/gpt-5.2,openai-codex/gpt-5.3-codex,anthropic/claude-opus-4-6,google/gemini-3-pro-preview,google/gemini-3-flash-preview,google-antigravity/claude-opus-4-6-thinking,google-antigravity/gemini-3-flash,zai/glm-4.7,minimax/minimax-m2.1" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts`
|
||||
`OPENCLAW_LIVE_GATEWAY_MODELS="openai/gpt-5.2,openai-codex/gpt-5.3-codex,anthropic/claude-opus-4-6,google/gemini-3-pro-preview,google/gemini-3-flash-preview,google-antigravity/claude-opus-4-6-thinking,google-antigravity/gemini-3-flash,zai/glm-4.7,minimax/minimax-m2.5" pnpm test:live src/gateway/gateway-models.profiles.live.test.ts`
|
||||
|
||||
### Baseline: tool calling (Read + optional Exec)
|
||||
|
||||
@@ -293,7 +293,7 @@ Pick at least one per provider family:
|
||||
- Anthropic: `anthropic/claude-opus-4-6` (or `anthropic/claude-sonnet-4-5`)
|
||||
- Google: `google/gemini-3-flash-preview` (or `google/gemini-3-pro-preview`)
|
||||
- Z.AI (GLM): `zai/glm-4.7`
|
||||
- MiniMax: `minimax/minimax-m2.1`
|
||||
- MiniMax: `minimax/minimax-m2.5`
|
||||
|
||||
Optional additional coverage (nice to have):
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ OpenClaw is a **self-hosted gateway** that connects your favorite chat apps —
|
||||
- **Agent-native**: built for coding agents with tool use, sessions, memory, and multi-agent routing
|
||||
- **Open source**: MIT licensed, community-driven
|
||||
|
||||
**What do you need?** Node 22+, an API key (Anthropic recommended), and 5 minutes.
|
||||
**What do you need?** Node 22+, an API key from your chosen provider, and 5 minutes. For best quality and security, use the strongest latest-generation model available.
|
||||
|
||||
## How it works
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ read_when:
|
||||
|
||||
- [flyctl CLI](https://fly.io/docs/hands-on/install-flyctl/) installed
|
||||
- Fly.io account (free tier works)
|
||||
- Model auth: Anthropic API key (or other provider keys)
|
||||
- Model auth: API key for your chosen model provider
|
||||
- Channel credentials: Discord bot token, Telegram token, etc.
|
||||
|
||||
## Beginner quick path
|
||||
|
||||
@@ -23,7 +23,7 @@ What I need you to do:
|
||||
1. Check if Determinate Nix is installed (if not, install it)
|
||||
2. Create a local flake at ~/code/openclaw-local using templates/agent-first/flake.nix
|
||||
3. Help me create a Telegram bot (@BotFather) and get my chat ID (@userinfobot)
|
||||
4. Set up secrets (bot token, Anthropic key) - plain files at ~/.secrets/ is fine
|
||||
4. Set up secrets (bot token, model provider API key) - plain files at ~/.secrets/ is fine
|
||||
5. Fill in the template placeholders and run home-manager switch
|
||||
6. Verify: launchd running, bot responds to messages
|
||||
|
||||
|
||||
@@ -170,11 +170,18 @@ When `requireMention: true` is set for a group chat, OpenClaw now transcribes au
|
||||
- If transcription fails during preflight (timeout, API error, etc.), the message is processed based on text-only mention detection.
|
||||
- This ensures that mixed messages (text + audio) are never incorrectly dropped.
|
||||
|
||||
**Opt-out per Telegram group/topic:**
|
||||
|
||||
- Set `channels.telegram.groups.<chatId>.disableAudioPreflight: true` to skip preflight transcript mention checks for that group.
|
||||
- Set `channels.telegram.groups.<chatId>.topics.<threadId>.disableAudioPreflight` to override per-topic (`true` to skip, `false` to force-enable).
|
||||
- Default is `false` (preflight enabled when mention-gated conditions match).
|
||||
|
||||
**Example:** A user sends a voice note saying "Hey @Claude, what's the weather?" in a Telegram group with `requireMention: true`. The voice note is transcribed, the mention is detected, and the agent replies.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Scope rules use first-match wins. `chatType` is normalized to `direct`, `group`, or `room`.
|
||||
- Ensure your CLI exits 0 and prints plain text; JSON needs to be massaged via `jq -r .text`.
|
||||
- For `parakeet-mlx`, if you pass `--output-dir`, OpenClaw reads `<output-dir>/<media-basename>.txt` when `--output-format` is `txt` (or omitted); non-`txt` output formats fall back to stdout parsing.
|
||||
- Keep timeouts reasonable (`timeoutSeconds`, default 60s) to avoid blocking the reply queue.
|
||||
- Preflight transcription only processes the **first** audio attachment for mention detection. Additional audio is processed during the main media understanding phase.
|
||||
|
||||
@@ -199,23 +199,13 @@ If you omit `capabilities`, the entry is eligible for the list it appears in.
|
||||
| Audio | OpenAI, Groq, Deepgram, Google, Mistral | Provider transcription (Whisper/Deepgram/Gemini/Voxtral). |
|
||||
| Video | Google (Gemini API) | Provider video understanding. |
|
||||
|
||||
## Recommended providers
|
||||
## Model selection guidance
|
||||
|
||||
**Image**
|
||||
|
||||
- Prefer your active model if it supports images.
|
||||
- Good defaults: `openai/gpt-5.2`, `anthropic/claude-opus-4-6`, `google/gemini-3-pro-preview`.
|
||||
|
||||
**Audio**
|
||||
|
||||
- `openai/gpt-4o-mini-transcribe`, `groq/whisper-large-v3-turbo`, `deepgram/nova-3`, or `mistral/voxtral-mini-latest`.
|
||||
- CLI fallback: `whisper-cli` (whisper-cpp) or `whisper`.
|
||||
- Deepgram setup: [Deepgram (audio transcription)](/providers/deepgram).
|
||||
|
||||
**Video**
|
||||
|
||||
- `google/gemini-3-flash-preview` (fast), `google/gemini-3-pro-preview` (richer).
|
||||
- CLI fallback: `gemini` CLI (supports `read_file` on video/audio).
|
||||
- Prefer the strongest latest-generation model available for each media capability when quality and safety matter.
|
||||
- For tool-enabled agents handling untrusted inputs, avoid older/weaker media models.
|
||||
- Keep at least one fallback per capability for availability (quality model + faster/cheaper model).
|
||||
- CLI fallbacks (`whisper-cli`, `whisper`, `gemini`) are useful when provider APIs are unavailable.
|
||||
- `parakeet-mlx` note: with `--output-dir`, OpenClaw reads `<output-dir>/<media-basename>.txt` when output format is `txt` (or unspecified); non-`txt` formats fall back to stdout.
|
||||
|
||||
## Attachment policy
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
summary: "Use Claude Max/Pro subscription as an OpenAI-compatible API endpoint"
|
||||
summary: "Community proxy to expose Claude subscription credentials as an OpenAI-compatible endpoint"
|
||||
read_when:
|
||||
- You want to use Claude Max subscription with OpenAI-compatible tools
|
||||
- You want a local API server that wraps Claude Code CLI
|
||||
- You want to save money by using subscription instead of API keys
|
||||
- You want to evaluate subscription-based vs API-key-based Anthropic access
|
||||
title: "Claude Max API Proxy"
|
||||
---
|
||||
|
||||
@@ -11,6 +11,12 @@ title: "Claude Max API Proxy"
|
||||
|
||||
**claude-max-api-proxy** is a community tool that exposes your Claude Max/Pro subscription as an OpenAI-compatible API endpoint. This allows you to use your subscription with any tool that supports the OpenAI API format.
|
||||
|
||||
<Warning>
|
||||
This path is technical compatibility only. Anthropic has blocked some subscription
|
||||
usage outside Claude Code in the past. You must decide for yourself whether to use
|
||||
it and verify Anthropic's current terms before relying on it.
|
||||
</Warning>
|
||||
|
||||
## Why Use This?
|
||||
|
||||
| Approach | Cost | Best For |
|
||||
@@ -18,7 +24,7 @@ title: "Claude Max API Proxy"
|
||||
| Anthropic API | Pay per token (~$15/M input, $75/M output for Opus) | Production apps, high volume |
|
||||
| Claude Max subscription | $200/month flat | Personal use, development, unlimited usage |
|
||||
|
||||
If you have a Claude Max subscription and want to use it with OpenAI-compatible tools, this proxy can save you significant money.
|
||||
If you have a Claude Max subscription and want to use it with OpenAI-compatible tools, this proxy may reduce cost for some workflows. API keys remain the clearer policy path for production use.
|
||||
|
||||
## How It Works
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
|
||||
|
||||
## Community tools
|
||||
|
||||
- [Claude Max API Proxy](/providers/claude-max-api-proxy) - Use Claude Max/Pro subscription as an OpenAI-compatible API endpoint
|
||||
- [Claude Max API Proxy](/providers/claude-max-api-proxy) - Community proxy for Claude subscription credentials (verify Anthropic policy/terms before use)
|
||||
|
||||
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
|
||||
see [Model providers](/concepts/model-providers).
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "Use MiniMax M2.1 in OpenClaw"
|
||||
summary: "Use MiniMax M2.5 in OpenClaw"
|
||||
read_when:
|
||||
- You want MiniMax models in OpenClaw
|
||||
- You need MiniMax setup guidance
|
||||
@@ -8,15 +8,15 @@ title: "MiniMax"
|
||||
|
||||
# MiniMax
|
||||
|
||||
MiniMax is an AI company that builds the **M2/M2.1** model family. The current
|
||||
coding-focused release is **MiniMax M2.1** (December 23, 2025), built for
|
||||
MiniMax is an AI company that builds the **M2/M2.5** model family. The current
|
||||
coding-focused release is **MiniMax M2.5** (December 23, 2025), built for
|
||||
real-world complex tasks.
|
||||
|
||||
Source: [MiniMax M2.1 release note](https://www.minimax.io/news/minimax-m21)
|
||||
Source: [MiniMax M2.5 release note](https://www.minimax.io/news/minimax-m25)
|
||||
|
||||
## Model overview (M2.1)
|
||||
## Model overview (M2.5)
|
||||
|
||||
MiniMax highlights these improvements in M2.1:
|
||||
MiniMax highlights these improvements in M2.5:
|
||||
|
||||
- Stronger **multi-language coding** (Rust, Java, Go, C++, Kotlin, Objective-C, TS/JS).
|
||||
- Better **web/app development** and aesthetic output quality (including native mobile).
|
||||
@@ -27,13 +27,12 @@ MiniMax highlights these improvements in M2.1:
|
||||
Droid/Factory AI, Cline, Kilo Code, Roo Code, BlackBox).
|
||||
- Higher-quality **dialogue and technical writing** outputs.
|
||||
|
||||
## MiniMax M2.1 vs MiniMax M2.1 Lightning
|
||||
## MiniMax M2.5 vs MiniMax M2.5 Highspeed
|
||||
|
||||
- **Speed:** Lightning is the “fast” variant in MiniMax’s pricing docs.
|
||||
- **Cost:** Pricing shows the same input cost, but Lightning has higher output cost.
|
||||
- **Coding plan routing:** The Lightning back-end isn’t directly available on the MiniMax
|
||||
coding plan. MiniMax auto-routes most requests to Lightning, but falls back to the
|
||||
regular M2.1 back-end during traffic spikes.
|
||||
- **Speed:** `MiniMax-M2.5-highspeed` is the official fast tier in MiniMax docs.
|
||||
- **Cost:** MiniMax pricing lists the same input cost and a higher output cost for highspeed.
|
||||
- **Compatibility:** OpenClaw still accepts legacy `MiniMax-M2.5-Lightning` configs, but prefer
|
||||
`MiniMax-M2.5-highspeed` for new setup.
|
||||
|
||||
## Choose a setup
|
||||
|
||||
@@ -56,7 +55,7 @@ You will be prompted to select an endpoint:
|
||||
|
||||
See [MiniMax OAuth plugin README](https://github.com/openclaw/openclaw/tree/main/extensions/minimax-portal-auth) for details.
|
||||
|
||||
### MiniMax M2.1 (API key)
|
||||
### MiniMax M2.5 (API key)
|
||||
|
||||
**Best for:** hosted MiniMax with Anthropic-compatible API.
|
||||
|
||||
@@ -64,12 +63,12 @@ Configure via CLI:
|
||||
|
||||
- Run `openclaw configure`
|
||||
- Select **Model/auth**
|
||||
- Choose **MiniMax M2.1**
|
||||
- Choose **MiniMax M2.5**
|
||||
|
||||
```json5
|
||||
{
|
||||
env: { MINIMAX_API_KEY: "sk-..." },
|
||||
agents: { defaults: { model: { primary: "minimax/MiniMax-M2.1" } } },
|
||||
agents: { defaults: { model: { primary: "minimax/MiniMax-M2.5" } } },
|
||||
models: {
|
||||
mode: "merge",
|
||||
providers: {
|
||||
@@ -79,11 +78,20 @@ Configure via CLI:
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "MiniMax-M2.1",
|
||||
name: "MiniMax M2.1",
|
||||
reasoning: false,
|
||||
id: "MiniMax-M2.5",
|
||||
name: "MiniMax M2.5",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 15, output: 60, cacheRead: 2, cacheWrite: 10 },
|
||||
cost: { input: 0.3, output: 1.2, cacheRead: 0.03, cacheWrite: 0.12 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
{
|
||||
id: "MiniMax-M2.5-highspeed",
|
||||
name: "MiniMax M2.5 Highspeed",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0.3, output: 1.2, cacheRead: 0.03, cacheWrite: 0.12 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
@@ -94,9 +102,10 @@ Configure via CLI:
|
||||
}
|
||||
```
|
||||
|
||||
### MiniMax M2.1 as fallback (Opus primary)
|
||||
### MiniMax M2.5 as fallback (example)
|
||||
|
||||
**Best for:** keep Opus 4.6 as primary, fail over to MiniMax M2.1.
|
||||
**Best for:** keep your strongest latest-generation model as primary, fail over to MiniMax M2.5.
|
||||
Example below uses Opus as a concrete primary; swap to your preferred latest-gen primary model.
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -104,12 +113,12 @@ Configure via CLI:
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"anthropic/claude-opus-4-6": { alias: "opus" },
|
||||
"minimax/MiniMax-M2.1": { alias: "minimax" },
|
||||
"anthropic/claude-opus-4-6": { alias: "primary" },
|
||||
"minimax/MiniMax-M2.5": { alias: "minimax" },
|
||||
},
|
||||
model: {
|
||||
primary: "anthropic/claude-opus-4-6",
|
||||
fallbacks: ["minimax/MiniMax-M2.1"],
|
||||
fallbacks: ["minimax/MiniMax-M2.5"],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -119,7 +128,7 @@ Configure via CLI:
|
||||
### Optional: Local via LM Studio (manual)
|
||||
|
||||
**Best for:** local inference with LM Studio.
|
||||
We have seen strong results with MiniMax M2.1 on powerful hardware (e.g. a
|
||||
We have seen strong results with MiniMax M2.5 on powerful hardware (e.g. a
|
||||
desktop/server) using LM Studio's local server.
|
||||
|
||||
Configure manually via `openclaw.json`:
|
||||
@@ -128,8 +137,8 @@ Configure manually via `openclaw.json`:
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "lmstudio/minimax-m2.1-gs32" },
|
||||
models: { "lmstudio/minimax-m2.1-gs32": { alias: "Minimax" } },
|
||||
model: { primary: "lmstudio/minimax-m2.5-gs32" },
|
||||
models: { "lmstudio/minimax-m2.5-gs32": { alias: "Minimax" } },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
@@ -141,8 +150,8 @@ Configure manually via `openclaw.json`:
|
||||
api: "openai-responses",
|
||||
models: [
|
||||
{
|
||||
id: "minimax-m2.1-gs32",
|
||||
name: "MiniMax M2.1 GS32",
|
||||
id: "minimax-m2.5-gs32",
|
||||
name: "MiniMax M2.5 GS32",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
@@ -162,7 +171,7 @@ Use the interactive config wizard to set MiniMax without editing JSON:
|
||||
|
||||
1. Run `openclaw configure`.
|
||||
2. Select **Model/auth**.
|
||||
3. Choose **MiniMax M2.1**.
|
||||
3. Choose **MiniMax M2.5**.
|
||||
4. Pick your default model when prompted.
|
||||
|
||||
## Configuration options
|
||||
@@ -177,29 +186,31 @@ Use the interactive config wizard to set MiniMax without editing JSON:
|
||||
## Notes
|
||||
|
||||
- Model refs are `minimax/<model>`.
|
||||
- Recommended model IDs: `MiniMax-M2.5` and `MiniMax-M2.5-highspeed`.
|
||||
- Coding Plan usage API: `https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains` (requires a coding plan key).
|
||||
- Update pricing values in `models.json` if you need exact cost tracking.
|
||||
- Referral link for MiniMax Coding Plan (10% off): [https://platform.minimax.io/subscribe/coding-plan?code=DbXJTRClnb&source=link](https://platform.minimax.io/subscribe/coding-plan?code=DbXJTRClnb&source=link)
|
||||
- See [/concepts/model-providers](/concepts/model-providers) for provider rules.
|
||||
- Use `openclaw models list` and `openclaw models set minimax/MiniMax-M2.1` to switch.
|
||||
- Use `openclaw models list` and `openclaw models set minimax/MiniMax-M2.5` to switch.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### “Unknown model: minimax/MiniMax-M2.1”
|
||||
### “Unknown model: minimax/MiniMax-M2.5”
|
||||
|
||||
This usually means the **MiniMax provider isn’t configured** (no provider entry
|
||||
and no MiniMax auth profile/env key found). A fix for this detection is in
|
||||
**2026.1.12** (unreleased at the time of writing). Fix by:
|
||||
|
||||
- Upgrading to **2026.1.12** (or run from source `main`), then restarting the gateway.
|
||||
- Running `openclaw configure` and selecting **MiniMax M2.1**, or
|
||||
- Running `openclaw configure` and selecting **MiniMax M2.5**, or
|
||||
- Adding the `models.providers.minimax` block manually, or
|
||||
- Setting `MINIMAX_API_KEY` (or a MiniMax auth profile) so the provider can be injected.
|
||||
|
||||
Make sure the model id is **case‑sensitive**:
|
||||
|
||||
- `minimax/MiniMax-M2.1`
|
||||
- `minimax/MiniMax-M2.1-lightning`
|
||||
- `minimax/MiniMax-M2.5`
|
||||
- `minimax/MiniMax-M2.5-highspeed`
|
||||
- `minimax/MiniMax-M2.5-Lightning` (legacy)
|
||||
|
||||
Then recheck with:
|
||||
|
||||
|
||||
@@ -15,14 +15,20 @@ Kimi Coding with `kimi-coding/k2p5`.
|
||||
|
||||
Current Kimi K2 model IDs:
|
||||
|
||||
{/_moonshot-kimi-k2-ids:start_/ && null}
|
||||
<!-- markdownlint-disable MD037 -->
|
||||
|
||||
{/_ moonshot-kimi-k2-ids:start _/ && null}
|
||||
|
||||
<!-- markdownlint-enable MD037 -->
|
||||
|
||||
- `kimi-k2.5`
|
||||
- `kimi-k2-0905-preview`
|
||||
- `kimi-k2-turbo-preview`
|
||||
- `kimi-k2-thinking`
|
||||
- `kimi-k2-thinking-turbo`
|
||||
{/_moonshot-kimi-k2-ids:end_/ && null}
|
||||
<!-- markdownlint-disable MD037 -->
|
||||
{/_ moonshot-kimi-k2-ids:end _/ && null}
|
||||
<!-- markdownlint-enable MD037 -->
|
||||
|
||||
```bash
|
||||
openclaw onboard --auth-choice moonshot-api-key
|
||||
@@ -140,3 +146,35 @@ Note: Moonshot and Kimi Coding are separate providers. Keys are not interchangea
|
||||
- If Moonshot publishes different context limits for a model, adjust
|
||||
`contextWindow` accordingly.
|
||||
- Use `https://api.moonshot.ai/v1` for the international endpoint, and `https://api.moonshot.cn/v1` for the China endpoint.
|
||||
|
||||
## Native thinking mode (Moonshot)
|
||||
|
||||
Moonshot Kimi supports binary native thinking:
|
||||
|
||||
- `thinking: { type: "enabled" }`
|
||||
- `thinking: { type: "disabled" }`
|
||||
|
||||
Configure it per model via `agents.defaults.models.<provider/model>.params`:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"moonshot/kimi-k2.5": {
|
||||
params: {
|
||||
thinking: { type: "disabled" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
OpenClaw also maps runtime `/think` levels for Moonshot:
|
||||
|
||||
- `/think off` -> `thinking.type=disabled`
|
||||
- any non-off thinking level -> `thinking.type=enabled`
|
||||
|
||||
When Moonshot thinking is enabled, `tool_choice` must be `auto` or `none`. OpenClaw normalizes incompatible `tool_choice` values to `auto` for compatibility.
|
||||
|
||||
@@ -10,6 +10,7 @@ title: "OpenAI"
|
||||
|
||||
OpenAI provides developer APIs for GPT models. Codex supports **ChatGPT sign-in** for subscription
|
||||
access or **API key** sign-in for usage-based access. Codex cloud requires ChatGPT sign-in.
|
||||
OpenAI explicitly supports subscription OAuth usage in external tools/workflows like OpenClaw.
|
||||
|
||||
## Option A: OpenAI API key (OpenAI Platform)
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ openclaw onboard --auth-choice synthetic-api-key
|
||||
The default model is set to:
|
||||
|
||||
```
|
||||
synthetic/hf:MiniMaxAI/MiniMax-M2.1
|
||||
synthetic/hf:MiniMaxAI/MiniMax-M2.5
|
||||
```
|
||||
|
||||
## Config example
|
||||
@@ -33,8 +33,8 @@ synthetic/hf:MiniMaxAI/MiniMax-M2.1
|
||||
env: { SYNTHETIC_API_KEY: "sk-..." },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.1" },
|
||||
models: { "synthetic/hf:MiniMaxAI/MiniMax-M2.1": { alias: "MiniMax M2.1" } },
|
||||
model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.5" },
|
||||
models: { "synthetic/hf:MiniMaxAI/MiniMax-M2.5": { alias: "MiniMax M2.5" } },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
@@ -46,8 +46,8 @@ synthetic/hf:MiniMaxAI/MiniMax-M2.1
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
{
|
||||
id: "hf:MiniMaxAI/MiniMax-M2.1",
|
||||
name: "MiniMax M2.1",
|
||||
id: "hf:MiniMaxAI/MiniMax-M2.5",
|
||||
name: "MiniMax M2.5",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
@@ -71,7 +71,7 @@ All models below use cost `0` (input/output/cache).
|
||||
|
||||
| Model ID | Context window | Max tokens | Reasoning | Input |
|
||||
| ------------------------------------------------------ | -------------- | ---------- | --------- | ------------ |
|
||||
| `hf:MiniMaxAI/MiniMax-M2.1` | 192000 | 65536 | false | text |
|
||||
| `hf:MiniMaxAI/MiniMax-M2.5` | 192000 | 65536 | false | text |
|
||||
| `hf:moonshotai/Kimi-K2-Thinking` | 256000 | 8192 | true | text |
|
||||
| `hf:zai-org/GLM-4.7` | 198000 | 128000 | false | text |
|
||||
| `hf:deepseek-ai/DeepSeek-R1-0528` | 128000 | 8192 | false | text |
|
||||
|
||||
@@ -158,7 +158,7 @@ openclaw models list | grep venice
|
||||
| `grok-41-fast` | Grok 4.1 Fast | 262k | Reasoning, vision |
|
||||
| `grok-code-fast-1` | Grok Code Fast 1 | 262k | Reasoning, code |
|
||||
| `kimi-k2-thinking` | Kimi K2 Thinking | 262k | Reasoning |
|
||||
| `minimax-m21` | MiniMax M2.1 | 202k | Reasoning |
|
||||
| `minimax-m21` | MiniMax M2.5 | 202k | Reasoning |
|
||||
|
||||
## Model Discovery
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ Semantic memory search uses **embedding APIs** when configured for remote provid
|
||||
- `memorySearch.provider = "gemini"` → Gemini embeddings
|
||||
- `memorySearch.provider = "voyage"` → Voyage embeddings
|
||||
- `memorySearch.provider = "mistral"` → Mistral embeddings
|
||||
- `memorySearch.provider = "ollama"` → Ollama embeddings (local/self-hosted; typically no hosted API billing)
|
||||
- Optional fallback to a remote provider if local embeddings fail
|
||||
|
||||
You can keep it local with `memorySearch.provider = "local"` (no API usage).
|
||||
|
||||
@@ -30,7 +30,7 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard).
|
||||
- Full reset (also removes workspace)
|
||||
</Step>
|
||||
<Step title="Model/Auth">
|
||||
- **Anthropic API key (recommended)**: uses `ANTHROPIC_API_KEY` if present or prompts for a key, then saves it for daemon use.
|
||||
- **Anthropic API key**: uses `ANTHROPIC_API_KEY` if present or prompts for a key, then saves it for daemon use.
|
||||
- **Anthropic OAuth (Claude Code CLI)**: on macOS the wizard checks Keychain item "Claude Code-credentials" (choose "Always Allow" so launchd starts don't block); on Linux/Windows it reuses `~/.claude/.credentials.json` if present.
|
||||
- **Anthropic token (paste setup-token)**: run `claude setup-token` on any machine, then paste the token (you can name it; blank = default).
|
||||
- **OpenAI Code (Codex) subscription (Codex CLI)**: if `~/.codex/auth.json` exists, the wizard can reuse it.
|
||||
@@ -44,7 +44,7 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard).
|
||||
- More detail: [Vercel AI Gateway](/providers/vercel-ai-gateway)
|
||||
- **Cloudflare AI Gateway**: prompts for Account ID, Gateway ID, and `CLOUDFLARE_AI_GATEWAY_API_KEY`.
|
||||
- More detail: [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway)
|
||||
- **MiniMax M2.1**: config is auto-written.
|
||||
- **MiniMax M2.5**: config is auto-written.
|
||||
- More detail: [MiniMax](/providers/minimax)
|
||||
- **Synthetic (Anthropic-compatible)**: prompts for `SYNTHETIC_API_KEY`.
|
||||
- More detail: [Synthetic](/providers/synthetic)
|
||||
@@ -52,7 +52,7 @@ For a high-level overview, see [Onboarding Wizard](/start/wizard).
|
||||
- **Kimi Coding**: config is auto-written.
|
||||
- More detail: [Moonshot AI (Kimi + Kimi Coding)](/providers/moonshot)
|
||||
- **Skip**: no auth configured yet.
|
||||
- Pick a default model from detected options (or enter provider/model manually).
|
||||
- Pick a default model from detected options (or enter provider/model manually). For best quality and lower prompt-injection risk, choose the strongest latest-generation model available in your provider stack.
|
||||
- Wizard runs a model check and warns if the configured model is unknown or missing auth.
|
||||
- API key storage mode defaults to plaintext auth-profile values. Use `--secret-input-mode ref` to store env-backed refs instead (for example `keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }`).
|
||||
- OAuth credentials live in `~/.openclaw/credentials/oauth.json`; auth profiles live in `~/.openclaw/agents/<agentId>/agent/auth-profiles.json` (API keys + OAuth).
|
||||
|
||||
@@ -116,7 +116,7 @@ What you set:
|
||||
## Auth and model options
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Anthropic API key (recommended)">
|
||||
<Accordion title="Anthropic API key">
|
||||
Uses `ANTHROPIC_API_KEY` if present or prompts for a key, then saves it for daemon use.
|
||||
</Accordion>
|
||||
<Accordion title="Anthropic OAuth (Claude Code CLI)">
|
||||
@@ -163,7 +163,7 @@ What you set:
|
||||
Prompts for account ID, gateway ID, and `CLOUDFLARE_AI_GATEWAY_API_KEY`.
|
||||
More detail: [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway).
|
||||
</Accordion>
|
||||
<Accordion title="MiniMax M2.1">
|
||||
<Accordion title="MiniMax M2.5">
|
||||
Config is auto-written.
|
||||
More detail: [MiniMax](/providers/minimax).
|
||||
</Accordion>
|
||||
|
||||
@@ -64,9 +64,9 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
|
||||
|
||||
**Local mode (default)** walks you through these steps:
|
||||
|
||||
1. **Model/Auth** — Anthropic API key (recommended), OpenAI, or Custom Provider
|
||||
1. **Model/Auth** — choose any supported provider/auth flow (API key, OAuth, or setup-token), including Custom Provider
|
||||
(OpenAI-compatible, Anthropic-compatible, or Unknown auto-detect). Pick a default model.
|
||||
Security note: if this agent will run tools or process webhook/hooks content, prefer a strong modern model tier and keep tool policy strict. Weaker model tiers are easier to prompt-inject.
|
||||
Security note: if this agent will run tools or process webhook/hooks content, prefer the strongest latest-generation model available and keep tool policy strict. Weaker/older tiers are easier to prompt-inject.
|
||||
For non-interactive runs, `--secret-input-mode ref` stores env-backed refs in auth profiles instead of plaintext API key values.
|
||||
In non-interactive `ref` mode, the provider env var must be set; passing inline key flags without that env var fails fast.
|
||||
In interactive runs, choosing secret reference mode lets you point at either an environment variable or a configured provider ref (`file` or `exec`), with a fast preflight validation before saving.
|
||||
|
||||
@@ -75,7 +75,7 @@ Thread binding support is adapter-specific. If the active channel adapter does n
|
||||
Required feature flags for thread-bound ACP:
|
||||
|
||||
- `acp.enabled=true`
|
||||
- `acp.dispatch.enabled=true`
|
||||
- `acp.dispatch.enabled` is on by default (set `false` to pause ACP dispatch)
|
||||
- Channel-adapter ACP thread-spawn flag enabled (adapter-specific)
|
||||
- Discord: `channels.discord.threadBindings.spawnAcpSessions=true`
|
||||
|
||||
@@ -120,6 +120,19 @@ Interface details:
|
||||
- `cwd` (optional): requested runtime working directory (validated by backend/runtime policy).
|
||||
- `label` (optional): operator-facing label used in session/banner text.
|
||||
|
||||
## Sandbox compatibility
|
||||
|
||||
ACP sessions currently run on the host runtime, not inside the OpenClaw sandbox.
|
||||
|
||||
Current limitations:
|
||||
|
||||
- If the requester session is sandboxed, ACP spawns are blocked.
|
||||
- Error: `Sandboxed sessions cannot spawn ACP sessions because runtime="acp" runs on the host. Use runtime="subagent" from sandboxed sessions.`
|
||||
- `sessions_spawn` with `runtime: "acp"` does not support `sandbox: "require"`.
|
||||
- Error: `sessions_spawn sandbox="require" is unsupported for runtime="acp" because ACP sessions run outside the sandbox. Use runtime="subagent" or sandbox="inherit".`
|
||||
|
||||
Use `runtime: "subagent"` when you need sandbox-enforced execution.
|
||||
|
||||
### From `/acp` command
|
||||
|
||||
Use `/acp spawn` for explicit operator control from chat when needed.
|
||||
@@ -236,6 +249,7 @@ Current acpx built-in harness aliases:
|
||||
- `codex`
|
||||
- `opencode`
|
||||
- `gemini`
|
||||
- `kimi`
|
||||
|
||||
When OpenClaw uses the acpx backend, prefer these values for `agentId` unless your acpx config defines custom agent aliases.
|
||||
|
||||
@@ -249,10 +263,11 @@ Core ACP baseline:
|
||||
{
|
||||
acp: {
|
||||
enabled: true,
|
||||
// Optional. Default is true; set false to pause ACP dispatch while keeping /acp controls.
|
||||
dispatch: { enabled: true },
|
||||
backend: "acpx",
|
||||
defaultAgent: "codex",
|
||||
allowedAgents: ["pi", "claude", "codex", "opencode", "gemini"],
|
||||
allowedAgents: ["pi", "claude", "codex", "opencode", "gemini", "kimi"],
|
||||
maxConcurrentSessions: 8,
|
||||
stream: {
|
||||
coalesceIdleMs: 300,
|
||||
@@ -403,6 +418,8 @@ Restart the gateway after changing these values.
|
||||
| `--thread here requires running /acp spawn inside an active ... thread` | `--thread here` used outside a thread context. | Move to target thread or use `--thread auto`/`off`. |
|
||||
| `Only <user-id> can rebind this thread.` | Another user owns thread binding. | Rebind as owner or use a different thread. |
|
||||
| `Thread bindings are unavailable for <channel>.` | Adapter lacks thread binding capability. | Use `--thread off` or move to supported adapter/channel. |
|
||||
| `Sandboxed sessions cannot spawn ACP sessions ...` | ACP runtime is host-side; requester session is sandboxed. | Use `runtime="subagent"` from sandboxed sessions, or run ACP spawn from a non-sandboxed session. |
|
||||
| `sessions_spawn sandbox="require" is unsupported for runtime="acp" ...` | `sandbox="require"` requested for ACP runtime. | Use `runtime="subagent"` for required sandboxing, or use ACP with `sandbox="inherit"` from a non-sandboxed session. |
|
||||
| Missing ACP metadata for bound session | Stale/deleted ACP session metadata. | Recreate with `/acp spawn`, then rebind/focus thread. |
|
||||
| `AcpRuntimeError: Permission prompt unavailable in non-interactive mode` | `permissionMode` blocks writes/exec in non-interactive ACP session. | Set `plugins.entries.acpx.config.permissionMode` to `approve-all` and restart gateway. See [Permission configuration](#permission-configuration). |
|
||||
| ACP session fails early with little output | Permission prompts are blocked by `permissionMode`/`nonInteractivePermissions`. | Check gateway logs for `AcpRuntimeError`. For full permissions, set `permissionMode=approve-all`; for graceful degradation, set `nonInteractivePermissions=deny`. |
|
||||
|
||||
@@ -22,6 +22,7 @@ title: "Thinking Levels"
|
||||
- Provider notes:
|
||||
- Anthropic Claude 4.6 models default to `adaptive` when no explicit thinking level is set.
|
||||
- Z.AI (`zai/*`) only supports binary thinking (`on`/`off`). Any non-`off` level is treated as `on` (mapped to `low`).
|
||||
- Moonshot (`moonshot/*`) maps `/think off` to `thinking: { type: "disabled" }` and any non-`off` level to `thinking: { type: "enabled" }`. When thinking is enabled, Moonshot only accepts `tool_choice` `auto|none`; OpenClaw normalizes incompatible values to `auto`.
|
||||
|
||||
## Resolution order
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ user-invocable: false
|
||||
|
||||
# ACP Harness Router
|
||||
|
||||
When user intent is "run this in Pi/Claude Code/Codex/OpenCode/Gemini (ACP harness)", do not use subagent runtime or PTY scraping. Route through ACP-aware flows.
|
||||
When user intent is "run this in Pi/Claude Code/Codex/OpenCode/Gemini/Kimi (ACP harness)", do not use subagent runtime or PTY scraping. Route through ACP-aware flows.
|
||||
|
||||
## Intent detection
|
||||
|
||||
@@ -39,7 +39,7 @@ Do not use:
|
||||
|
||||
- `subagents` runtime for harness control
|
||||
- `/acp` command delegation as a requirement for the user
|
||||
- PTY scraping of pi/claude/codex/opencode/gemini CLIs when `acpx` is available
|
||||
- PTY scraping of pi/claude/codex/opencode/gemini/kimi CLIs when `acpx` is available
|
||||
|
||||
## AgentId mapping
|
||||
|
||||
@@ -50,6 +50,7 @@ Use these defaults when user names a harness directly:
|
||||
- "codex" -> `agentId: "codex"`
|
||||
- "opencode" -> `agentId: "opencode"`
|
||||
- "gemini" or "gemini cli" -> `agentId: "gemini"`
|
||||
- "kimi" or "kimi cli" -> `agentId: "kimi"`
|
||||
|
||||
These defaults match current acpx built-in aliases.
|
||||
|
||||
@@ -87,7 +88,7 @@ Call:
|
||||
|
||||
## Thread spawn recovery policy
|
||||
|
||||
When the user asks to start a coding harness in a thread (for example "start a codex/claude/pi thread"), treat that as an ACP runtime request and try to satisfy it end-to-end.
|
||||
When the user asks to start a coding harness in a thread (for example "start a codex/claude/pi/kimi thread"), treat that as an ACP runtime request and try to satisfy it end-to-end.
|
||||
|
||||
Required behavior when ACP backend is unavailable:
|
||||
|
||||
@@ -183,6 +184,7 @@ ${ACPX_CMD} codex sessions close oc-codex-<conversationId>
|
||||
- `codex`
|
||||
- `opencode`
|
||||
- `gemini`
|
||||
- `kimi`
|
||||
|
||||
### Built-in adapter commands in acpx
|
||||
|
||||
@@ -193,6 +195,7 @@ Defaults are:
|
||||
- `codex -> npx @zed-industries/codex-acp`
|
||||
- `opencode -> npx -y opencode-ai acp`
|
||||
- `gemini -> gemini`
|
||||
- `kimi -> kimi acp`
|
||||
|
||||
If `~/.acpx/config.json` overrides `agents`, those overrides replace defaults.
|
||||
|
||||
|
||||
@@ -11,13 +11,28 @@ import {
|
||||
import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js";
|
||||
|
||||
let sharedFixture: Awaited<ReturnType<typeof createMockRuntimeFixture>> | null = null;
|
||||
let missingCommandRuntime: AcpxRuntime | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
sharedFixture = await createMockRuntimeFixture();
|
||||
missingCommandRuntime = new AcpxRuntime(
|
||||
{
|
||||
command: "/definitely/missing/acpx",
|
||||
allowPluginLocalInstall: false,
|
||||
installCommand: "n/a",
|
||||
cwd: process.cwd(),
|
||||
permissionMode: "approve-reads",
|
||||
nonInteractivePermissions: "fail",
|
||||
strictWindowsCmdWrapper: true,
|
||||
queueOwnerTtlSeconds: 0.1,
|
||||
},
|
||||
{ logger: NOOP_LOGGER },
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
sharedFixture = null;
|
||||
missingCommandRuntime = null;
|
||||
await cleanupMockRuntimeFixtures();
|
||||
});
|
||||
|
||||
@@ -319,22 +334,12 @@ describe("AcpxRuntime", () => {
|
||||
});
|
||||
|
||||
it("marks runtime unhealthy when command is missing", async () => {
|
||||
const runtime = new AcpxRuntime(
|
||||
{
|
||||
command: "/definitely/missing/acpx",
|
||||
allowPluginLocalInstall: false,
|
||||
installCommand: "n/a",
|
||||
cwd: process.cwd(),
|
||||
permissionMode: "approve-reads",
|
||||
nonInteractivePermissions: "fail",
|
||||
strictWindowsCmdWrapper: true,
|
||||
queueOwnerTtlSeconds: 0.1,
|
||||
},
|
||||
{ logger: NOOP_LOGGER },
|
||||
);
|
||||
|
||||
await runtime.probeAvailability();
|
||||
expect(runtime.isHealthy()).toBe(false);
|
||||
expect(missingCommandRuntime).toBeDefined();
|
||||
if (!missingCommandRuntime) {
|
||||
throw new Error("missing-command runtime fixture missing");
|
||||
}
|
||||
await missingCommandRuntime.probeAvailability();
|
||||
expect(missingCommandRuntime.isHealthy()).toBe(false);
|
||||
});
|
||||
|
||||
it("logs ACPX spawn resolution once per command policy", async () => {
|
||||
@@ -363,21 +368,11 @@ describe("AcpxRuntime", () => {
|
||||
});
|
||||
|
||||
it("returns doctor report for missing command", async () => {
|
||||
const runtime = new AcpxRuntime(
|
||||
{
|
||||
command: "/definitely/missing/acpx",
|
||||
allowPluginLocalInstall: false,
|
||||
installCommand: "n/a",
|
||||
cwd: process.cwd(),
|
||||
permissionMode: "approve-reads",
|
||||
nonInteractivePermissions: "fail",
|
||||
strictWindowsCmdWrapper: true,
|
||||
queueOwnerTtlSeconds: 0.1,
|
||||
},
|
||||
{ logger: NOOP_LOGGER },
|
||||
);
|
||||
|
||||
const report = await runtime.doctor();
|
||||
expect(missingCommandRuntime).toBeDefined();
|
||||
if (!missingCommandRuntime) {
|
||||
throw new Error("missing-command runtime fixture missing");
|
||||
}
|
||||
const report = await missingCommandRuntime.doctor();
|
||||
expect(report.ok).toBe(false);
|
||||
expect(report.code).toBe("ACP_BACKEND_UNAVAILABLE");
|
||||
expect(report.installCommand).toContain("acpx");
|
||||
|
||||
@@ -103,6 +103,7 @@ function createMockRuntime(): PluginRuntime {
|
||||
system: {
|
||||
enqueueSystemEvent:
|
||||
mockEnqueueSystemEvent as unknown as PluginRuntime["system"]["enqueueSystemEvent"],
|
||||
requestHeartbeatNow: vi.fn() as unknown as PluginRuntime["system"]["requestHeartbeatNow"],
|
||||
runCommandWithTimeout: vi.fn() as unknown as PluginRuntime["system"]["runCommandWithTimeout"],
|
||||
formatNativeDependencyHint: vi.fn(
|
||||
() => "",
|
||||
@@ -274,6 +275,12 @@ function createMockRuntime(): PluginRuntime {
|
||||
imessage: {} as PluginRuntime["channel"]["imessage"],
|
||||
whatsapp: {} as PluginRuntime["channel"]["whatsapp"],
|
||||
},
|
||||
events: {
|
||||
onAgentEvent: vi.fn(() => () => {}) as unknown as PluginRuntime["events"]["onAgentEvent"],
|
||||
onSessionTranscriptUpdate: vi.fn(
|
||||
() => () => {},
|
||||
) as unknown as PluginRuntime["events"]["onSessionTranscriptUpdate"],
|
||||
},
|
||||
logging: {
|
||||
shouldLogVerbose: vi.fn(
|
||||
() => false,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveDefaultFeishuAccountId, resolveFeishuAccount } from "./accounts.js";
|
||||
import {
|
||||
resolveDefaultFeishuAccountId,
|
||||
resolveDefaultFeishuAccountSelection,
|
||||
resolveFeishuAccount,
|
||||
} from "./accounts.js";
|
||||
|
||||
describe("resolveDefaultFeishuAccountId", () => {
|
||||
it("prefers channels.feishu.defaultAccount when configured", () => {
|
||||
@@ -33,11 +37,26 @@ describe("resolveDefaultFeishuAccountId", () => {
|
||||
expect(resolveDefaultFeishuAccountId(cfg as never)).toBe("router-d");
|
||||
});
|
||||
|
||||
it("falls back to literal default account id when preferred is missing", () => {
|
||||
it("keeps configured defaultAccount even when not present in accounts map", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
feishu: {
|
||||
defaultAccount: "router-d",
|
||||
accounts: {
|
||||
default: { appId: "cli_default", appSecret: "secret_default" },
|
||||
zeta: { appId: "cli_zeta", appSecret: "secret_zeta" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveDefaultFeishuAccountId(cfg as never)).toBe("router-d");
|
||||
});
|
||||
|
||||
it("falls back to literal default account id when present", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
feishu: {
|
||||
defaultAccount: "missing",
|
||||
accounts: {
|
||||
default: { appId: "cli_default", appSecret: "secret_default" },
|
||||
zeta: { appId: "cli_zeta", appSecret: "secret_zeta" },
|
||||
@@ -48,9 +67,59 @@ describe("resolveDefaultFeishuAccountId", () => {
|
||||
|
||||
expect(resolveDefaultFeishuAccountId(cfg as never)).toBe("default");
|
||||
});
|
||||
|
||||
it("reports selection source for configured defaults and mapped defaults", () => {
|
||||
const explicitDefaultCfg = {
|
||||
channels: {
|
||||
feishu: {
|
||||
defaultAccount: "router-d",
|
||||
accounts: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(resolveDefaultFeishuAccountSelection(explicitDefaultCfg as never)).toEqual({
|
||||
accountId: "router-d",
|
||||
source: "explicit-default",
|
||||
});
|
||||
|
||||
const mappedDefaultCfg = {
|
||||
channels: {
|
||||
feishu: {
|
||||
accounts: {
|
||||
default: { appId: "cli_default", appSecret: "secret_default" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(resolveDefaultFeishuAccountSelection(mappedDefaultCfg as never)).toEqual({
|
||||
accountId: "default",
|
||||
source: "mapped-default",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveFeishuAccount", () => {
|
||||
it("uses top-level credentials with configured default account id even without account map entry", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
feishu: {
|
||||
defaultAccount: "router-d",
|
||||
appId: "top_level_app",
|
||||
appSecret: "top_level_secret",
|
||||
accounts: {
|
||||
default: { appId: "cli_default", appSecret: "secret_default" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveFeishuAccount({ cfg: cfg as never, accountId: undefined });
|
||||
expect(account.accountId).toBe("router-d");
|
||||
expect(account.selectionSource).toBe("explicit-default");
|
||||
expect(account.configured).toBe(true);
|
||||
expect(account.appId).toBe("top_level_app");
|
||||
});
|
||||
|
||||
it("uses configured default account when accountId is omitted", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
@@ -66,6 +135,7 @@ describe("resolveFeishuAccount", () => {
|
||||
|
||||
const account = resolveFeishuAccount({ cfg: cfg as never, accountId: undefined });
|
||||
expect(account.accountId).toBe("router-d");
|
||||
expect(account.selectionSource).toBe("explicit-default");
|
||||
expect(account.configured).toBe(true);
|
||||
expect(account.appId).toBe("cli_router");
|
||||
});
|
||||
@@ -85,6 +155,7 @@ describe("resolveFeishuAccount", () => {
|
||||
|
||||
const account = resolveFeishuAccount({ cfg: cfg as never, accountId: "default" });
|
||||
expect(account.accountId).toBe("default");
|
||||
expect(account.selectionSource).toBe("explicit");
|
||||
expect(account.appId).toBe("cli_default");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/acco
|
||||
import type {
|
||||
FeishuConfig,
|
||||
FeishuAccountConfig,
|
||||
FeishuDefaultAccountSelectionSource,
|
||||
FeishuDomain,
|
||||
ResolvedFeishuAccount,
|
||||
} from "./types.js";
|
||||
@@ -31,20 +32,39 @@ export function listFeishuAccountIds(cfg: ClawdbotConfig): string[] {
|
||||
return [...ids].toSorted((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the default account selection and its source.
|
||||
*/
|
||||
export function resolveDefaultFeishuAccountSelection(cfg: ClawdbotConfig): {
|
||||
accountId: string;
|
||||
source: FeishuDefaultAccountSelectionSource;
|
||||
} {
|
||||
const preferredRaw = (cfg.channels?.feishu as FeishuConfig | undefined)?.defaultAccount?.trim();
|
||||
const preferred = preferredRaw ? normalizeAccountId(preferredRaw) : undefined;
|
||||
if (preferred) {
|
||||
return {
|
||||
accountId: preferred,
|
||||
source: "explicit-default",
|
||||
};
|
||||
}
|
||||
const ids = listFeishuAccountIds(cfg);
|
||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
|
||||
return {
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
source: "mapped-default",
|
||||
};
|
||||
}
|
||||
return {
|
||||
accountId: ids[0] ?? DEFAULT_ACCOUNT_ID,
|
||||
source: "fallback",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the default account ID.
|
||||
*/
|
||||
export function resolveDefaultFeishuAccountId(cfg: ClawdbotConfig): string {
|
||||
const preferredRaw = (cfg.channels?.feishu as FeishuConfig | undefined)?.defaultAccount?.trim();
|
||||
const preferred = preferredRaw ? normalizeAccountId(preferredRaw) : undefined;
|
||||
const ids = listFeishuAccountIds(cfg);
|
||||
if (preferred && ids.includes(preferred)) {
|
||||
return preferred;
|
||||
}
|
||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
return resolveDefaultFeishuAccountSelection(cfg).accountId;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,9 +131,15 @@ export function resolveFeishuAccount(params: {
|
||||
}): ResolvedFeishuAccount {
|
||||
const hasExplicitAccountId =
|
||||
typeof params.accountId === "string" && params.accountId.trim() !== "";
|
||||
const defaultSelection = hasExplicitAccountId
|
||||
? null
|
||||
: resolveDefaultFeishuAccountSelection(params.cfg);
|
||||
const accountId = hasExplicitAccountId
|
||||
? normalizeAccountId(params.accountId)
|
||||
: resolveDefaultFeishuAccountId(params.cfg);
|
||||
: (defaultSelection?.accountId ?? DEFAULT_ACCOUNT_ID);
|
||||
const selectionSource = hasExplicitAccountId
|
||||
? "explicit"
|
||||
: (defaultSelection?.source ?? "fallback");
|
||||
const feishuCfg = params.cfg.channels?.feishu as FeishuConfig | undefined;
|
||||
|
||||
// Base enabled state (top-level)
|
||||
@@ -131,6 +157,7 @@ export function resolveFeishuAccount(params: {
|
||||
|
||||
return {
|
||||
accountId,
|
||||
selectionSource,
|
||||
enabled,
|
||||
configured: Boolean(creds),
|
||||
name: (merged as FeishuAccountConfig).name?.trim() || undefined,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { parseFeishuMessageEvent } from "./bot.js";
|
||||
|
||||
// Helper to build a minimal FeishuMessageEvent for testing
|
||||
function makeEvent(
|
||||
chatType: "p2p" | "group",
|
||||
chatType: "p2p" | "group" | "private",
|
||||
mentions?: Array<{ key: string; name: string; id: { open_id?: string } }>,
|
||||
text = "hello",
|
||||
) {
|
||||
|
||||
@@ -366,6 +366,41 @@ describe("handleFeishuMessage command authorization", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("replies pairing challenge to DM chat_id instead of user:sender id", async () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
dmPolicy: "pairing",
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const event: FeishuMessageEvent = {
|
||||
sender: {
|
||||
sender_id: {
|
||||
user_id: "u_mobile_only",
|
||||
},
|
||||
},
|
||||
message: {
|
||||
message_id: "msg-pairing-chat-reply",
|
||||
chat_id: "oc_dm_chat_1",
|
||||
chat_type: "p2p",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "hello" }),
|
||||
},
|
||||
};
|
||||
|
||||
mockReadAllowFromStore.mockResolvedValue([]);
|
||||
mockUpsertPairingRequest.mockResolvedValue({ code: "ABCDEFGH", created: true });
|
||||
|
||||
await dispatchMessage({ cfg, event });
|
||||
|
||||
expect(mockSendMessageFeishu).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "chat:oc_dm_chat_1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
it("creates pairing request and drops unauthorized DMs in pairing mode", async () => {
|
||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||
mockReadAllowFromStore.mockResolvedValue([]);
|
||||
@@ -410,7 +445,7 @@ describe("handleFeishuMessage command authorization", () => {
|
||||
});
|
||||
expect(mockSendMessageFeishu).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "user:ou-unapproved",
|
||||
to: "chat:oc-dm",
|
||||
accountId: "default",
|
||||
}),
|
||||
);
|
||||
@@ -1038,6 +1073,67 @@ describe("handleFeishuMessage command authorization", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores stale non-existent contact scope permission errors", async () => {
|
||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||
mockCreateFeishuClient.mockReturnValue({
|
||||
contact: {
|
||||
user: {
|
||||
get: vi.fn().mockRejectedValue({
|
||||
response: {
|
||||
data: {
|
||||
code: 99991672,
|
||||
msg: "permission denied: contact:contact.base:readonly https://open.feishu.cn/app/cli_scope_bug",
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
appId: "cli_scope_bug",
|
||||
appSecret: "sec_scope_bug",
|
||||
groups: {
|
||||
"oc-group": {
|
||||
requireMention: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const event: FeishuMessageEvent = {
|
||||
sender: {
|
||||
sender_id: {
|
||||
open_id: "ou-perm-scope",
|
||||
},
|
||||
},
|
||||
message: {
|
||||
message_id: "msg-perm-scope-1",
|
||||
chat_id: "oc-group",
|
||||
chat_type: "group",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "hello group" }),
|
||||
},
|
||||
};
|
||||
|
||||
await dispatchMessage({ cfg, event });
|
||||
|
||||
expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
|
||||
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
BodyForAgent: expect.not.stringContaining("Permission grant URL"),
|
||||
}),
|
||||
);
|
||||
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
BodyForAgent: expect.stringContaining("ou-perm-scope: hello group"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("routes group sessions by sender when groupSessionScope=group_sender", async () => {
|
||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||
|
||||
@@ -1113,6 +1209,83 @@ describe("handleFeishuMessage command authorization", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps root_id as topic key when root_id and thread_id both exist", async () => {
|
||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
groups: {
|
||||
"oc-group": {
|
||||
requireMention: false,
|
||||
groupSessionScope: "group_topic_sender",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const event: FeishuMessageEvent = {
|
||||
sender: { sender_id: { open_id: "ou-topic-user" } },
|
||||
message: {
|
||||
message_id: "msg-scope-topic-thread-id",
|
||||
chat_id: "oc-group",
|
||||
chat_type: "group",
|
||||
root_id: "om_root_topic",
|
||||
thread_id: "omt_topic_1",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "topic sender scope" }),
|
||||
},
|
||||
};
|
||||
|
||||
await dispatchMessage({ cfg, event });
|
||||
|
||||
expect(mockResolveAgentRoute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
peer: { kind: "group", id: "oc-group:topic:om_root_topic:sender:ou-topic-user" },
|
||||
parentPeer: { kind: "group", id: "oc-group" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses thread_id as topic key when root_id is missing", async () => {
|
||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
groups: {
|
||||
"oc-group": {
|
||||
requireMention: false,
|
||||
groupSessionScope: "group_topic_sender",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const event: FeishuMessageEvent = {
|
||||
sender: { sender_id: { open_id: "ou-topic-user" } },
|
||||
message: {
|
||||
message_id: "msg-scope-topic-thread-only",
|
||||
chat_id: "oc-group",
|
||||
chat_type: "group",
|
||||
thread_id: "omt_topic_1",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "topic sender scope" }),
|
||||
},
|
||||
};
|
||||
|
||||
await dispatchMessage({ cfg, event });
|
||||
|
||||
expect(mockResolveAgentRoute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
peer: { kind: "group", id: "oc-group:topic:omt_topic_1:sender:ou-topic-user" },
|
||||
parentPeer: { kind: "group", id: "oc-group" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("maps legacy topicSessionMode=enabled to group_topic routing", async () => {
|
||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||
|
||||
@@ -1151,6 +1324,45 @@ describe("handleFeishuMessage command authorization", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("maps legacy topicSessionMode=enabled to root_id when both root_id and thread_id exist", async () => {
|
||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
topicSessionMode: "enabled",
|
||||
groups: {
|
||||
"oc-group": {
|
||||
requireMention: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const event: FeishuMessageEvent = {
|
||||
sender: { sender_id: { open_id: "ou-legacy-thread-id" } },
|
||||
message: {
|
||||
message_id: "msg-legacy-topic-thread-id",
|
||||
chat_id: "oc-group",
|
||||
chat_type: "group",
|
||||
root_id: "om_root_legacy",
|
||||
thread_id: "omt_topic_legacy",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "legacy topic mode" }),
|
||||
},
|
||||
};
|
||||
|
||||
await dispatchMessage({ cfg, event });
|
||||
|
||||
expect(mockResolveAgentRoute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
peer: { kind: "group", id: "oc-group:topic:om_root_legacy" },
|
||||
parentPeer: { kind: "group", id: "oc-group" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses message_id as topic root when group_topic + replyInThread and no root_id", async () => {
|
||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||
|
||||
@@ -1189,6 +1401,140 @@ describe("handleFeishuMessage command authorization", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps topic session key stable after first turn creates a thread", async () => {
|
||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
groups: {
|
||||
"oc-group": {
|
||||
requireMention: false,
|
||||
groupSessionScope: "group_topic",
|
||||
replyInThread: "enabled",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const firstTurn: FeishuMessageEvent = {
|
||||
sender: { sender_id: { open_id: "ou-topic-init" } },
|
||||
message: {
|
||||
message_id: "msg-topic-first",
|
||||
chat_id: "oc-group",
|
||||
chat_type: "group",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "create topic" }),
|
||||
},
|
||||
};
|
||||
const secondTurn: FeishuMessageEvent = {
|
||||
sender: { sender_id: { open_id: "ou-topic-init" } },
|
||||
message: {
|
||||
message_id: "msg-topic-second",
|
||||
chat_id: "oc-group",
|
||||
chat_type: "group",
|
||||
root_id: "msg-topic-first",
|
||||
thread_id: "omt_topic_created",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "follow up in same topic" }),
|
||||
},
|
||||
};
|
||||
|
||||
await dispatchMessage({ cfg, event: firstTurn });
|
||||
await dispatchMessage({ cfg, event: secondTurn });
|
||||
|
||||
expect(mockResolveAgentRoute).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
peer: { kind: "group", id: "oc-group:topic:msg-topic-first" },
|
||||
}),
|
||||
);
|
||||
expect(mockResolveAgentRoute).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
peer: { kind: "group", id: "oc-group:topic:msg-topic-first" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("replies to the topic root when handling a message inside an existing topic", async () => {
|
||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
groups: {
|
||||
"oc-group": {
|
||||
requireMention: false,
|
||||
replyInThread: "enabled",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const event: FeishuMessageEvent = {
|
||||
sender: { sender_id: { open_id: "ou-topic-user" } },
|
||||
message: {
|
||||
message_id: "om_child_message",
|
||||
root_id: "om_root_topic",
|
||||
chat_id: "oc-group",
|
||||
chat_type: "group",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "reply inside topic" }),
|
||||
},
|
||||
};
|
||||
|
||||
await dispatchMessage({ cfg, event });
|
||||
|
||||
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replyToMessageId: "om_root_topic",
|
||||
rootId: "om_root_topic",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("forces thread replies when inbound message contains thread_id", async () => {
|
||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
feishu: {
|
||||
groups: {
|
||||
"oc-group": {
|
||||
requireMention: false,
|
||||
groupSessionScope: "group",
|
||||
replyInThread: "disabled",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
const event: FeishuMessageEvent = {
|
||||
sender: { sender_id: { open_id: "ou-thread-reply" } },
|
||||
message: {
|
||||
message_id: "msg-thread-reply",
|
||||
chat_id: "oc-group",
|
||||
chat_type: "group",
|
||||
thread_id: "omt_topic_thread_reply",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "thread content" }),
|
||||
},
|
||||
};
|
||||
|
||||
await dispatchMessage({ cfg, event });
|
||||
|
||||
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replyInThread: true,
|
||||
threadReply: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not dispatch twice for the same image message_id (concurrent dedupe)", async () => {
|
||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||
|
||||
|
||||
@@ -44,6 +44,13 @@ type PermissionError = {
|
||||
grantUrl?: string;
|
||||
};
|
||||
|
||||
const IGNORED_PERMISSION_SCOPE_TOKENS = ["contact:contact.base:readonly"];
|
||||
|
||||
function shouldSuppressPermissionErrorNotice(permissionError: PermissionError): boolean {
|
||||
const message = permissionError.message.toLowerCase();
|
||||
return IGNORED_PERMISSION_SCOPE_TOKENS.some((token) => message.includes(token));
|
||||
}
|
||||
|
||||
function extractPermissionError(err: unknown): PermissionError | null {
|
||||
if (!err || typeof err !== "object") return null;
|
||||
|
||||
@@ -140,6 +147,10 @@ async function resolveFeishuSenderName(params: {
|
||||
// Check if this is a permission error
|
||||
const permErr = extractPermissionError(err);
|
||||
if (permErr) {
|
||||
if (shouldSuppressPermissionErrorNotice(permErr)) {
|
||||
log(`feishu: ignoring stale permission scope error: ${permErr.message}`);
|
||||
return {};
|
||||
}
|
||||
log(`feishu: permission error resolving sender name: code=${permErr.code}`);
|
||||
return { permissionError: permErr };
|
||||
}
|
||||
@@ -164,8 +175,9 @@ export type FeishuMessageEvent = {
|
||||
message_id: string;
|
||||
root_id?: string;
|
||||
parent_id?: string;
|
||||
thread_id?: string;
|
||||
chat_id: string;
|
||||
chat_type: "p2p" | "group";
|
||||
chat_type: "p2p" | "group" | "private";
|
||||
message_type: string;
|
||||
content: string;
|
||||
create_time?: string;
|
||||
@@ -193,6 +205,94 @@ export type FeishuBotAddedEvent = {
|
||||
operator_tenant_key?: string;
|
||||
};
|
||||
|
||||
type GroupSessionScope = "group" | "group_sender" | "group_topic" | "group_topic_sender";
|
||||
|
||||
type ResolvedFeishuGroupSession = {
|
||||
peerId: string;
|
||||
parentPeer: { kind: "group"; id: string } | null;
|
||||
groupSessionScope: GroupSessionScope;
|
||||
replyInThread: boolean;
|
||||
threadReply: boolean;
|
||||
};
|
||||
|
||||
function resolveFeishuGroupSession(params: {
|
||||
chatId: string;
|
||||
senderOpenId: string;
|
||||
messageId: string;
|
||||
rootId?: string;
|
||||
threadId?: string;
|
||||
groupConfig?: {
|
||||
groupSessionScope?: GroupSessionScope;
|
||||
topicSessionMode?: "enabled" | "disabled";
|
||||
replyInThread?: "enabled" | "disabled";
|
||||
};
|
||||
feishuCfg?: {
|
||||
groupSessionScope?: GroupSessionScope;
|
||||
topicSessionMode?: "enabled" | "disabled";
|
||||
replyInThread?: "enabled" | "disabled";
|
||||
};
|
||||
}): ResolvedFeishuGroupSession {
|
||||
const { chatId, senderOpenId, messageId, rootId, threadId, groupConfig, feishuCfg } = params;
|
||||
|
||||
const normalizedThreadId = threadId?.trim();
|
||||
const normalizedRootId = rootId?.trim();
|
||||
const threadReply = Boolean(normalizedThreadId || normalizedRootId);
|
||||
const replyInThread =
|
||||
(groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled" ||
|
||||
threadReply;
|
||||
|
||||
const legacyTopicSessionMode =
|
||||
groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
|
||||
const groupSessionScope: GroupSessionScope =
|
||||
groupConfig?.groupSessionScope ??
|
||||
feishuCfg?.groupSessionScope ??
|
||||
(legacyTopicSessionMode === "enabled" ? "group_topic" : "group");
|
||||
|
||||
// Keep topic session keys stable across the "first turn creates thread" flow:
|
||||
// first turn may only have message_id, while the next turn carries root_id/thread_id.
|
||||
// Prefer root_id first so both turns stay on the same peer key.
|
||||
const topicScope =
|
||||
groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender"
|
||||
? (normalizedRootId ?? normalizedThreadId ?? (replyInThread ? messageId : null))
|
||||
: null;
|
||||
|
||||
let peerId = chatId;
|
||||
switch (groupSessionScope) {
|
||||
case "group_sender":
|
||||
peerId = `${chatId}:sender:${senderOpenId}`;
|
||||
break;
|
||||
case "group_topic":
|
||||
peerId = topicScope ? `${chatId}:topic:${topicScope}` : chatId;
|
||||
break;
|
||||
case "group_topic_sender":
|
||||
peerId = topicScope
|
||||
? `${chatId}:topic:${topicScope}:sender:${senderOpenId}`
|
||||
: `${chatId}:sender:${senderOpenId}`;
|
||||
break;
|
||||
case "group":
|
||||
default:
|
||||
peerId = chatId;
|
||||
break;
|
||||
}
|
||||
|
||||
const parentPeer =
|
||||
topicScope &&
|
||||
(groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender")
|
||||
? {
|
||||
kind: "group" as const,
|
||||
id: chatId,
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
peerId,
|
||||
parentPeer,
|
||||
groupSessionScope,
|
||||
replyInThread,
|
||||
threadReply,
|
||||
};
|
||||
}
|
||||
|
||||
function parseMessageContent(content: string, messageType: string): string {
|
||||
if (messageType === "post") {
|
||||
// Extract text content from rich text post
|
||||
@@ -624,6 +724,7 @@ export function parseFeishuMessageEvent(
|
||||
mentionedBot,
|
||||
rootId: event.message.root_id || undefined,
|
||||
parentId: event.message.parent_id || undefined,
|
||||
threadId: event.message.thread_id || undefined,
|
||||
content,
|
||||
contentType: event.message.message_type,
|
||||
};
|
||||
@@ -709,6 +810,7 @@ export async function handleFeishuMessage(params: {
|
||||
|
||||
let ctx = parseFeishuMessageEvent(event, botOpenId);
|
||||
const isGroup = ctx.chatType === "group";
|
||||
const isDirect = !isGroup;
|
||||
const senderUserId = event.sender.sender_id.user_id?.trim() || undefined;
|
||||
|
||||
// Handle merge_forward messages: fetch full message via API then expand sub-messages
|
||||
@@ -784,6 +886,18 @@ export async function handleFeishuMessage(params: {
|
||||
const groupConfig = isGroup
|
||||
? resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId })
|
||||
: undefined;
|
||||
const groupSession = isGroup
|
||||
? resolveFeishuGroupSession({
|
||||
chatId: ctx.chatId,
|
||||
senderOpenId: ctx.senderOpenId,
|
||||
messageId: ctx.messageId,
|
||||
rootId: ctx.rootId,
|
||||
threadId: ctx.threadId,
|
||||
groupConfig,
|
||||
feishuCfg,
|
||||
})
|
||||
: null;
|
||||
const groupHistoryKey = isGroup ? (groupSession?.peerId ?? ctx.chatId) : undefined;
|
||||
const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
|
||||
const configAllowFrom = feishuCfg?.allowFrom ?? [];
|
||||
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
||||
@@ -852,10 +966,10 @@ export async function handleFeishuMessage(params: {
|
||||
log(
|
||||
`feishu[${account.accountId}]: message in group ${ctx.chatId} did not mention bot, recording to history`,
|
||||
);
|
||||
if (chatHistories) {
|
||||
if (chatHistories && groupHistoryKey) {
|
||||
recordPendingHistoryEntryIfEnabled({
|
||||
historyMap: chatHistories,
|
||||
historyKey: ctx.chatId,
|
||||
historyKey: groupHistoryKey,
|
||||
limit: historyLimit,
|
||||
entry: {
|
||||
sender: ctx.senderOpenId,
|
||||
@@ -895,7 +1009,7 @@ export async function handleFeishuMessage(params: {
|
||||
senderName: ctx.senderName,
|
||||
}).allowed;
|
||||
|
||||
if (!isGroup && dmPolicy !== "open" && !dmAllowed) {
|
||||
if (isDirect && dmPolicy !== "open" && !dmAllowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
const { code, created } = await pairing.upsertPairingRequest({
|
||||
id: ctx.senderOpenId,
|
||||
@@ -906,7 +1020,7 @@ export async function handleFeishuMessage(params: {
|
||||
try {
|
||||
await sendMessageFeishu({
|
||||
cfg,
|
||||
to: `user:${ctx.senderOpenId}`,
|
||||
to: `chat:${ctx.chatId}`,
|
||||
text: core.channel.pairing.buildPairingReply({
|
||||
channel: "feishu",
|
||||
idLine: `Your Feishu user id: ${ctx.senderOpenId}`,
|
||||
@@ -950,50 +1064,14 @@ export async function handleFeishuMessage(params: {
|
||||
// Using a group-scoped From causes the agent to treat different users as the same person.
|
||||
const feishuFrom = `feishu:${ctx.senderOpenId}`;
|
||||
const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
|
||||
const peerId = isGroup ? (groupSession?.peerId ?? ctx.chatId) : ctx.senderOpenId;
|
||||
const parentPeer = isGroup ? (groupSession?.parentPeer ?? null) : null;
|
||||
const replyInThread = isGroup ? (groupSession?.replyInThread ?? false) : false;
|
||||
|
||||
// Resolve peer ID for session routing.
|
||||
// Default is one session per group chat; this can be customized with groupSessionScope.
|
||||
let peerId = isGroup ? ctx.chatId : ctx.senderOpenId;
|
||||
let groupSessionScope: "group" | "group_sender" | "group_topic" | "group_topic_sender" =
|
||||
"group";
|
||||
let topicRootForSession: string | null = null;
|
||||
const replyInThread =
|
||||
isGroup &&
|
||||
(groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled";
|
||||
|
||||
if (isGroup) {
|
||||
const legacyTopicSessionMode =
|
||||
groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
|
||||
groupSessionScope =
|
||||
groupConfig?.groupSessionScope ??
|
||||
feishuCfg?.groupSessionScope ??
|
||||
(legacyTopicSessionMode === "enabled" ? "group_topic" : "group");
|
||||
|
||||
// When topic-scoped sessions are enabled and replyInThread is on, the first
|
||||
// bot reply creates the thread rooted at the current message ID.
|
||||
if (groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender") {
|
||||
topicRootForSession = ctx.rootId ?? (replyInThread ? ctx.messageId : null);
|
||||
}
|
||||
|
||||
switch (groupSessionScope) {
|
||||
case "group_sender":
|
||||
peerId = `${ctx.chatId}:sender:${ctx.senderOpenId}`;
|
||||
break;
|
||||
case "group_topic":
|
||||
peerId = topicRootForSession ? `${ctx.chatId}:topic:${topicRootForSession}` : ctx.chatId;
|
||||
break;
|
||||
case "group_topic_sender":
|
||||
peerId = topicRootForSession
|
||||
? `${ctx.chatId}:topic:${topicRootForSession}:sender:${ctx.senderOpenId}`
|
||||
: `${ctx.chatId}:sender:${ctx.senderOpenId}`;
|
||||
break;
|
||||
case "group":
|
||||
default:
|
||||
peerId = ctx.chatId;
|
||||
break;
|
||||
}
|
||||
|
||||
log(`feishu[${account.accountId}]: group session scope=${groupSessionScope}, peer=${peerId}`);
|
||||
if (isGroup && groupSession) {
|
||||
log(
|
||||
`feishu[${account.accountId}]: group session scope=${groupSession.groupSessionScope}, peer=${peerId}`,
|
||||
);
|
||||
}
|
||||
|
||||
let route = core.channel.routing.resolveAgentRoute({
|
||||
@@ -1004,16 +1082,7 @@ export async function handleFeishuMessage(params: {
|
||||
kind: isGroup ? "group" : "direct",
|
||||
id: peerId,
|
||||
},
|
||||
// Add parentPeer for binding inheritance in topic-scoped modes.
|
||||
parentPeer:
|
||||
isGroup &&
|
||||
topicRootForSession &&
|
||||
(groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender")
|
||||
? {
|
||||
kind: "group",
|
||||
id: ctx.chatId,
|
||||
}
|
||||
: null,
|
||||
parentPeer,
|
||||
});
|
||||
|
||||
// Dynamic agent creation for DM users
|
||||
@@ -1110,7 +1179,7 @@ export async function handleFeishuMessage(params: {
|
||||
});
|
||||
|
||||
let combinedBody = body;
|
||||
const historyKey = isGroup ? ctx.chatId : undefined;
|
||||
const historyKey = groupHistoryKey;
|
||||
|
||||
if (isGroup && historyKey && chatHistories) {
|
||||
combinedBody = buildPendingHistoryContextFromMap({
|
||||
@@ -1173,16 +1242,17 @@ export async function handleFeishuMessage(params: {
|
||||
const messageCreateTimeMs = event.message.create_time
|
||||
? parseInt(event.message.create_time, 10)
|
||||
: undefined;
|
||||
|
||||
const replyTargetMessageId = ctx.rootId ?? ctx.messageId;
|
||||
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
runtime: runtime as RuntimeEnv,
|
||||
chatId: ctx.chatId,
|
||||
replyToMessageId: ctx.messageId,
|
||||
replyToMessageId: replyTargetMessageId,
|
||||
skipReplyToInMessages: !isGroup,
|
||||
replyInThread,
|
||||
rootId: ctx.rootId,
|
||||
threadReply: isGroup ? (groupSession?.threadReply ?? false) : false,
|
||||
mentionTargets: ctx.mentionTargets,
|
||||
accountId: account.accountId,
|
||||
messageCreateTimeMs,
|
||||
|
||||
@@ -34,6 +34,7 @@ let priorProxyEnv: Partial<Record<ProxyEnvKey, string | undefined>> = {};
|
||||
|
||||
const baseAccount: ResolvedFeishuAccount = {
|
||||
accountId: "main",
|
||||
selectionSource: "explicit",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
appId: "app_123",
|
||||
|
||||
@@ -110,6 +110,9 @@ const GroupSessionScopeSchema = z
|
||||
* Topic session isolation mode for group chats.
|
||||
* - "disabled" (default): All messages in a group share one session
|
||||
* - "enabled": Messages in different topics get separate sessions
|
||||
*
|
||||
* Topic routing uses `root_id` when present to keep session continuity and
|
||||
* falls back to `thread_id` when `root_id` is unavailable.
|
||||
*/
|
||||
const TopicSessionModeSchema = z.enum(["disabled", "enabled"]).optional();
|
||||
const ReactionNotificationModeSchema = z.enum(["off", "own", "all"]).optional();
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createDedupeCache, createPersistentDedupe } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
createDedupeCache,
|
||||
createPersistentDedupe,
|
||||
readJsonFileWithFallback,
|
||||
} from "openclaw/plugin-sdk";
|
||||
|
||||
// Persistent TTL: 24 hours — survives restarts & WebSocket reconnects.
|
||||
const DEDUP_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const MEMORY_MAX_SIZE = 1_000;
|
||||
const FILE_MAX_ENTRIES = 10_000;
|
||||
type PersistentDedupeData = Record<string, number>;
|
||||
|
||||
const memoryDedupe = createDedupeCache({ ttlMs: DEDUP_TTL_MS, maxSize: MEMORY_MAX_SIZE });
|
||||
|
||||
@@ -40,6 +45,14 @@ export function tryRecordMessage(messageId: string): boolean {
|
||||
return !memoryDedupe.check(messageId);
|
||||
}
|
||||
|
||||
export function hasRecordedMessage(messageId: string): boolean {
|
||||
const trimmed = messageId.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
return memoryDedupe.peek(trimmed);
|
||||
}
|
||||
|
||||
export async function tryRecordMessagePersistent(
|
||||
messageId: string,
|
||||
namespace = "global",
|
||||
@@ -52,3 +65,36 @@ export async function tryRecordMessagePersistent(
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function hasRecordedMessagePersistent(
|
||||
messageId: string,
|
||||
namespace = "global",
|
||||
log?: (...args: unknown[]) => void,
|
||||
): Promise<boolean> {
|
||||
const trimmed = messageId.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
const now = Date.now();
|
||||
const filePath = resolveNamespaceFilePath(namespace);
|
||||
try {
|
||||
const { value } = await readJsonFileWithFallback<PersistentDedupeData>(filePath, {});
|
||||
const seenAt = value[trimmed];
|
||||
if (typeof seenAt !== "number" || !Number.isFinite(seenAt)) {
|
||||
return false;
|
||||
}
|
||||
return DEDUP_TTL_MS <= 0 || now - seenAt < DEDUP_TTL_MS;
|
||||
} catch (error) {
|
||||
log?.(`feishu-dedup: persistent peek failed: ${String(error)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function warmupDedupFromDisk(
|
||||
namespace: string,
|
||||
log?: (...args: unknown[]) => void,
|
||||
): Promise<number> {
|
||||
return persistentDedupe.warmup(namespace, (error) => {
|
||||
log?.(`feishu-dedup: warmup disk error: ${String(error)}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -36,7 +36,12 @@ vi.mock("./runtime.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
import { downloadImageFeishu, downloadMessageResourceFeishu, sendMediaFeishu } from "./media.js";
|
||||
import {
|
||||
downloadImageFeishu,
|
||||
downloadMessageResourceFeishu,
|
||||
sanitizeFileNameForUpload,
|
||||
sendMediaFeishu,
|
||||
} from "./media.js";
|
||||
|
||||
function expectPathIsolatedToTmpRoot(pathValue: string, key: string): void {
|
||||
expect(pathValue).not.toContain(key);
|
||||
@@ -334,6 +339,104 @@ describe("sendMediaFeishu msg_type routing", () => {
|
||||
|
||||
expect(messageResourceGetMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("encodes Chinese filenames for file uploads", async () => {
|
||||
await sendMediaFeishu({
|
||||
cfg: {} as any,
|
||||
to: "user:ou_target",
|
||||
mediaBuffer: Buffer.from("doc"),
|
||||
fileName: "测试文档.pdf",
|
||||
});
|
||||
|
||||
const createCall = fileCreateMock.mock.calls[0][0];
|
||||
expect(createCall.data.file_name).not.toBe("测试文档.pdf");
|
||||
expect(createCall.data.file_name).toBe(encodeURIComponent("测试文档") + ".pdf");
|
||||
});
|
||||
|
||||
it("preserves ASCII filenames unchanged for file uploads", async () => {
|
||||
await sendMediaFeishu({
|
||||
cfg: {} as any,
|
||||
to: "user:ou_target",
|
||||
mediaBuffer: Buffer.from("doc"),
|
||||
fileName: "report-2026.pdf",
|
||||
});
|
||||
|
||||
const createCall = fileCreateMock.mock.calls[0][0];
|
||||
expect(createCall.data.file_name).toBe("report-2026.pdf");
|
||||
});
|
||||
|
||||
it("encodes special characters (em-dash, full-width brackets) in filenames", async () => {
|
||||
await sendMediaFeishu({
|
||||
cfg: {} as any,
|
||||
to: "user:ou_target",
|
||||
mediaBuffer: Buffer.from("doc"),
|
||||
fileName: "报告—详情(2026).md",
|
||||
});
|
||||
|
||||
const createCall = fileCreateMock.mock.calls[0][0];
|
||||
expect(createCall.data.file_name).toMatch(/\.md$/);
|
||||
expect(createCall.data.file_name).not.toContain("—");
|
||||
expect(createCall.data.file_name).not.toContain("(");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeFileNameForUpload", () => {
|
||||
it("returns ASCII filenames unchanged", () => {
|
||||
expect(sanitizeFileNameForUpload("report.pdf")).toBe("report.pdf");
|
||||
expect(sanitizeFileNameForUpload("my-file_v2.txt")).toBe("my-file_v2.txt");
|
||||
});
|
||||
|
||||
it("encodes Chinese characters in basename, preserves extension", () => {
|
||||
const result = sanitizeFileNameForUpload("测试文件.md");
|
||||
expect(result).toBe(encodeURIComponent("测试文件") + ".md");
|
||||
expect(result).toMatch(/\.md$/);
|
||||
});
|
||||
|
||||
it("encodes em-dash and full-width brackets", () => {
|
||||
const result = sanitizeFileNameForUpload("文件—说明(v2).pdf");
|
||||
expect(result).toMatch(/\.pdf$/);
|
||||
expect(result).not.toContain("—");
|
||||
expect(result).not.toContain("(");
|
||||
expect(result).not.toContain(")");
|
||||
});
|
||||
|
||||
it("encodes single quotes and parentheses per RFC 5987", () => {
|
||||
const result = sanitizeFileNameForUpload("文件'(test).txt");
|
||||
expect(result).toContain("%27");
|
||||
expect(result).toContain("%28");
|
||||
expect(result).toContain("%29");
|
||||
expect(result).toMatch(/\.txt$/);
|
||||
});
|
||||
|
||||
it("handles filenames without extension", () => {
|
||||
const result = sanitizeFileNameForUpload("测试文件");
|
||||
expect(result).toBe(encodeURIComponent("测试文件"));
|
||||
});
|
||||
|
||||
it("handles mixed ASCII and non-ASCII", () => {
|
||||
const result = sanitizeFileNameForUpload("Report_报告_2026.xlsx");
|
||||
expect(result).toMatch(/\.xlsx$/);
|
||||
expect(result).not.toContain("报告");
|
||||
});
|
||||
|
||||
it("encodes non-ASCII extensions", () => {
|
||||
const result = sanitizeFileNameForUpload("报告.文档");
|
||||
expect(result).toContain("%E6%96%87%E6%A1%A3");
|
||||
expect(result).not.toContain("文档");
|
||||
});
|
||||
|
||||
it("encodes emoji filenames", () => {
|
||||
const result = sanitizeFileNameForUpload("report_😀.txt");
|
||||
expect(result).toContain("%F0%9F%98%80");
|
||||
expect(result).toMatch(/\.txt$/);
|
||||
});
|
||||
|
||||
it("encodes mixed ASCII and non-ASCII extensions", () => {
|
||||
const result = sanitizeFileNameForUpload("notes_总结.v测试");
|
||||
expect(result).toContain("notes_");
|
||||
expect(result).toContain("%E6%B5%8B%E8%AF%95");
|
||||
expect(result).not.toContain("测试");
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadMessageResourceFeishu", () => {
|
||||
|
||||
@@ -207,6 +207,24 @@ export async function uploadImageFeishu(params: {
|
||||
return { imageKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a filename for safe use in Feishu multipart/form-data uploads.
|
||||
* Non-ASCII characters (Chinese, em-dash, full-width brackets, etc.) cause
|
||||
* the upload to silently fail when passed raw through the SDK's form-data
|
||||
* serialization. RFC 5987 percent-encoding keeps headers 7-bit clean while
|
||||
* Feishu's server decodes and preserves the original display name.
|
||||
*/
|
||||
export function sanitizeFileNameForUpload(fileName: string): string {
|
||||
const ASCII_ONLY = /^[\x20-\x7E]+$/;
|
||||
if (ASCII_ONLY.test(fileName)) {
|
||||
return fileName;
|
||||
}
|
||||
return encodeURIComponent(fileName)
|
||||
.replace(/'/g, "%27")
|
||||
.replace(/\(/g, "%28")
|
||||
.replace(/\)/g, "%29");
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file to Feishu and get a file_key for sending.
|
||||
* Max file size: 30MB
|
||||
@@ -232,10 +250,12 @@ export async function uploadFileFeishu(params: {
|
||||
// See: https://github.com/larksuite/node-sdk/issues/121
|
||||
const fileData = typeof file === "string" ? fs.createReadStream(file) : file;
|
||||
|
||||
const safeFileName = sanitizeFileNameForUpload(fileName);
|
||||
|
||||
const response = await client.im.file.create({
|
||||
data: {
|
||||
file_type: fileType,
|
||||
file_name: fileName,
|
||||
file_name: safeFileName,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK accepts Buffer or ReadStream
|
||||
file: fileData as any,
|
||||
...(duration !== undefined && { duration }),
|
||||
|
||||
@@ -53,7 +53,7 @@ export function isMentionForwardRequest(event: FeishuMessageEvent, botOpenId?: s
|
||||
return false;
|
||||
}
|
||||
|
||||
const isDirectMessage = event.message.chat_type === "p2p";
|
||||
const isDirectMessage = event.message.chat_type !== "group";
|
||||
const hasOtherMention = mentions.some((m) => m.id.open_id !== botOpenId);
|
||||
|
||||
if (isDirectMessage) {
|
||||
|
||||
@@ -3,12 +3,26 @@ import * as Lark from "@larksuiteoapi/node-sdk";
|
||||
import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk";
|
||||
import { resolveFeishuAccount } from "./accounts.js";
|
||||
import { raceWithTimeoutAndAbort } from "./async.js";
|
||||
import { handleFeishuMessage, type FeishuMessageEvent, type FeishuBotAddedEvent } from "./bot.js";
|
||||
import {
|
||||
handleFeishuMessage,
|
||||
parseFeishuMessageEvent,
|
||||
type FeishuMessageEvent,
|
||||
type FeishuBotAddedEvent,
|
||||
} from "./bot.js";
|
||||
import { handleFeishuCardAction, type FeishuCardActionEvent } from "./card-action.js";
|
||||
import { createEventDispatcher } from "./client.js";
|
||||
import {
|
||||
hasRecordedMessage,
|
||||
hasRecordedMessagePersistent,
|
||||
tryRecordMessage,
|
||||
tryRecordMessagePersistent,
|
||||
warmupDedupFromDisk,
|
||||
} from "./dedup.js";
|
||||
import { isMentionForwardRequest } from "./mention.js";
|
||||
import { fetchBotOpenIdForMonitor } from "./monitor.startup.js";
|
||||
import { botOpenIds } from "./monitor.state.js";
|
||||
import { monitorWebhook, monitorWebSocket } from "./monitor.transport.js";
|
||||
import { getFeishuRuntime } from "./runtime.js";
|
||||
import { getMessageFeishu } from "./send.js";
|
||||
import type { ResolvedFeishuAccount } from "./types.js";
|
||||
|
||||
@@ -17,7 +31,7 @@ const FEISHU_REACTION_VERIFY_TIMEOUT_MS = 1_500;
|
||||
export type FeishuReactionCreatedEvent = {
|
||||
message_id: string;
|
||||
chat_id?: string;
|
||||
chat_type?: "p2p" | "group";
|
||||
chat_type?: "p2p" | "group" | "private";
|
||||
reaction_type?: { emoji_type?: string };
|
||||
operator_type?: string;
|
||||
user_id?: { open_id?: string };
|
||||
@@ -93,7 +107,8 @@ export async function resolveReactionSyntheticEvent(
|
||||
|
||||
const syntheticChatIdRaw = event.chat_id ?? reactedMsg.chatId;
|
||||
const syntheticChatId = syntheticChatIdRaw?.trim() ? syntheticChatIdRaw : `p2p:${senderId}`;
|
||||
const syntheticChatType: "p2p" | "group" = event.chat_type ?? "p2p";
|
||||
const syntheticChatType: "p2p" | "group" | "private" =
|
||||
event.chat_type === "group" ? "group" : "p2p";
|
||||
return {
|
||||
sender: {
|
||||
sender_id: { open_id: senderId },
|
||||
@@ -119,33 +134,261 @@ type RegisterEventHandlersContext = {
|
||||
fireAndForget?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-chat serial queue that ensures messages from the same chat are processed
|
||||
* in arrival order while allowing different chats to run concurrently.
|
||||
*/
|
||||
function createChatQueue() {
|
||||
const queues = new Map<string, Promise<void>>();
|
||||
return (chatId: string, task: () => Promise<void>): Promise<void> => {
|
||||
const prev = queues.get(chatId) ?? Promise.resolve();
|
||||
const next = prev.then(task, task);
|
||||
queues.set(chatId, next);
|
||||
void next.finally(() => {
|
||||
if (queues.get(chatId) === next) {
|
||||
queues.delete(chatId);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
};
|
||||
}
|
||||
|
||||
function mergeFeishuDebounceMentions(
|
||||
entries: FeishuMessageEvent[],
|
||||
): FeishuMessageEvent["message"]["mentions"] | undefined {
|
||||
const merged = new Map<string, NonNullable<FeishuMessageEvent["message"]["mentions"]>[number]>();
|
||||
for (const entry of entries) {
|
||||
for (const mention of entry.message.mentions ?? []) {
|
||||
const stableId =
|
||||
mention.id.open_id?.trim() || mention.id.user_id?.trim() || mention.id.union_id?.trim();
|
||||
const mentionName = mention.name?.trim();
|
||||
const mentionKey = mention.key?.trim();
|
||||
const fallback =
|
||||
mentionName && mentionKey ? `${mentionName}|${mentionKey}` : mentionName || mentionKey;
|
||||
const key = stableId || fallback;
|
||||
if (!key || merged.has(key)) {
|
||||
continue;
|
||||
}
|
||||
merged.set(key, mention);
|
||||
}
|
||||
}
|
||||
if (merged.size === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return Array.from(merged.values());
|
||||
}
|
||||
|
||||
function dedupeFeishuDebounceEntriesByMessageId(
|
||||
entries: FeishuMessageEvent[],
|
||||
): FeishuMessageEvent[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: FeishuMessageEvent[] = [];
|
||||
for (const entry of entries) {
|
||||
const messageId = entry.message.message_id?.trim();
|
||||
if (!messageId) {
|
||||
deduped.push(entry);
|
||||
continue;
|
||||
}
|
||||
if (seen.has(messageId)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(messageId);
|
||||
deduped.push(entry);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function resolveFeishuDebounceMentions(params: {
|
||||
entries: FeishuMessageEvent[];
|
||||
botOpenId?: string;
|
||||
}): FeishuMessageEvent["message"]["mentions"] | undefined {
|
||||
const { entries, botOpenId } = params;
|
||||
if (entries.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
||||
const entry = entries[index];
|
||||
if (isMentionForwardRequest(entry, botOpenId)) {
|
||||
// Keep mention-forward semantics scoped to a single source message.
|
||||
return mergeFeishuDebounceMentions([entry]);
|
||||
}
|
||||
}
|
||||
const merged = mergeFeishuDebounceMentions(entries);
|
||||
if (!merged) {
|
||||
return undefined;
|
||||
}
|
||||
const normalizedBotOpenId = botOpenId?.trim();
|
||||
if (!normalizedBotOpenId) {
|
||||
return undefined;
|
||||
}
|
||||
const botMentions = merged.filter(
|
||||
(mention) => mention.id.open_id?.trim() === normalizedBotOpenId,
|
||||
);
|
||||
return botMentions.length > 0 ? botMentions : undefined;
|
||||
}
|
||||
|
||||
function registerEventHandlers(
|
||||
eventDispatcher: Lark.EventDispatcher,
|
||||
context: RegisterEventHandlersContext,
|
||||
): void {
|
||||
const { cfg, accountId, runtime, chatHistories, fireAndForget } = context;
|
||||
const core = getFeishuRuntime();
|
||||
const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
});
|
||||
const log = runtime?.log ?? console.log;
|
||||
const error = runtime?.error ?? console.error;
|
||||
const enqueue = createChatQueue();
|
||||
const dispatchFeishuMessage = async (event: FeishuMessageEvent) => {
|
||||
const chatId = event.message.chat_id?.trim() || "unknown";
|
||||
const task = () =>
|
||||
handleFeishuMessage({
|
||||
cfg,
|
||||
event,
|
||||
botOpenId: botOpenIds.get(accountId),
|
||||
runtime,
|
||||
chatHistories,
|
||||
accountId,
|
||||
});
|
||||
await enqueue(chatId, task);
|
||||
};
|
||||
const resolveSenderDebounceId = (event: FeishuMessageEvent): string | undefined => {
|
||||
const senderId =
|
||||
event.sender.sender_id.open_id?.trim() || event.sender.sender_id.user_id?.trim();
|
||||
return senderId || undefined;
|
||||
};
|
||||
const resolveDebounceText = (event: FeishuMessageEvent): string => {
|
||||
const botOpenId = botOpenIds.get(accountId);
|
||||
const parsed = parseFeishuMessageEvent(event, botOpenId);
|
||||
return parsed.content.trim();
|
||||
};
|
||||
const recordSuppressedMessageIds = async (
|
||||
entries: FeishuMessageEvent[],
|
||||
dispatchMessageId?: string,
|
||||
) => {
|
||||
const keepMessageId = dispatchMessageId?.trim();
|
||||
const suppressedIds = new Set(
|
||||
entries
|
||||
.map((entry) => entry.message.message_id?.trim())
|
||||
.filter((id): id is string => Boolean(id) && (!keepMessageId || id !== keepMessageId)),
|
||||
);
|
||||
if (suppressedIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
for (const messageId of suppressedIds) {
|
||||
// Keep in-memory dedupe in sync with handleFeishuMessage's keying.
|
||||
tryRecordMessage(`${accountId}:${messageId}`);
|
||||
try {
|
||||
await tryRecordMessagePersistent(messageId, accountId, log);
|
||||
} catch (err) {
|
||||
error(
|
||||
`feishu[${accountId}]: failed to record merged dedupe id ${messageId}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
const isMessageAlreadyProcessed = async (entry: FeishuMessageEvent): Promise<boolean> => {
|
||||
const messageId = entry.message.message_id?.trim();
|
||||
if (!messageId) {
|
||||
return false;
|
||||
}
|
||||
const memoryKey = `${accountId}:${messageId}`;
|
||||
if (hasRecordedMessage(memoryKey)) {
|
||||
return true;
|
||||
}
|
||||
return hasRecordedMessagePersistent(messageId, accountId, log);
|
||||
};
|
||||
const inboundDebouncer = core.channel.debounce.createInboundDebouncer<FeishuMessageEvent>({
|
||||
debounceMs: inboundDebounceMs,
|
||||
buildKey: (event) => {
|
||||
const chatId = event.message.chat_id?.trim();
|
||||
const senderId = resolveSenderDebounceId(event);
|
||||
if (!chatId || !senderId) {
|
||||
return null;
|
||||
}
|
||||
const rootId = event.message.root_id?.trim();
|
||||
const threadKey = rootId ? `thread:${rootId}` : "chat";
|
||||
return `feishu:${accountId}:${chatId}:${threadKey}:${senderId}`;
|
||||
},
|
||||
shouldDebounce: (event) => {
|
||||
if (event.message.message_type !== "text") {
|
||||
return false;
|
||||
}
|
||||
const text = resolveDebounceText(event);
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
return !core.channel.text.hasControlCommand(text, cfg);
|
||||
},
|
||||
onFlush: async (entries) => {
|
||||
const last = entries.at(-1);
|
||||
if (!last) {
|
||||
return;
|
||||
}
|
||||
if (entries.length === 1) {
|
||||
await dispatchFeishuMessage(last);
|
||||
return;
|
||||
}
|
||||
const dedupedEntries = dedupeFeishuDebounceEntriesByMessageId(entries);
|
||||
const freshEntries: FeishuMessageEvent[] = [];
|
||||
for (const entry of dedupedEntries) {
|
||||
if (!(await isMessageAlreadyProcessed(entry))) {
|
||||
freshEntries.push(entry);
|
||||
}
|
||||
}
|
||||
const dispatchEntry = freshEntries.at(-1);
|
||||
if (!dispatchEntry) {
|
||||
return;
|
||||
}
|
||||
await recordSuppressedMessageIds(dedupedEntries, dispatchEntry.message.message_id);
|
||||
const combinedText = freshEntries
|
||||
.map((entry) => resolveDebounceText(entry))
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
const mergedMentions = resolveFeishuDebounceMentions({
|
||||
entries: freshEntries,
|
||||
botOpenId: botOpenIds.get(accountId),
|
||||
});
|
||||
if (!combinedText.trim()) {
|
||||
await dispatchFeishuMessage({
|
||||
...dispatchEntry,
|
||||
message: {
|
||||
...dispatchEntry.message,
|
||||
mentions: mergedMentions ?? dispatchEntry.message.mentions,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
await dispatchFeishuMessage({
|
||||
...dispatchEntry,
|
||||
message: {
|
||||
...dispatchEntry.message,
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: combinedText }),
|
||||
mentions: mergedMentions ?? dispatchEntry.message.mentions,
|
||||
},
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
error(`feishu[${accountId}]: inbound debounce flush failed: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
|
||||
eventDispatcher.register({
|
||||
"im.message.receive_v1": async (data) => {
|
||||
try {
|
||||
const processMessage = async () => {
|
||||
const event = data as unknown as FeishuMessageEvent;
|
||||
const promise = handleFeishuMessage({
|
||||
cfg,
|
||||
event,
|
||||
botOpenId: botOpenIds.get(accountId),
|
||||
runtime,
|
||||
chatHistories,
|
||||
accountId,
|
||||
await inboundDebouncer.enqueue(event);
|
||||
};
|
||||
if (fireAndForget) {
|
||||
void processMessage().catch((err) => {
|
||||
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
|
||||
});
|
||||
if (fireAndForget) {
|
||||
promise.catch((err) => {
|
||||
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
|
||||
});
|
||||
} else {
|
||||
await promise;
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await processMessage();
|
||||
} catch (err) {
|
||||
error(`feishu[${accountId}]: error handling message: ${String(err)}`);
|
||||
}
|
||||
@@ -268,6 +511,11 @@ export async function monitorSingleAccount(params: MonitorSingleAccountParams):
|
||||
throw new Error(`Feishu account "${accountId}" webhook mode requires verificationToken`);
|
||||
}
|
||||
|
||||
const warmupCount = await warmupDedupFromDisk(accountId, log);
|
||||
if (warmupCount > 0) {
|
||||
log(`feishu[${accountId}]: dedup warmup loaded ${warmupCount} entries from disk`);
|
||||
}
|
||||
|
||||
const eventDispatcher = createEventDispatcher(account);
|
||||
const chatHistories = new Map<string, HistoryEntry[]>();
|
||||
|
||||
|
||||
@@ -1,6 +1,40 @@
|
||||
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { hasControlCommand } from "../../../src/auto-reply/command-detection.js";
|
||||
import {
|
||||
createInboundDebouncer,
|
||||
resolveInboundDebounceMs,
|
||||
} from "../../../src/auto-reply/inbound-debounce.js";
|
||||
import { parseFeishuMessageEvent, type FeishuMessageEvent } from "./bot.js";
|
||||
import * as dedup from "./dedup.js";
|
||||
import { monitorSingleAccount } from "./monitor.account.js";
|
||||
import { resolveReactionSyntheticEvent, type FeishuReactionCreatedEvent } from "./monitor.js";
|
||||
import { setFeishuRuntime } from "./runtime.js";
|
||||
import type { ResolvedFeishuAccount } from "./types.js";
|
||||
|
||||
const handleFeishuMessageMock = vi.hoisted(() => vi.fn(async (_params: { event?: unknown }) => {}));
|
||||
const createEventDispatcherMock = vi.hoisted(() => vi.fn());
|
||||
const monitorWebSocketMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const monitorWebhookMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
|
||||
let handlers: Record<string, (data: unknown) => Promise<void>> = {};
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
createEventDispatcher: createEventDispatcherMock,
|
||||
}));
|
||||
|
||||
vi.mock("./bot.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./bot.js")>("./bot.js");
|
||||
return {
|
||||
...actual,
|
||||
handleFeishuMessage: handleFeishuMessageMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./monitor.transport.js", () => ({
|
||||
monitorWebSocket: monitorWebSocketMock,
|
||||
monitorWebhook: monitorWebhookMock,
|
||||
}));
|
||||
|
||||
const cfg = {} as ClawdbotConfig;
|
||||
|
||||
@@ -16,6 +50,100 @@ function makeReactionEvent(
|
||||
};
|
||||
}
|
||||
|
||||
type FeishuMention = NonNullable<FeishuMessageEvent["message"]["mentions"]>[number];
|
||||
|
||||
function buildDebounceConfig(): ClawdbotConfig {
|
||||
return {
|
||||
messages: {
|
||||
inbound: {
|
||||
debounceMs: 0,
|
||||
byChannel: {
|
||||
feishu: 20,
|
||||
},
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
feishu: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
}
|
||||
|
||||
function buildDebounceAccount(): ResolvedFeishuAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
appId: "cli_test",
|
||||
appSecret: "secret_test",
|
||||
domain: "feishu",
|
||||
config: {
|
||||
enabled: true,
|
||||
connectionMode: "websocket",
|
||||
},
|
||||
} as ResolvedFeishuAccount;
|
||||
}
|
||||
|
||||
function createTextEvent(params: {
|
||||
messageId: string;
|
||||
text: string;
|
||||
senderId?: string;
|
||||
mentions?: FeishuMention[];
|
||||
}): FeishuMessageEvent {
|
||||
const senderId = params.senderId ?? "ou_sender";
|
||||
return {
|
||||
sender: {
|
||||
sender_id: { open_id: senderId },
|
||||
sender_type: "user",
|
||||
},
|
||||
message: {
|
||||
message_id: params.messageId,
|
||||
chat_id: "oc_group_1",
|
||||
chat_type: "group",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: params.text }),
|
||||
mentions: params.mentions,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function setupDebounceMonitor(): Promise<(data: unknown) => Promise<void>> {
|
||||
const register = vi.fn((registered: Record<string, (data: unknown) => Promise<void>>) => {
|
||||
handlers = registered;
|
||||
});
|
||||
createEventDispatcherMock.mockReturnValue({ register });
|
||||
|
||||
await monitorSingleAccount({
|
||||
cfg: buildDebounceConfig(),
|
||||
account: buildDebounceAccount(),
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
} as RuntimeEnv,
|
||||
botOpenIdSource: { kind: "prefetched", botOpenId: "ou_bot" },
|
||||
});
|
||||
|
||||
const onMessage = handlers["im.message.receive_v1"];
|
||||
if (!onMessage) {
|
||||
throw new Error("missing im.message.receive_v1 handler");
|
||||
}
|
||||
return onMessage;
|
||||
}
|
||||
|
||||
function getFirstDispatchedEvent(): FeishuMessageEvent {
|
||||
const firstCall = handleFeishuMessageMock.mock.calls[0];
|
||||
if (!firstCall) {
|
||||
throw new Error("missing dispatch call");
|
||||
}
|
||||
const firstParams = firstCall[0] as { event?: FeishuMessageEvent } | undefined;
|
||||
if (!firstParams?.event) {
|
||||
throw new Error("missing dispatched event payload");
|
||||
}
|
||||
return firstParams.event;
|
||||
}
|
||||
|
||||
describe("resolveReactionSyntheticEvent", () => {
|
||||
it("filters app self-reactions", async () => {
|
||||
const event = makeReactionEvent({ operator_type: "app" });
|
||||
@@ -233,3 +361,215 @@ describe("resolveReactionSyntheticEvent", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Feishu inbound debounce regressions", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
handlers = {};
|
||||
handleFeishuMessageMock.mockClear();
|
||||
setFeishuRuntime({
|
||||
channel: {
|
||||
debounce: {
|
||||
createInboundDebouncer,
|
||||
resolveInboundDebounceMs,
|
||||
},
|
||||
text: {
|
||||
hasControlCommand,
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("keeps bot mention when per-message mention keys collide across non-forward messages", async () => {
|
||||
vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
|
||||
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
|
||||
vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
|
||||
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
|
||||
const onMessage = await setupDebounceMonitor();
|
||||
|
||||
await onMessage(
|
||||
createTextEvent({
|
||||
messageId: "om_1",
|
||||
text: "first",
|
||||
mentions: [
|
||||
{
|
||||
key: "@_user_1",
|
||||
id: { open_id: "ou_user_a" },
|
||||
name: "user-a",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await onMessage(
|
||||
createTextEvent({
|
||||
messageId: "om_2",
|
||||
text: "@bot second",
|
||||
mentions: [
|
||||
{
|
||||
key: "@_user_1",
|
||||
id: { open_id: "ou_bot" },
|
||||
name: "bot",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
||||
const dispatched = getFirstDispatchedEvent();
|
||||
const mergedMentions = dispatched.message.mentions ?? [];
|
||||
expect(mergedMentions.some((mention) => mention.id.open_id === "ou_bot")).toBe(true);
|
||||
expect(mergedMentions.some((mention) => mention.id.open_id === "ou_user_a")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not synthesize mention-forward intent across separate messages", async () => {
|
||||
vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
|
||||
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
|
||||
vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
|
||||
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
|
||||
const onMessage = await setupDebounceMonitor();
|
||||
|
||||
await onMessage(
|
||||
createTextEvent({
|
||||
messageId: "om_user_mention",
|
||||
text: "@alice first",
|
||||
mentions: [
|
||||
{
|
||||
key: "@_user_1",
|
||||
id: { open_id: "ou_alice" },
|
||||
name: "alice",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await onMessage(
|
||||
createTextEvent({
|
||||
messageId: "om_bot_mention",
|
||||
text: "@bot second",
|
||||
mentions: [
|
||||
{
|
||||
key: "@_user_1",
|
||||
id: { open_id: "ou_bot" },
|
||||
name: "bot",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
||||
const dispatched = getFirstDispatchedEvent();
|
||||
const parsed = parseFeishuMessageEvent(dispatched, "ou_bot");
|
||||
expect(parsed.mentionedBot).toBe(true);
|
||||
expect(parsed.mentionTargets).toBeUndefined();
|
||||
const mergedMentions = dispatched.message.mentions ?? [];
|
||||
expect(mergedMentions.every((mention) => mention.id.open_id === "ou_bot")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves bot mention signal when the latest merged message has no mentions", async () => {
|
||||
vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
|
||||
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
|
||||
vi.spyOn(dedup, "hasRecordedMessage").mockReturnValue(false);
|
||||
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockResolvedValue(false);
|
||||
const onMessage = await setupDebounceMonitor();
|
||||
|
||||
await onMessage(
|
||||
createTextEvent({
|
||||
messageId: "om_bot_first",
|
||||
text: "@bot first",
|
||||
mentions: [
|
||||
{
|
||||
key: "@_user_1",
|
||||
id: { open_id: "ou_bot" },
|
||||
name: "bot",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await onMessage(
|
||||
createTextEvent({
|
||||
messageId: "om_plain_second",
|
||||
text: "plain follow-up",
|
||||
}),
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
||||
const dispatched = getFirstDispatchedEvent();
|
||||
const parsed = parseFeishuMessageEvent(dispatched, "ou_bot");
|
||||
expect(parsed.mentionedBot).toBe(true);
|
||||
});
|
||||
|
||||
it("excludes previously processed retries from combined debounce text", async () => {
|
||||
vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
|
||||
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
|
||||
vi.spyOn(dedup, "hasRecordedMessage").mockImplementation((key) => key.endsWith(":om_old"));
|
||||
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation(
|
||||
async (messageId) => messageId === "om_old",
|
||||
);
|
||||
const onMessage = await setupDebounceMonitor();
|
||||
|
||||
await onMessage(createTextEvent({ messageId: "om_old", text: "stale" }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await onMessage(createTextEvent({ messageId: "om_new_1", text: "first" }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await onMessage(createTextEvent({ messageId: "om_old", text: "stale" }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await onMessage(createTextEvent({ messageId: "om_new_2", text: "second" }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
||||
const dispatched = getFirstDispatchedEvent();
|
||||
expect(dispatched.message.message_id).toBe("om_new_2");
|
||||
const combined = JSON.parse(dispatched.message.content) as { text?: string };
|
||||
expect(combined.text).toBe("first\nsecond");
|
||||
});
|
||||
|
||||
it("uses latest fresh message id when debounce batch ends with stale retry", async () => {
|
||||
const recordSpy = vi.spyOn(dedup, "tryRecordMessage").mockReturnValue(true);
|
||||
vi.spyOn(dedup, "tryRecordMessagePersistent").mockResolvedValue(true);
|
||||
vi.spyOn(dedup, "hasRecordedMessage").mockImplementation((key) => key.endsWith(":om_old"));
|
||||
vi.spyOn(dedup, "hasRecordedMessagePersistent").mockImplementation(
|
||||
async (messageId) => messageId === "om_old",
|
||||
);
|
||||
const onMessage = await setupDebounceMonitor();
|
||||
|
||||
await onMessage(createTextEvent({ messageId: "om_new", text: "fresh" }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await onMessage(createTextEvent({ messageId: "om_old", text: "stale" }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
expect(handleFeishuMessageMock).toHaveBeenCalledTimes(1);
|
||||
const dispatched = getFirstDispatchedEvent();
|
||||
expect(dispatched.message.message_id).toBe("om_new");
|
||||
const combined = JSON.parse(dispatched.message.content) as { text?: string };
|
||||
expect(combined.text).toBe("fresh");
|
||||
expect(recordSpy).toHaveBeenCalledWith("default:om_old");
|
||||
expect(recordSpy).not.toHaveBeenCalledWith("default:om_new");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,34 @@
|
||||
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js";
|
||||
import { probeFeishuMock } from "./monitor.test-mocks.js";
|
||||
|
||||
const probeFeishuMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./probe.js", () => ({
|
||||
probeFeishu: probeFeishuMock,
|
||||
}));
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })),
|
||||
createEventDispatcher: vi.fn(() => ({ register: vi.fn() })),
|
||||
}));
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
getFeishuRuntime: () => ({
|
||||
channel: {
|
||||
debounce: {
|
||||
resolveInboundDebounceMs: () => 0,
|
||||
createInboundDebouncer: () => ({
|
||||
enqueue: async () => {},
|
||||
flushKey: async () => {},
|
||||
}),
|
||||
},
|
||||
text: {
|
||||
hasControlCommand: () => false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
function buildMultiAccountWebsocketConfig(accountIds: string[]): ClawdbotConfig {
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
export const probeFeishuMock: ReturnType<typeof vi.fn> = vi.hoisted(() => vi.fn());
|
||||
export const probeFeishuMock: ReturnType<typeof vi.fn> = vi.fn();
|
||||
|
||||
vi.mock("./probe.js", () => ({
|
||||
probeFeishu: probeFeishuMock,
|
||||
|
||||
@@ -2,7 +2,34 @@ import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { probeFeishuMock } from "./monitor.test-mocks.js";
|
||||
|
||||
const probeFeishuMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./probe.js", () => ({
|
||||
probeFeishu: probeFeishuMock,
|
||||
}));
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
createFeishuWSClient: vi.fn(() => ({ start: vi.fn() })),
|
||||
createEventDispatcher: vi.fn(() => ({ register: vi.fn() })),
|
||||
}));
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
getFeishuRuntime: () => ({
|
||||
channel: {
|
||||
debounce: {
|
||||
resolveInboundDebounceMs: () => 0,
|
||||
createInboundDebouncer: () => ({
|
||||
enqueue: async () => {},
|
||||
flushKey: async () => {},
|
||||
}),
|
||||
},
|
||||
text: {
|
||||
hasControlCommand: () => false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@larksuiteoapi/node-sdk", () => ({
|
||||
adaptDefault: vi.fn(
|
||||
|
||||
@@ -59,7 +59,7 @@ describe("probeFeishu", () => {
|
||||
expect(requestFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses explicit timeout for bot info request", async () => {
|
||||
it("passes the probe timeout to the Feishu request", async () => {
|
||||
const requestFn = setupClient({
|
||||
code: 0,
|
||||
bot: { bot_name: "TestBot", open_id: "ou_abc123" },
|
||||
@@ -105,7 +105,6 @@ describe("probeFeishu", () => {
|
||||
expect(result).toMatchObject({ ok: false, error: "probe aborted" });
|
||||
expect(createFeishuClientMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns cached result on subsequent calls within TTL", async () => {
|
||||
const requestFn = setupClient({
|
||||
code: 0,
|
||||
@@ -133,7 +132,7 @@ describe("probeFeishu", () => {
|
||||
await probeFeishu(creds);
|
||||
expect(requestFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Advance time past the 10-minute TTL
|
||||
// Advance time past the success TTL
|
||||
vi.advanceTimersByTime(10 * 60 * 1000 + 1);
|
||||
|
||||
await probeFeishu(creds);
|
||||
@@ -143,29 +142,48 @@ describe("probeFeishu", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not cache failed probe results (API error)", async () => {
|
||||
const requestFn = makeRequestFn({ code: 99, msg: "token expired" });
|
||||
createFeishuClientMock.mockReturnValue({ request: requestFn });
|
||||
it("caches failed probe results (API error) for the error TTL", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const requestFn = makeRequestFn({ code: 99, msg: "token expired" });
|
||||
createFeishuClientMock.mockReturnValue({ request: requestFn });
|
||||
|
||||
const creds = { appId: "cli_123", appSecret: "secret" };
|
||||
const first = await probeFeishu(creds);
|
||||
expect(first).toMatchObject({ ok: false, error: "API error: token expired" });
|
||||
const creds = { appId: "cli_123", appSecret: "secret" };
|
||||
const first = await probeFeishu(creds);
|
||||
const second = await probeFeishu(creds);
|
||||
expect(first).toMatchObject({ ok: false, error: "API error: token expired" });
|
||||
expect(second).toMatchObject({ ok: false, error: "API error: token expired" });
|
||||
expect(requestFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second call should make a fresh request since failures are not cached
|
||||
await probeFeishu(creds);
|
||||
expect(requestFn).toHaveBeenCalledTimes(2);
|
||||
vi.advanceTimersByTime(60 * 1000 + 1);
|
||||
|
||||
await probeFeishu(creds);
|
||||
expect(requestFn).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not cache results when request throws", async () => {
|
||||
const requestFn = vi.fn().mockRejectedValue(new Error("network error"));
|
||||
createFeishuClientMock.mockReturnValue({ request: requestFn });
|
||||
it("caches thrown request errors for the error TTL", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const requestFn = vi.fn().mockRejectedValue(new Error("network error"));
|
||||
createFeishuClientMock.mockReturnValue({ request: requestFn });
|
||||
|
||||
const creds = { appId: "cli_123", appSecret: "secret" };
|
||||
const first = await probeFeishu(creds);
|
||||
expect(first).toMatchObject({ ok: false, error: "network error" });
|
||||
const creds = { appId: "cli_123", appSecret: "secret" };
|
||||
const first = await probeFeishu(creds);
|
||||
const second = await probeFeishu(creds);
|
||||
expect(first).toMatchObject({ ok: false, error: "network error" });
|
||||
expect(second).toMatchObject({ ok: false, error: "network error" });
|
||||
expect(requestFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
await probeFeishu(creds);
|
||||
expect(requestFn).toHaveBeenCalledTimes(2);
|
||||
vi.advanceTimersByTime(60 * 1000 + 1);
|
||||
|
||||
await probeFeishu(creds);
|
||||
expect(requestFn).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("caches per account independently", async () => {
|
||||
|
||||
@@ -2,15 +2,16 @@ import { raceWithTimeoutAndAbort } from "./async.js";
|
||||
import { createFeishuClient, type FeishuClientCredentials } from "./client.js";
|
||||
import type { FeishuProbeResult } from "./types.js";
|
||||
|
||||
/** Cache successful probe results to reduce API calls (bot info is static).
|
||||
/** Cache probe results to reduce repeated health-check calls.
|
||||
* Gateway health checks call probeFeishu() every minute; without caching this
|
||||
* burns ~43,200 calls/month, easily exceeding Feishu's free-tier quota.
|
||||
* A 10-min TTL cuts that to ~4,320 calls/month. (#26684) */
|
||||
* Successful bot info is effectively static, while failures are cached briefly
|
||||
* to avoid hammering the API during transient outages. */
|
||||
const probeCache = new Map<string, { result: FeishuProbeResult; expiresAt: number }>();
|
||||
const PROBE_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
const PROBE_SUCCESS_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
const PROBE_ERROR_TTL_MS = 60 * 1000; // 1 minute
|
||||
const MAX_PROBE_CACHE_SIZE = 64;
|
||||
export const FEISHU_PROBE_REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
export type ProbeFeishuOptions = {
|
||||
timeoutMs?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
@@ -23,6 +24,21 @@ type FeishuBotInfoResponse = {
|
||||
data?: { bot?: { bot_name?: string; open_id?: string } };
|
||||
};
|
||||
|
||||
function setCachedProbeResult(
|
||||
cacheKey: string,
|
||||
result: FeishuProbeResult,
|
||||
ttlMs: number,
|
||||
): FeishuProbeResult {
|
||||
probeCache.set(cacheKey, { result, expiresAt: Date.now() + ttlMs });
|
||||
if (probeCache.size > MAX_PROBE_CACHE_SIZE) {
|
||||
const oldest = probeCache.keys().next().value;
|
||||
if (oldest !== undefined) {
|
||||
probeCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function probeFeishu(
|
||||
creds?: FeishuClientCredentials,
|
||||
options: ProbeFeishuOptions = {},
|
||||
@@ -78,11 +94,15 @@ export async function probeFeishu(
|
||||
};
|
||||
}
|
||||
if (responseResult.status === "timeout") {
|
||||
return {
|
||||
ok: false,
|
||||
appId: creds.appId,
|
||||
error: `probe timed out after ${timeoutMs}ms`,
|
||||
};
|
||||
return setCachedProbeResult(
|
||||
cacheKey,
|
||||
{
|
||||
ok: false,
|
||||
appId: creds.appId,
|
||||
error: `probe timed out after ${timeoutMs}ms`,
|
||||
},
|
||||
PROBE_ERROR_TTL_MS,
|
||||
);
|
||||
}
|
||||
|
||||
const response = responseResult.value;
|
||||
@@ -95,38 +115,38 @@ export async function probeFeishu(
|
||||
}
|
||||
|
||||
if (response.code !== 0) {
|
||||
return {
|
||||
ok: false,
|
||||
appId: creds.appId,
|
||||
error: `API error: ${response.msg || `code ${response.code}`}`,
|
||||
};
|
||||
return setCachedProbeResult(
|
||||
cacheKey,
|
||||
{
|
||||
ok: false,
|
||||
appId: creds.appId,
|
||||
error: `API error: ${response.msg || `code ${response.code}`}`,
|
||||
},
|
||||
PROBE_ERROR_TTL_MS,
|
||||
);
|
||||
}
|
||||
|
||||
const bot = response.bot || response.data?.bot;
|
||||
const result: FeishuProbeResult = {
|
||||
ok: true,
|
||||
appId: creds.appId,
|
||||
botName: bot?.bot_name,
|
||||
botOpenId: bot?.open_id,
|
||||
};
|
||||
|
||||
// Cache successful results only
|
||||
probeCache.set(cacheKey, { result, expiresAt: Date.now() + PROBE_CACHE_TTL_MS });
|
||||
// Evict oldest entry if cache exceeds max size
|
||||
if (probeCache.size > MAX_PROBE_CACHE_SIZE) {
|
||||
const oldest = probeCache.keys().next().value;
|
||||
if (oldest !== undefined) {
|
||||
probeCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return setCachedProbeResult(
|
||||
cacheKey,
|
||||
{
|
||||
ok: true,
|
||||
appId: creds.appId,
|
||||
botName: bot?.bot_name,
|
||||
botOpenId: bot?.open_id,
|
||||
},
|
||||
PROBE_SUCCESS_TTL_MS,
|
||||
);
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
appId: creds.appId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
return setCachedProbeResult(
|
||||
cacheKey,
|
||||
{
|
||||
ok: false,
|
||||
appId: creds.appId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
PROBE_ERROR_TTL_MS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -369,6 +369,30 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("disables streaming for thread replies and keeps reply metadata", async () => {
|
||||
createFeishuReplyDispatcher({
|
||||
cfg: {} as never,
|
||||
agentId: "agent",
|
||||
runtime: { log: vi.fn(), error: vi.fn() } as never,
|
||||
chatId: "oc_chat",
|
||||
replyToMessageId: "om_msg",
|
||||
replyInThread: false,
|
||||
threadReply: true,
|
||||
rootId: "om_root_topic",
|
||||
});
|
||||
|
||||
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
||||
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
|
||||
|
||||
expect(streamingInstances).toHaveLength(0);
|
||||
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replyToMessageId: "om_msg",
|
||||
replyInThread: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes replyInThread to media attachments", async () => {
|
||||
createFeishuReplyDispatcher({
|
||||
cfg: {} as never,
|
||||
|
||||
@@ -45,6 +45,8 @@ export type CreateFeishuReplyDispatcherParams = {
|
||||
/** When true, preserve typing indicator on reply target but send messages without reply metadata */
|
||||
skipReplyToInMessages?: boolean;
|
||||
replyInThread?: boolean;
|
||||
/** True when inbound message is already inside a thread/topic context */
|
||||
threadReply?: boolean;
|
||||
rootId?: string;
|
||||
mentionTargets?: MentionTarget[];
|
||||
accountId?: string;
|
||||
@@ -62,11 +64,14 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
replyToMessageId,
|
||||
skipReplyToInMessages,
|
||||
replyInThread,
|
||||
threadReply,
|
||||
rootId,
|
||||
mentionTargets,
|
||||
accountId,
|
||||
} = params;
|
||||
const sendReplyToMessageId = skipReplyToInMessages ? undefined : replyToMessageId;
|
||||
const threadReplyMode = threadReply === true;
|
||||
const effectiveReplyInThread = threadReplyMode ? true : replyInThread;
|
||||
const account = resolveFeishuAccount({ cfg, accountId });
|
||||
const prefixContext = createReplyPrefixContext({ cfg, agentId });
|
||||
|
||||
@@ -89,6 +94,12 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Feishu reactions persist until explicitly removed, so skip keepalive
|
||||
// re-adds when a reaction already exists. Re-adding the same emoji
|
||||
// triggers a new push notification for every call (#28660).
|
||||
if (typingState?.reactionId) {
|
||||
return;
|
||||
}
|
||||
typingState = await addTypingIndicator({
|
||||
cfg,
|
||||
messageId: replyToMessageId,
|
||||
@@ -125,7 +136,9 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
const chunkMode = core.channel.text.resolveChunkMode(cfg, "feishu");
|
||||
const tableMode = core.channel.text.resolveMarkdownTableMode({ cfg, channel: "feishu" });
|
||||
const renderMode = account.config?.renderMode ?? "auto";
|
||||
const streamingEnabled = account.config?.streaming !== false && renderMode !== "raw";
|
||||
// Card streaming may miss thread affinity in topic contexts; use direct replies there.
|
||||
const streamingEnabled =
|
||||
!threadReplyMode && account.config?.streaming !== false && renderMode !== "raw";
|
||||
|
||||
let streaming: FeishuStreamingSession | null = null;
|
||||
let streamText = "";
|
||||
@@ -152,7 +165,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
try {
|
||||
await streaming.start(chatId, resolveReceiveIdType(chatId), {
|
||||
replyToMessageId,
|
||||
replyInThread,
|
||||
replyInThread: effectiveReplyInThread,
|
||||
rootId,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -235,7 +248,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
to: chatId,
|
||||
mediaUrl,
|
||||
replyToMessageId: sendReplyToMessageId,
|
||||
replyInThread,
|
||||
replyInThread: effectiveReplyInThread,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
@@ -255,7 +268,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
to: chatId,
|
||||
text: chunk,
|
||||
replyToMessageId: sendReplyToMessageId,
|
||||
replyInThread,
|
||||
replyInThread: effectiveReplyInThread,
|
||||
mentions: first ? mentionTargets : undefined,
|
||||
accountId,
|
||||
});
|
||||
@@ -273,7 +286,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
to: chatId,
|
||||
text: chunk,
|
||||
replyToMessageId: sendReplyToMessageId,
|
||||
replyInThread,
|
||||
replyInThread: effectiveReplyInThread,
|
||||
mentions: first ? mentionTargets : undefined,
|
||||
accountId,
|
||||
});
|
||||
@@ -289,7 +302,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
|
||||
to: chatId,
|
||||
mediaUrl,
|
||||
replyToMessageId: sendReplyToMessageId,
|
||||
replyInThread,
|
||||
replyInThread: effectiveReplyInThread,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ describe("resolveReceiveIdType", () => {
|
||||
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");
|
||||
});
|
||||
@@ -33,8 +37,11 @@ describe("normalizeFeishuTarget", () => {
|
||||
expect(normalizeFeishuTarget("feishu:chat:oc_123")).toBe("oc_123");
|
||||
});
|
||||
|
||||
it("strips provider and group prefixes", () => {
|
||||
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", () => {
|
||||
@@ -55,7 +62,9 @@ describe("looksLikeFeishuId", () => {
|
||||
expect(looksLikeFeishuId("lark:chat:oc_123")).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts provider-prefixed group targets", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,9 @@ export function normalizeFeishuTarget(raw: string): string | null {
|
||||
if (lowered.startsWith("group:")) {
|
||||
return withoutProvider.slice("group:".length).trim() || null;
|
||||
}
|
||||
if (lowered.startsWith("channel:")) {
|
||||
return withoutProvider.slice("channel:".length).trim() || null;
|
||||
}
|
||||
if (lowered.startsWith("user:")) {
|
||||
return withoutProvider.slice("user:".length).trim() || null;
|
||||
}
|
||||
@@ -87,7 +90,7 @@ export function looksLikeFeishuId(raw: string): boolean {
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
if (/^(chat|group|user|dm|open_id):/i.test(trimmed)) {
|
||||
if (/^(chat|group|channel|user|dm|open_id):/i.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
if (trimmed.startsWith(CHAT_ID_PREFIX)) {
|
||||
|
||||
@@ -14,8 +14,15 @@ export type FeishuAccountConfig = z.infer<typeof FeishuAccountConfigSchema>;
|
||||
export type FeishuDomain = "feishu" | "lark" | (string & {});
|
||||
export type FeishuConnectionMode = "websocket" | "webhook";
|
||||
|
||||
export type FeishuDefaultAccountSelectionSource =
|
||||
| "explicit-default"
|
||||
| "mapped-default"
|
||||
| "fallback";
|
||||
export type FeishuAccountSelectionSource = "explicit" | FeishuDefaultAccountSelectionSource;
|
||||
|
||||
export type ResolvedFeishuAccount = {
|
||||
accountId: string;
|
||||
selectionSource: FeishuAccountSelectionSource;
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
name?: string;
|
||||
@@ -36,10 +43,11 @@ export type FeishuMessageContext = {
|
||||
senderId: string;
|
||||
senderOpenId: string;
|
||||
senderName?: string;
|
||||
chatType: "p2p" | "group";
|
||||
chatType: "p2p" | "group" | "private";
|
||||
mentionedBot: boolean;
|
||||
rootId?: string;
|
||||
parentId?: string;
|
||||
threadId?: string;
|
||||
content: string;
|
||||
contentType: string;
|
||||
/** Mention forward targets (excluding the bot itself) */
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"google-auth-library": "^10.6.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.1.26"
|
||||
"openclaw": ">=2026.3.1"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "OpenClaw core memory search plugin",
|
||||
"type": "module",
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.1.26"
|
||||
"openclaw": ">=2026.3.1"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
|
||||
@@ -85,13 +85,19 @@ function createOAuthHandler(region: MiniMaxRegion) {
|
||||
api: "anthropic-messages",
|
||||
models: [
|
||||
buildModelDefinition({
|
||||
id: "MiniMax-M2.1",
|
||||
name: "MiniMax M2.1",
|
||||
id: "MiniMax-M2.5",
|
||||
name: "MiniMax M2.5",
|
||||
input: ["text"],
|
||||
}),
|
||||
buildModelDefinition({
|
||||
id: "MiniMax-M2.5",
|
||||
name: "MiniMax M2.5",
|
||||
id: "MiniMax-M2.5-highspeed",
|
||||
name: "MiniMax M2.5 Highspeed",
|
||||
input: ["text"],
|
||||
reasoning: true,
|
||||
}),
|
||||
buildModelDefinition({
|
||||
id: "MiniMax-M2.5-Lightning",
|
||||
name: "MiniMax M2.5 Lightning",
|
||||
input: ["text"],
|
||||
reasoning: true,
|
||||
}),
|
||||
@@ -102,8 +108,13 @@ function createOAuthHandler(region: MiniMaxRegion) {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
[modelRef("MiniMax-M2.1")]: { alias: "minimax-m2.1" },
|
||||
[modelRef("MiniMax-M2.5")]: { alias: "minimax-m2.5" },
|
||||
[modelRef("MiniMax-M2.5-highspeed")]: {
|
||||
alias: "minimax-m2.5-highspeed",
|
||||
},
|
||||
[modelRef("MiniMax-M2.5-Lightning")]: {
|
||||
alias: "minimax-m2.5-lightning",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,8 +1,128 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import { tlonPlugin } from "./src/channel.js";
|
||||
import { setTlonRuntime } from "./src/runtime.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Whitelist of allowed tlon subcommands
|
||||
const ALLOWED_TLON_COMMANDS = new Set([
|
||||
"activity",
|
||||
"channels",
|
||||
"contacts",
|
||||
"groups",
|
||||
"messages",
|
||||
"dms",
|
||||
"posts",
|
||||
"notebook",
|
||||
"settings",
|
||||
"help",
|
||||
"version",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Find the tlon binary from the skill package
|
||||
*/
|
||||
function findTlonBinary(): string {
|
||||
// Check in node_modules/.bin
|
||||
const skillBin = join(__dirname, "node_modules", ".bin", "tlon");
|
||||
console.log(`[tlon] Checking for binary at: ${skillBin}, exists: ${existsSync(skillBin)}`);
|
||||
if (existsSync(skillBin)) return skillBin;
|
||||
|
||||
// Check for platform-specific binary directly
|
||||
const platform = process.platform;
|
||||
const arch = process.arch;
|
||||
const platformPkg = `@tloncorp/tlon-skill-${platform}-${arch}`;
|
||||
const platformBin = join(__dirname, "node_modules", platformPkg, "tlon");
|
||||
console.log(
|
||||
`[tlon] Checking for platform binary at: ${platformBin}, exists: ${existsSync(platformBin)}`,
|
||||
);
|
||||
if (existsSync(platformBin)) return platformBin;
|
||||
|
||||
// Fallback to PATH
|
||||
console.log(`[tlon] Falling back to PATH lookup for 'tlon'`);
|
||||
return "tlon";
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell-like argument splitter that respects quotes
|
||||
*/
|
||||
function shellSplit(str: string): string[] {
|
||||
const args: string[] = [];
|
||||
let cur = "";
|
||||
let inDouble = false;
|
||||
let inSingle = false;
|
||||
let escape = false;
|
||||
|
||||
for (const ch of str) {
|
||||
if (escape) {
|
||||
cur += ch;
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\" && !inSingle) {
|
||||
escape = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' && !inSingle) {
|
||||
inDouble = !inDouble;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" && !inDouble) {
|
||||
inSingle = !inSingle;
|
||||
continue;
|
||||
}
|
||||
if (/\s/.test(ch) && !inDouble && !inSingle) {
|
||||
if (cur) {
|
||||
args.push(cur);
|
||||
cur = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
cur += ch;
|
||||
}
|
||||
if (cur) args.push(cur);
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the tlon command and return the result
|
||||
*/
|
||||
function runTlonCommand(binary: string, args: string[]): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(binary, args, {
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
child.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
reject(new Error(`Failed to run tlon: ${err.message}`));
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(stderr || `tlon exited with code ${code}`));
|
||||
} else {
|
||||
resolve(stdout);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const plugin = {
|
||||
id: "tlon",
|
||||
name: "Tlon",
|
||||
@@ -11,6 +131,59 @@ const plugin = {
|
||||
register(api: OpenClawPluginApi) {
|
||||
setTlonRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: tlonPlugin });
|
||||
|
||||
// Register the tlon tool
|
||||
const tlonBinary = findTlonBinary();
|
||||
api.logger.info(`[tlon] Registering tlon tool, binary: ${tlonBinary}`);
|
||||
api.registerTool({
|
||||
name: "tlon",
|
||||
label: "Tlon CLI",
|
||||
description:
|
||||
"Tlon/Urbit API operations: activity, channels, contacts, groups, messages, dms, posts, notebook, settings. " +
|
||||
"Examples: 'activity mentions --limit 10', 'channels groups', 'contacts self', 'groups list'",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
command: {
|
||||
type: "string",
|
||||
description:
|
||||
"The tlon command and arguments. " +
|
||||
"Examples: 'activity mentions --limit 10', 'contacts get ~sampel-palnet', 'groups list'",
|
||||
},
|
||||
},
|
||||
required: ["command"],
|
||||
},
|
||||
async execute(_id: string, params: { command: string }) {
|
||||
try {
|
||||
const args = shellSplit(params.command);
|
||||
|
||||
// Validate first argument is a whitelisted tlon subcommand
|
||||
const subcommand = args[0];
|
||||
if (!ALLOWED_TLON_COMMANDS.has(subcommand)) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Error: Unknown tlon subcommand '${subcommand}'. Allowed: ${[...ALLOWED_TLON_COMMANDS].join(", ")}`,
|
||||
},
|
||||
],
|
||||
details: { error: true },
|
||||
};
|
||||
}
|
||||
|
||||
const output = await runTlonCommand(tlonBinary, args);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: output }],
|
||||
details: undefined,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Error: ${error.message}` }],
|
||||
details: { error: true },
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"id": "tlon",
|
||||
"channels": ["tlon"],
|
||||
"skills": ["node_modules/@tloncorp/tlon-skill"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
"description": "OpenClaw Tlon/Urbit channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@urbit/aura": "^3.0.0"
|
||||
"@tloncorp/api": "github:tloncorp/api-beta#7eede1c1a756977b09f96aa14a92e2b06318ae87",
|
||||
"@tloncorp/tlon-skill": "0.1.9",
|
||||
"@urbit/aura": "^3.0.0",
|
||||
"@urbit/http-api": "^3.0.0"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
|
||||
@@ -6,6 +6,7 @@ export type TlonAccountFieldsInput = {
|
||||
groupChannels?: string[];
|
||||
dmAllowlist?: string[];
|
||||
autoDiscoverChannels?: boolean;
|
||||
ownerShip?: string;
|
||||
};
|
||||
|
||||
export function buildTlonAccountFields(input: TlonAccountFieldsInput) {
|
||||
@@ -21,5 +22,6 @@ export function buildTlonAccountFields(input: TlonAccountFieldsInput) {
|
||||
...(typeof input.autoDiscoverChannels === "boolean"
|
||||
? { autoDiscoverChannels: input.autoDiscoverChannels }
|
||||
: {}),
|
||||
...(input.ownerShip ? { ownerShip: input.ownerShip } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import crypto from "node:crypto";
|
||||
import { configureClient } from "@tloncorp/api";
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelOutboundAdapter,
|
||||
ChannelPlugin,
|
||||
ChannelSetupInput,
|
||||
@@ -17,9 +18,74 @@ import { tlonOnboardingAdapter } from "./onboarding.js";
|
||||
import { formatTargetHint, normalizeShip, parseTlonTarget } from "./targets.js";
|
||||
import { resolveTlonAccount, listTlonAccountIds } from "./types.js";
|
||||
import { authenticate } from "./urbit/auth.js";
|
||||
import { UrbitChannelClient } from "./urbit/channel-client.js";
|
||||
import { ssrfPolicyFromAllowPrivateNetwork } from "./urbit/context.js";
|
||||
import { buildMediaText, sendDm, sendGroupMessage } from "./urbit/send.js";
|
||||
import { urbitFetch } from "./urbit/fetch.js";
|
||||
import {
|
||||
buildMediaStory,
|
||||
sendDm,
|
||||
sendGroupMessage,
|
||||
sendDmWithStory,
|
||||
sendGroupMessageWithStory,
|
||||
} from "./urbit/send.js";
|
||||
import { uploadImageFromUrl } from "./urbit/upload.js";
|
||||
|
||||
// Simple HTTP-only poke that doesn't open an EventSource (avoids conflict with monitor's SSE)
|
||||
async function createHttpPokeApi(params: {
|
||||
url: string;
|
||||
code: string;
|
||||
ship: string;
|
||||
allowPrivateNetwork?: boolean;
|
||||
}) {
|
||||
const ssrfPolicy = ssrfPolicyFromAllowPrivateNetwork(params.allowPrivateNetwork);
|
||||
const cookie = await authenticate(params.url, params.code, { ssrfPolicy });
|
||||
const channelId = `${Math.floor(Date.now() / 1000)}-${crypto.randomUUID()}`;
|
||||
const channelPath = `/~/channel/${channelId}`;
|
||||
const shipName = params.ship.replace(/^~/, "");
|
||||
|
||||
return {
|
||||
poke: async (pokeParams: { app: string; mark: string; json: unknown }) => {
|
||||
const pokeId = Date.now();
|
||||
const pokeData = {
|
||||
id: pokeId,
|
||||
action: "poke",
|
||||
ship: shipName,
|
||||
app: pokeParams.app,
|
||||
mark: pokeParams.mark,
|
||||
json: pokeParams.json,
|
||||
};
|
||||
|
||||
// Use urbitFetch for consistent SSRF protection (DNS pinning + redirect handling)
|
||||
const { response, release } = await urbitFetch({
|
||||
baseUrl: params.url,
|
||||
path: channelPath,
|
||||
init: {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: cookie.split(";")[0],
|
||||
},
|
||||
body: JSON.stringify([pokeData]),
|
||||
},
|
||||
ssrfPolicy,
|
||||
auditContext: "tlon-poke",
|
||||
});
|
||||
|
||||
try {
|
||||
if (!response.ok && response.status !== 204) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Poke failed: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
return pokeId;
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
},
|
||||
delete: async () => {
|
||||
// No-op for HTTP-only client
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const TLON_CHANNEL_ID = "tlon" as const;
|
||||
|
||||
@@ -31,6 +97,7 @@ type TlonSetupInput = ChannelSetupInput & {
|
||||
groupChannels?: string[];
|
||||
dmAllowlist?: string[];
|
||||
autoDiscoverChannels?: boolean;
|
||||
ownerShip?: string;
|
||||
};
|
||||
|
||||
function applyTlonSetupConfig(params: {
|
||||
@@ -97,7 +164,7 @@ const tlonOutbound: ChannelOutboundAdapter = {
|
||||
error: new Error(`Invalid Tlon target. Use ${formatTargetHint()}`),
|
||||
};
|
||||
}
|
||||
if (parsed.kind === "direct") {
|
||||
if (parsed.kind === "dm") {
|
||||
return { ok: true, to: parsed.ship };
|
||||
}
|
||||
return { ok: true, to: parsed.nest };
|
||||
@@ -113,16 +180,17 @@ const tlonOutbound: ChannelOutboundAdapter = {
|
||||
throw new Error(`Invalid Tlon target. Use ${formatTargetHint()}`);
|
||||
}
|
||||
|
||||
const ssrfPolicy = ssrfPolicyFromAllowPrivateNetwork(account.allowPrivateNetwork);
|
||||
const cookie = await authenticate(account.url, account.code, { ssrfPolicy });
|
||||
const api = new UrbitChannelClient(account.url, cookie, {
|
||||
ship: account.ship.replace(/^~/, ""),
|
||||
ssrfPolicy,
|
||||
// Use HTTP-only poke (no EventSource) to avoid conflicts with monitor's SSE connection
|
||||
const api = await createHttpPokeApi({
|
||||
url: account.url,
|
||||
ship: account.ship,
|
||||
code: account.code,
|
||||
allowPrivateNetwork: account.allowPrivateNetwork ?? undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
const fromShip = normalizeShip(account.ship);
|
||||
if (parsed.kind === "direct") {
|
||||
if (parsed.kind === "dm") {
|
||||
return await sendDm({
|
||||
api,
|
||||
fromShip,
|
||||
@@ -140,19 +208,69 @@ const tlonOutbound: ChannelOutboundAdapter = {
|
||||
replyToId: replyId,
|
||||
});
|
||||
} finally {
|
||||
await api.close();
|
||||
try {
|
||||
await api.delete();
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
},
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId, threadId }) => {
|
||||
const mergedText = buildMediaText(text, mediaUrl);
|
||||
return await tlonOutbound.sendText!({
|
||||
cfg,
|
||||
to,
|
||||
text: mergedText,
|
||||
accountId,
|
||||
replyToId,
|
||||
threadId,
|
||||
const account = resolveTlonAccount(cfg, accountId ?? undefined);
|
||||
if (!account.configured || !account.ship || !account.url || !account.code) {
|
||||
throw new Error("Tlon account not configured");
|
||||
}
|
||||
|
||||
const parsed = parseTlonTarget(to);
|
||||
if (!parsed) {
|
||||
throw new Error(`Invalid Tlon target. Use ${formatTargetHint()}`);
|
||||
}
|
||||
|
||||
// Configure the API client for uploads
|
||||
configureClient({
|
||||
shipUrl: account.url,
|
||||
shipName: account.ship.replace(/^~/, ""),
|
||||
verbose: false,
|
||||
getCode: async () => account.code!,
|
||||
});
|
||||
|
||||
const uploadedUrl = mediaUrl ? await uploadImageFromUrl(mediaUrl) : undefined;
|
||||
|
||||
const api = await createHttpPokeApi({
|
||||
url: account.url,
|
||||
ship: account.ship,
|
||||
code: account.code,
|
||||
allowPrivateNetwork: account.allowPrivateNetwork ?? undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
const fromShip = normalizeShip(account.ship);
|
||||
const story = buildMediaStory(text, uploadedUrl);
|
||||
|
||||
if (parsed.kind === "dm") {
|
||||
return await sendDmWithStory({
|
||||
api,
|
||||
fromShip,
|
||||
toShip: parsed.ship,
|
||||
story,
|
||||
});
|
||||
}
|
||||
const replyId = (replyToId ?? threadId) ? String(replyToId ?? threadId) : undefined;
|
||||
return await sendGroupMessageWithStory({
|
||||
api,
|
||||
fromShip,
|
||||
hostShip: parsed.hostShip,
|
||||
channelName: parsed.channelName,
|
||||
story,
|
||||
replyToId: replyId,
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
await api.delete();
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -170,7 +288,7 @@ export const tlonPlugin: ChannelPlugin = {
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group", "thread"],
|
||||
media: false,
|
||||
media: true,
|
||||
reply: true,
|
||||
threads: true,
|
||||
},
|
||||
@@ -189,7 +307,7 @@ export const tlonPlugin: ChannelPlugin = {
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
tlon: {
|
||||
...(cfg.channels?.tlon as Record<string, unknown>),
|
||||
...cfg.channels?.tlon,
|
||||
enabled,
|
||||
},
|
||||
},
|
||||
@@ -200,7 +318,7 @@ export const tlonPlugin: ChannelPlugin = {
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
tlon: {
|
||||
...(cfg.channels?.tlon as Record<string, unknown>),
|
||||
...cfg.channels?.tlon,
|
||||
accounts: {
|
||||
...cfg.channels?.tlon?.accounts,
|
||||
[accountId]: {
|
||||
@@ -215,11 +333,13 @@ export const tlonPlugin: ChannelPlugin = {
|
||||
deleteAccount: ({ cfg, accountId }) => {
|
||||
const useDefault = !accountId || accountId === "default";
|
||||
if (useDefault) {
|
||||
// oxlint-disable-next-line no-unused-vars
|
||||
const { ship, code, url, name, ...rest } = (cfg.channels?.tlon ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const {
|
||||
ship: _ship,
|
||||
code: _code,
|
||||
url: _url,
|
||||
name: _name,
|
||||
...rest
|
||||
} = cfg.channels?.tlon ?? {};
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
@@ -228,15 +348,13 @@ export const tlonPlugin: ChannelPlugin = {
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
// oxlint-disable-next-line no-unused-vars
|
||||
const { [accountId]: removed, ...remainingAccounts } = (cfg.channels?.tlon?.accounts ??
|
||||
{}) as Record<string, unknown>;
|
||||
const { [accountId]: _removed, ...remainingAccounts } = cfg.channels?.tlon?.accounts ?? {};
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
tlon: {
|
||||
...(cfg.channels?.tlon as Record<string, unknown>),
|
||||
...cfg.channels?.tlon,
|
||||
accounts: remainingAccounts,
|
||||
},
|
||||
},
|
||||
@@ -291,7 +409,7 @@ export const tlonPlugin: ChannelPlugin = {
|
||||
if (!parsed) {
|
||||
return target.trim();
|
||||
}
|
||||
if (parsed.kind === "direct") {
|
||||
if (parsed.kind === "dm") {
|
||||
return parsed.ship;
|
||||
}
|
||||
return parsed.nest;
|
||||
@@ -325,11 +443,14 @@ export const tlonPlugin: ChannelPlugin = {
|
||||
return [];
|
||||
});
|
||||
},
|
||||
buildChannelSummary: ({ snapshot }) => ({
|
||||
configured: snapshot.configured ?? false,
|
||||
ship: (snapshot as { ship?: string | null }).ship ?? null,
|
||||
url: (snapshot as { url?: string | null }).url ?? null,
|
||||
}),
|
||||
buildChannelSummary: ({ snapshot }) => {
|
||||
const s = snapshot as { configured?: boolean; ship?: string; url?: string };
|
||||
return {
|
||||
configured: s.configured ?? false,
|
||||
ship: s.ship ?? null,
|
||||
url: s.url ?? null,
|
||||
};
|
||||
},
|
||||
probeAccount: async ({ account }) => {
|
||||
if (!account.configured || !account.ship || !account.url || !account.code) {
|
||||
return { ok: false, error: "Not configured" };
|
||||
@@ -337,33 +458,47 @@ export const tlonPlugin: ChannelPlugin = {
|
||||
try {
|
||||
const ssrfPolicy = ssrfPolicyFromAllowPrivateNetwork(account.allowPrivateNetwork);
|
||||
const cookie = await authenticate(account.url, account.code, { ssrfPolicy });
|
||||
const api = new UrbitChannelClient(account.url, cookie, {
|
||||
ship: account.ship.replace(/^~/, ""),
|
||||
// Simple probe - just verify we can reach /~/name
|
||||
const { response, release } = await urbitFetch({
|
||||
baseUrl: account.url,
|
||||
path: "/~/name",
|
||||
init: {
|
||||
method: "GET",
|
||||
headers: { Cookie: cookie },
|
||||
},
|
||||
ssrfPolicy,
|
||||
timeoutMs: 30_000,
|
||||
auditContext: "tlon-probe-account",
|
||||
});
|
||||
try {
|
||||
await api.getOurName();
|
||||
if (!response.ok) {
|
||||
return { ok: false, error: `Name request failed: ${response.status}` };
|
||||
}
|
||||
return { ok: true };
|
||||
} finally {
|
||||
await api.close();
|
||||
await release();
|
||||
}
|
||||
} catch (error) {
|
||||
return { ok: false, error: (error as { message?: string })?.message ?? String(error) };
|
||||
}
|
||||
},
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
ship: account.ship,
|
||||
url: account.url,
|
||||
running: runtime?.running ?? false,
|
||||
lastStartAt: runtime?.lastStartAt ?? null,
|
||||
lastStopAt: runtime?.lastStopAt ?? null,
|
||||
lastError: runtime?.lastError ?? null,
|
||||
probe,
|
||||
}),
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => {
|
||||
// Tlon-specific snapshot with ship/url for status display
|
||||
const snapshot = {
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
ship: account.ship,
|
||||
url: account.url,
|
||||
running: runtime?.running ?? false,
|
||||
lastStartAt: runtime?.lastStartAt ?? null,
|
||||
lastStopAt: runtime?.lastStopAt ?? null,
|
||||
lastError: runtime?.lastError ?? null,
|
||||
probe,
|
||||
};
|
||||
return snapshot as import("openclaw/plugin-sdk").ChannelAccountSnapshot;
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
@@ -372,7 +507,7 @@ export const tlonPlugin: ChannelPlugin = {
|
||||
accountId: account.accountId,
|
||||
ship: account.ship,
|
||||
url: account.url,
|
||||
} as ChannelAccountSnapshot);
|
||||
} as import("openclaw/plugin-sdk").ChannelAccountSnapshot);
|
||||
ctx.log?.info(`[${account.accountId}] starting Tlon provider for ${account.ship ?? "tlon"}`);
|
||||
return monitorTlonProvider({
|
||||
runtime: ctx.runtime,
|
||||
|
||||
@@ -25,6 +25,11 @@ const tlonCommonConfigFields = {
|
||||
autoDiscoverChannels: z.boolean().optional(),
|
||||
showModelSignature: z.boolean().optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
// Auto-accept settings
|
||||
autoAcceptDmInvites: z.boolean().optional(), // Auto-accept DMs from ships in dmAllowlist
|
||||
autoAcceptGroupInvites: z.boolean().optional(), // Auto-accept all group invites
|
||||
// Owner ship for approval system
|
||||
ownerShip: ShipSchema.optional(), // Ship that receives approval requests and can approve/deny
|
||||
} satisfies z.ZodRawShape;
|
||||
|
||||
export const TlonAccountSchema = z.object({
|
||||
|
||||
278
extensions/tlon/src/monitor/approval.ts
Normal file
278
extensions/tlon/src/monitor/approval.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Approval system for managing DM, channel mention, and group invite approvals.
|
||||
*
|
||||
* When an unknown ship tries to interact with the bot, the owner receives
|
||||
* a notification and can approve or deny the request.
|
||||
*/
|
||||
|
||||
import type { PendingApproval } from "../settings.js";
|
||||
|
||||
export type { PendingApproval };
|
||||
|
||||
export type ApprovalType = "dm" | "channel" | "group";
|
||||
|
||||
export type CreateApprovalParams = {
|
||||
type: ApprovalType;
|
||||
requestingShip: string;
|
||||
channelNest?: string;
|
||||
groupFlag?: string;
|
||||
messagePreview?: string;
|
||||
originalMessage?: {
|
||||
messageId: string;
|
||||
messageText: string;
|
||||
messageContent: unknown;
|
||||
timestamp: number;
|
||||
parentId?: string;
|
||||
isThreadReply?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a unique approval ID in the format: {type}-{timestamp}-{shortHash}
|
||||
*/
|
||||
export function generateApprovalId(type: ApprovalType): string {
|
||||
const timestamp = Date.now();
|
||||
const randomPart = Math.random().toString(36).substring(2, 6);
|
||||
return `${type}-${timestamp}-${randomPart}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a pending approval object.
|
||||
*/
|
||||
export function createPendingApproval(params: CreateApprovalParams): PendingApproval {
|
||||
return {
|
||||
id: generateApprovalId(params.type),
|
||||
type: params.type,
|
||||
requestingShip: params.requestingShip,
|
||||
channelNest: params.channelNest,
|
||||
groupFlag: params.groupFlag,
|
||||
messagePreview: params.messagePreview,
|
||||
originalMessage: params.originalMessage,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text to a maximum length with ellipsis.
|
||||
*/
|
||||
function truncate(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) {
|
||||
return text;
|
||||
}
|
||||
return text.substring(0, maxLength - 3) + "...";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a notification message for the owner about a pending approval.
|
||||
*/
|
||||
export function formatApprovalRequest(approval: PendingApproval): string {
|
||||
const preview = approval.messagePreview ? `\n"${truncate(approval.messagePreview, 100)}"` : "";
|
||||
|
||||
switch (approval.type) {
|
||||
case "dm":
|
||||
return (
|
||||
`New DM request from ${approval.requestingShip}:${preview}\n\n` +
|
||||
`Reply "approve", "deny", or "block" (ID: ${approval.id})`
|
||||
);
|
||||
|
||||
case "channel":
|
||||
return (
|
||||
`${approval.requestingShip} mentioned you in ${approval.channelNest}:${preview}\n\n` +
|
||||
`Reply "approve", "deny", or "block"\n` +
|
||||
`(ID: ${approval.id})`
|
||||
);
|
||||
|
||||
case "group":
|
||||
return (
|
||||
`Group invite from ${approval.requestingShip} to join ${approval.groupFlag}\n\n` +
|
||||
`Reply "approve", "deny", or "block"\n` +
|
||||
`(ID: ${approval.id})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export type ApprovalResponse = {
|
||||
action: "approve" | "deny" | "block";
|
||||
id?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse an owner's response to an approval request.
|
||||
* Supports formats:
|
||||
* - "approve" / "deny" / "block" (applies to most recent pending)
|
||||
* - "approve dm-1234567890-abc" / "deny dm-1234567890-abc" (specific ID)
|
||||
* - "block" permanently blocks the ship via Tlon's native blocking
|
||||
*/
|
||||
export function parseApprovalResponse(text: string): ApprovalResponse | null {
|
||||
const trimmed = text.trim().toLowerCase();
|
||||
|
||||
// Match "approve", "deny", or "block" optionally followed by an ID
|
||||
const match = trimmed.match(/^(approve|deny|block)(?:\s+(.+))?$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const action = match[1] as "approve" | "deny" | "block";
|
||||
const id = match[2]?.trim();
|
||||
|
||||
return { action, id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message text looks like an approval response.
|
||||
* Used to determine if we should intercept the message before normal processing.
|
||||
*/
|
||||
export function isApprovalResponse(text: string): boolean {
|
||||
const trimmed = text.trim().toLowerCase();
|
||||
return trimmed.startsWith("approve") || trimmed.startsWith("deny") || trimmed.startsWith("block");
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a pending approval by ID, or return the most recent if no ID specified.
|
||||
*/
|
||||
export function findPendingApproval(
|
||||
pendingApprovals: PendingApproval[],
|
||||
id?: string,
|
||||
): PendingApproval | undefined {
|
||||
if (id) {
|
||||
return pendingApprovals.find((a) => a.id === id);
|
||||
}
|
||||
// Return most recent
|
||||
return pendingApprovals[pendingApprovals.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there's already a pending approval for the same ship/channel/group combo.
|
||||
* Used to avoid sending duplicate notifications.
|
||||
*/
|
||||
export function hasDuplicatePending(
|
||||
pendingApprovals: PendingApproval[],
|
||||
type: ApprovalType,
|
||||
requestingShip: string,
|
||||
channelNest?: string,
|
||||
groupFlag?: string,
|
||||
): boolean {
|
||||
return pendingApprovals.some((approval) => {
|
||||
if (approval.type !== type || approval.requestingShip !== requestingShip) {
|
||||
return false;
|
||||
}
|
||||
if (type === "channel" && approval.channelNest !== channelNest) {
|
||||
return false;
|
||||
}
|
||||
if (type === "group" && approval.groupFlag !== groupFlag) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a pending approval from the list by ID.
|
||||
*/
|
||||
export function removePendingApproval(
|
||||
pendingApprovals: PendingApproval[],
|
||||
id: string,
|
||||
): PendingApproval[] {
|
||||
return pendingApprovals.filter((a) => a.id !== id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a confirmation message after an approval action.
|
||||
*/
|
||||
export function formatApprovalConfirmation(
|
||||
approval: PendingApproval,
|
||||
action: "approve" | "deny" | "block",
|
||||
): string {
|
||||
if (action === "block") {
|
||||
return `Blocked ${approval.requestingShip}. They will no longer be able to contact the bot.`;
|
||||
}
|
||||
|
||||
const actionText = action === "approve" ? "Approved" : "Denied";
|
||||
|
||||
switch (approval.type) {
|
||||
case "dm":
|
||||
if (action === "approve") {
|
||||
return `${actionText} DM access for ${approval.requestingShip}. They can now message the bot.`;
|
||||
}
|
||||
return `${actionText} DM request from ${approval.requestingShip}.`;
|
||||
|
||||
case "channel":
|
||||
if (action === "approve") {
|
||||
return `${actionText} ${approval.requestingShip} for ${approval.channelNest}. They can now interact in this channel.`;
|
||||
}
|
||||
return `${actionText} ${approval.requestingShip} for ${approval.channelNest}.`;
|
||||
|
||||
case "group":
|
||||
if (action === "approve") {
|
||||
return `${actionText} group invite from ${approval.requestingShip} to ${approval.groupFlag}. Joining group...`;
|
||||
}
|
||||
return `${actionText} group invite from ${approval.requestingShip} to ${approval.groupFlag}.`;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Admin Commands
|
||||
// ============================================================================
|
||||
|
||||
export type AdminCommand =
|
||||
| { type: "unblock"; ship: string }
|
||||
| { type: "blocked" }
|
||||
| { type: "pending" };
|
||||
|
||||
/**
|
||||
* Parse an admin command from owner message.
|
||||
* Supports:
|
||||
* - "unblock ~ship" - unblock a specific ship
|
||||
* - "blocked" - list all blocked ships
|
||||
* - "pending" - list all pending approvals
|
||||
*/
|
||||
export function parseAdminCommand(text: string): AdminCommand | null {
|
||||
const trimmed = text.trim().toLowerCase();
|
||||
|
||||
// "blocked" - list blocked ships
|
||||
if (trimmed === "blocked") {
|
||||
return { type: "blocked" };
|
||||
}
|
||||
|
||||
// "pending" - list pending approvals
|
||||
if (trimmed === "pending") {
|
||||
return { type: "pending" };
|
||||
}
|
||||
|
||||
// "unblock ~ship" - unblock a specific ship
|
||||
const unblockMatch = trimmed.match(/^unblock\s+(~[\w-]+)$/);
|
||||
if (unblockMatch) {
|
||||
return { type: "unblock", ship: unblockMatch[1] };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message text looks like an admin command.
|
||||
*/
|
||||
export function isAdminCommand(text: string): boolean {
|
||||
return parseAdminCommand(text) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the list of blocked ships for display to owner.
|
||||
*/
|
||||
export function formatBlockedList(ships: string[]): string {
|
||||
if (ships.length === 0) {
|
||||
return "No ships are currently blocked.";
|
||||
}
|
||||
return `Blocked ships (${ships.length}):\n${ships.map((s) => `• ${s}`).join("\n")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the list of pending approvals for display to owner.
|
||||
*/
|
||||
export function formatPendingList(approvals: PendingApproval[]): string {
|
||||
if (approvals.length === 0) {
|
||||
return "No pending approval requests.";
|
||||
}
|
||||
return `Pending approvals (${approvals.length}):\n${approvals
|
||||
.map((a) => `• ${a.id}: ${a.type} from ${a.requestingShip}`)
|
||||
.join("\n")}`;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
import type { Foreigns } from "../urbit/foreigns.js";
|
||||
import { formatChangesDate } from "./utils.js";
|
||||
|
||||
export async function fetchGroupChanges(
|
||||
@@ -15,34 +16,33 @@ export async function fetchGroupChanges(
|
||||
return changes;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
runtime.log?.(
|
||||
`[tlon] Failed to fetch changes (falling back to full init): ${(error as { message?: string })?.message ?? String(error)}`,
|
||||
`[tlon] Failed to fetch changes (falling back to full init): ${error?.message ?? String(error)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAllChannels(
|
||||
export interface InitData {
|
||||
channels: string[];
|
||||
foreigns: Foreigns | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch groups-ui init data, returning channels and foreigns.
|
||||
* This is a single scry that provides both channel discovery and pending invites.
|
||||
*/
|
||||
export async function fetchInitData(
|
||||
api: { scry: (path: string) => Promise<unknown> },
|
||||
runtime: RuntimeEnv,
|
||||
): Promise<string[]> {
|
||||
): Promise<InitData> {
|
||||
try {
|
||||
runtime.log?.("[tlon] Attempting auto-discovery of group channels...");
|
||||
const changes = await fetchGroupChanges(api, runtime, 5);
|
||||
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
let initData: any;
|
||||
if (changes) {
|
||||
runtime.log?.("[tlon] Changes data received, using full init for channel extraction");
|
||||
initData = await api.scry("/groups-ui/v6/init.json");
|
||||
} else {
|
||||
initData = await api.scry("/groups-ui/v6/init.json");
|
||||
}
|
||||
runtime.log?.("[tlon] Fetching groups-ui init data...");
|
||||
const initData = (await api.scry("/groups-ui/v6/init.json")) as any;
|
||||
|
||||
const channels: string[] = [];
|
||||
if (initData && initData.groups) {
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
if (initData?.groups) {
|
||||
for (const groupData of Object.values(initData.groups as Record<string, any>)) {
|
||||
if (groupData && typeof groupData === "object" && groupData.channels) {
|
||||
for (const channelNest of Object.keys(groupData.channels)) {
|
||||
@@ -56,23 +56,31 @@ export async function fetchAllChannels(
|
||||
|
||||
if (channels.length > 0) {
|
||||
runtime.log?.(`[tlon] Auto-discovered ${channels.length} chat channel(s)`);
|
||||
runtime.log?.(
|
||||
`[tlon] Channels: ${channels.slice(0, 5).join(", ")}${channels.length > 5 ? "..." : ""}`,
|
||||
);
|
||||
} else {
|
||||
runtime.log?.("[tlon] No chat channels found via auto-discovery");
|
||||
runtime.log?.("[tlon] Add channels manually to config: channels.tlon.groupChannels");
|
||||
}
|
||||
|
||||
return channels;
|
||||
} catch (error) {
|
||||
runtime.log?.(
|
||||
`[tlon] Auto-discovery failed: ${(error as { message?: string })?.message ?? String(error)}`,
|
||||
);
|
||||
runtime.log?.(
|
||||
"[tlon] To monitor group channels, add them to config: channels.tlon.groupChannels",
|
||||
);
|
||||
runtime.log?.('[tlon] Example: ["chat/~host-ship/channel-name"]');
|
||||
return [];
|
||||
const foreigns = (initData?.foreigns as Foreigns) || null;
|
||||
if (foreigns) {
|
||||
const pendingCount = Object.values(foreigns).filter((f) =>
|
||||
f.invites?.some((i) => i.valid),
|
||||
).length;
|
||||
if (pendingCount > 0) {
|
||||
runtime.log?.(`[tlon] Found ${pendingCount} pending group invite(s)`);
|
||||
}
|
||||
}
|
||||
|
||||
return { channels, foreigns };
|
||||
} catch (error: any) {
|
||||
runtime.log?.(`[tlon] Init data fetch failed: ${error?.message ?? String(error)}`);
|
||||
return { channels: [], foreigns: null };
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAllChannels(
|
||||
api: { scry: (path: string) => Promise<unknown> },
|
||||
runtime: RuntimeEnv,
|
||||
): Promise<string[]> {
|
||||
const { channels } = await fetchInitData(api, runtime);
|
||||
return channels;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
import { extractMessageText } from "./utils.js";
|
||||
|
||||
/**
|
||||
* Format a number as @ud (with dots every 3 digits from the right)
|
||||
* e.g., 170141184507799509469114119040828178432 -> 170.141.184.507.799.509.469.114.119.040.828.178.432
|
||||
*/
|
||||
function formatUd(id: string | number): string {
|
||||
const str = String(id).replace(/\./g, ""); // Remove any existing dots
|
||||
const reversed = str.split("").toReversed();
|
||||
const chunks: string[] = [];
|
||||
for (let i = 0; i < reversed.length; i += 3) {
|
||||
chunks.push(
|
||||
reversed
|
||||
.slice(i, i + 3)
|
||||
.toReversed()
|
||||
.join(""),
|
||||
);
|
||||
}
|
||||
return chunks.toReversed().join(".");
|
||||
}
|
||||
|
||||
export type TlonHistoryEntry = {
|
||||
author: string;
|
||||
content: string;
|
||||
@@ -35,13 +54,11 @@ export async function fetchChannelHistory(
|
||||
const scryPath = `/channels/v4/${channelNest}/posts/newest/${count}/outline.json`;
|
||||
runtime?.log?.(`[tlon] Fetching history: ${scryPath}`);
|
||||
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
const data: any = await api.scry(scryPath);
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
let posts: any[] = [];
|
||||
if (Array.isArray(data)) {
|
||||
posts = data;
|
||||
@@ -67,10 +84,8 @@ export async function fetchChannelHistory(
|
||||
|
||||
runtime?.log?.(`[tlon] Extracted ${messages.length} messages from history`);
|
||||
return messages;
|
||||
} catch (error) {
|
||||
runtime?.log?.(
|
||||
`[tlon] Error fetching channel history: ${(error as { message?: string })?.message ?? String(error)}`,
|
||||
);
|
||||
} catch (error: any) {
|
||||
runtime?.log?.(`[tlon] Error fetching channel history: ${error?.message ?? String(error)}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -90,3 +105,87 @@ export async function getChannelHistory(
|
||||
runtime?.log?.(`[tlon] Cache has ${cache.length} messages, need ${count}, fetching from scry...`);
|
||||
return await fetchChannelHistory(api, channelNest, count, runtime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch thread/reply history for a specific parent post.
|
||||
* Used to get context when entering a thread conversation.
|
||||
*/
|
||||
export async function fetchThreadHistory(
|
||||
api: { scry: (path: string) => Promise<unknown> },
|
||||
channelNest: string,
|
||||
parentId: string,
|
||||
count = 50,
|
||||
runtime?: RuntimeEnv,
|
||||
): Promise<TlonHistoryEntry[]> {
|
||||
try {
|
||||
// Tlon API: fetch replies to a specific post
|
||||
// Format: /channels/v4/{nest}/posts/post/{parentId}/replies/newest/{count}.json
|
||||
// parentId needs @ud formatting (dots every 3 digits)
|
||||
const formattedParentId = formatUd(parentId);
|
||||
runtime?.log?.(
|
||||
`[tlon] Thread history - parentId: ${parentId} -> formatted: ${formattedParentId}`,
|
||||
);
|
||||
|
||||
const scryPath = `/channels/v4/${channelNest}/posts/post/id/${formattedParentId}/replies/newest/${count}.json`;
|
||||
runtime?.log?.(`[tlon] Fetching thread history: ${scryPath}`);
|
||||
|
||||
const data: any = await api.scry(scryPath);
|
||||
if (!data) {
|
||||
runtime?.log?.(`[tlon] No thread history data returned`);
|
||||
return [];
|
||||
}
|
||||
|
||||
let replies: any[] = [];
|
||||
if (Array.isArray(data)) {
|
||||
replies = data;
|
||||
} else if (data.replies && Array.isArray(data.replies)) {
|
||||
replies = data.replies;
|
||||
} else if (typeof data === "object") {
|
||||
replies = Object.values(data);
|
||||
}
|
||||
|
||||
const messages = replies
|
||||
.map((item) => {
|
||||
// Thread replies use 'memo' structure
|
||||
const memo = item.memo || item["r-reply"]?.set?.memo || item;
|
||||
const seal = item.seal || item["r-reply"]?.set?.seal;
|
||||
|
||||
return {
|
||||
author: memo?.author || "unknown",
|
||||
content: extractMessageText(memo?.content || []),
|
||||
timestamp: memo?.sent || Date.now(),
|
||||
id: seal?.id || item.id,
|
||||
} as TlonHistoryEntry;
|
||||
})
|
||||
.filter((msg) => msg.content);
|
||||
|
||||
runtime?.log?.(`[tlon] Extracted ${messages.length} thread replies from history`);
|
||||
return messages;
|
||||
} catch (error: any) {
|
||||
runtime?.log?.(`[tlon] Error fetching thread history: ${error?.message ?? String(error)}`);
|
||||
// Fall back to trying alternate path structure
|
||||
try {
|
||||
const altPath = `/channels/v4/${channelNest}/posts/post/id/${formatUd(parentId)}.json`;
|
||||
runtime?.log?.(`[tlon] Trying alternate path: ${altPath}`);
|
||||
const data: any = await api.scry(altPath);
|
||||
|
||||
if (data?.seal?.meta?.replyCount > 0 && data?.replies) {
|
||||
const replies = Array.isArray(data.replies) ? data.replies : Object.values(data.replies);
|
||||
const messages = replies
|
||||
.map((reply: any) => ({
|
||||
author: reply.memo?.author || "unknown",
|
||||
content: extractMessageText(reply.memo?.content || []),
|
||||
timestamp: reply.memo?.sent || Date.now(),
|
||||
id: reply.seal?.id,
|
||||
}))
|
||||
.filter((msg: TlonHistoryEntry) => msg.content);
|
||||
|
||||
runtime?.log?.(`[tlon] Extracted ${messages.length} replies from post data`);
|
||||
return messages;
|
||||
}
|
||||
} catch (altError: any) {
|
||||
runtime?.log?.(`[tlon] Alternate path also failed: ${altError?.message ?? String(altError)}`);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
166
extensions/tlon/src/monitor/media.ts
Normal file
166
extensions/tlon/src/monitor/media.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createWriteStream } from "node:fs";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk";
|
||||
import { getDefaultSsrFPolicy } from "../urbit/context.js";
|
||||
|
||||
// Default to OpenClaw workspace media directory
|
||||
const DEFAULT_MEDIA_DIR = path.join(homedir(), ".openclaw", "workspace", "media", "inbound");
|
||||
|
||||
export interface ExtractedImage {
|
||||
url: string;
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
export interface DownloadedMedia {
|
||||
localPath: string;
|
||||
contentType: string;
|
||||
originalUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract image blocks from Tlon message content.
|
||||
* Returns array of image URLs found in the message.
|
||||
*/
|
||||
export function extractImageBlocks(content: unknown): ExtractedImage[] {
|
||||
if (!content || !Array.isArray(content)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const images: ExtractedImage[] = [];
|
||||
|
||||
for (const verse of content) {
|
||||
if (verse?.block?.image?.src) {
|
||||
images.push({
|
||||
url: verse.block.image.src,
|
||||
alt: verse.block.image.alt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a media file from URL to local storage.
|
||||
* Returns the local path where the file was saved.
|
||||
*/
|
||||
export async function downloadMedia(
|
||||
url: string,
|
||||
mediaDir: string = DEFAULT_MEDIA_DIR,
|
||||
): Promise<DownloadedMedia | null> {
|
||||
try {
|
||||
// Validate URL is http/https before fetching
|
||||
const parsedUrl = new URL(url);
|
||||
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
|
||||
console.warn(`[tlon-media] Rejected non-http(s) URL: ${url}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure media directory exists
|
||||
await mkdir(mediaDir, { recursive: true });
|
||||
|
||||
// Fetch with SSRF protection
|
||||
// Use fetchWithSsrFGuard directly (not urbitFetch) to preserve the full URL path
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url,
|
||||
init: { method: "GET" },
|
||||
policy: getDefaultSsrFPolicy(),
|
||||
auditContext: "tlon-media-download",
|
||||
});
|
||||
|
||||
try {
|
||||
if (!response.ok) {
|
||||
console.error(`[tlon-media] Failed to fetch ${url}: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Determine content type and extension
|
||||
const contentType = response.headers.get("content-type") || "application/octet-stream";
|
||||
const ext = getExtensionFromContentType(contentType) || getExtensionFromUrl(url) || "bin";
|
||||
|
||||
// Generate unique filename
|
||||
const filename = `${randomUUID()}.${ext}`;
|
||||
const localPath = path.join(mediaDir, filename);
|
||||
|
||||
// Stream to file
|
||||
const body = response.body;
|
||||
if (!body) {
|
||||
console.error(`[tlon-media] No response body for ${url}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const writeStream = createWriteStream(localPath);
|
||||
await pipeline(Readable.fromWeb(body as any), writeStream);
|
||||
|
||||
return {
|
||||
localPath,
|
||||
contentType,
|
||||
originalUrl: url,
|
||||
};
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`[tlon-media] Error downloading ${url}: ${error?.message ?? String(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getExtensionFromContentType(contentType: string): string | null {
|
||||
const map: Record<string, string> = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/jpg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/gif": "gif",
|
||||
"image/webp": "webp",
|
||||
"image/svg+xml": "svg",
|
||||
"video/mp4": "mp4",
|
||||
"video/webm": "webm",
|
||||
"audio/mpeg": "mp3",
|
||||
"audio/ogg": "ogg",
|
||||
};
|
||||
return map[contentType.split(";")[0].trim()] ?? null;
|
||||
}
|
||||
|
||||
function getExtensionFromUrl(url: string): string | null {
|
||||
try {
|
||||
const pathname = new URL(url).pathname;
|
||||
const match = pathname.match(/\.([a-z0-9]+)$/i);
|
||||
return match ? match[1].toLowerCase() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download all images from a message and return attachment metadata.
|
||||
* Format matches OpenClaw's expected attachment structure.
|
||||
*/
|
||||
export async function downloadMessageImages(
|
||||
content: unknown,
|
||||
mediaDir?: string,
|
||||
): Promise<Array<{ path: string; contentType: string }>> {
|
||||
const images = extractImageBlocks(content);
|
||||
if (images.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const attachments: Array<{ path: string; contentType: string }> = [];
|
||||
|
||||
for (const image of images) {
|
||||
const downloaded = await downloadMedia(image.url, mediaDir);
|
||||
if (downloaded) {
|
||||
attachments.push({
|
||||
path: downloaded.localPath,
|
||||
contentType: downloaded.contentType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return attachments;
|
||||
}
|
||||
@@ -1,12 +1,76 @@
|
||||
import { normalizeShip } from "../targets.js";
|
||||
|
||||
// Cite types for message references
|
||||
export interface ChanCite {
|
||||
chan: { nest: string; where: string };
|
||||
}
|
||||
export interface GroupCite {
|
||||
group: string;
|
||||
}
|
||||
export interface DeskCite {
|
||||
desk: { flag: string; where: string };
|
||||
}
|
||||
export interface BaitCite {
|
||||
bait: { group: string; graph: string; where: string };
|
||||
}
|
||||
export type Cite = ChanCite | GroupCite | DeskCite | BaitCite;
|
||||
|
||||
export interface ParsedCite {
|
||||
type: "chan" | "group" | "desk" | "bait";
|
||||
nest?: string;
|
||||
author?: string;
|
||||
postId?: string;
|
||||
group?: string;
|
||||
flag?: string;
|
||||
where?: string;
|
||||
}
|
||||
|
||||
// Extract all cites from message content
|
||||
export function extractCites(content: unknown): ParsedCite[] {
|
||||
if (!content || !Array.isArray(content)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const cites: ParsedCite[] = [];
|
||||
|
||||
for (const verse of content) {
|
||||
if (verse?.block?.cite && typeof verse.block.cite === "object") {
|
||||
const cite = verse.block.cite;
|
||||
|
||||
if (cite.chan && typeof cite.chan === "object") {
|
||||
const { nest, where } = cite.chan;
|
||||
const whereMatch = where?.match(/\/msg\/(~[a-z-]+)\/(.+)/);
|
||||
cites.push({
|
||||
type: "chan",
|
||||
nest,
|
||||
where,
|
||||
author: whereMatch?.[1],
|
||||
postId: whereMatch?.[2],
|
||||
});
|
||||
} else if (cite.group && typeof cite.group === "string") {
|
||||
cites.push({ type: "group", group: cite.group });
|
||||
} else if (cite.desk && typeof cite.desk === "object") {
|
||||
cites.push({ type: "desk", flag: cite.desk.flag, where: cite.desk.where });
|
||||
} else if (cite.bait && typeof cite.bait === "object") {
|
||||
cites.push({
|
||||
type: "bait",
|
||||
group: cite.bait.group,
|
||||
nest: cite.bait.graph,
|
||||
where: cite.bait.where,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cites;
|
||||
}
|
||||
|
||||
export function formatModelName(modelString?: string | null): string {
|
||||
if (!modelString) {
|
||||
return "AI";
|
||||
}
|
||||
const modelName = modelString.includes("/") ? modelString.split("/")[1] : modelString;
|
||||
const modelMappings: Record<string, string> = {
|
||||
"claude-opus-4-6": "Claude Opus 4.6",
|
||||
"claude-opus-4-5": "Claude Opus 4.5",
|
||||
"claude-sonnet-4-5": "Claude Sonnet 4.5",
|
||||
"claude-sonnet-3-5": "Claude Sonnet 3.5",
|
||||
@@ -27,62 +91,234 @@ export function formatModelName(modelString?: string | null): string {
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function isBotMentioned(messageText: string, botShipName: string): boolean {
|
||||
export function isBotMentioned(
|
||||
messageText: string,
|
||||
botShipName: string,
|
||||
nickname?: string,
|
||||
): boolean {
|
||||
if (!messageText || !botShipName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for @all mention
|
||||
if (/@all\b/i.test(messageText)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for ship mention
|
||||
const normalizedBotShip = normalizeShip(botShipName);
|
||||
const escapedShip = normalizedBotShip.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const mentionPattern = new RegExp(`(^|\\s)${escapedShip}(?=\\s|$)`, "i");
|
||||
return mentionPattern.test(messageText);
|
||||
if (mentionPattern.test(messageText)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for nickname mention (case-insensitive, word boundary)
|
||||
if (nickname) {
|
||||
const escapedNickname = nickname.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const nicknamePattern = new RegExp(`(^|\\s)${escapedNickname}(?=\\s|$|[,!?.])`, "i");
|
||||
if (nicknamePattern.test(messageText)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip bot ship mention from message text for command detection.
|
||||
* "~bot-ship /status" → "/status"
|
||||
*/
|
||||
export function stripBotMention(messageText: string, botShipName: string): string {
|
||||
if (!messageText || !botShipName) return messageText;
|
||||
return messageText.replace(normalizeShip(botShipName), "").trim();
|
||||
}
|
||||
|
||||
export function isDmAllowed(senderShip: string, allowlist: string[] | undefined): boolean {
|
||||
if (!allowlist || allowlist.length === 0) {
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
const normalizedSender = normalizeShip(senderShip);
|
||||
return allowlist.map((ship) => normalizeShip(ship)).some((ship) => ship === normalizedSender);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a group invite from a ship should be auto-accepted.
|
||||
*
|
||||
* SECURITY: Fail-safe to deny. If allowlist is empty or undefined,
|
||||
* ALL invites are rejected - even if autoAcceptGroupInvites is enabled.
|
||||
* This prevents misconfigured bots from accepting malicious invites.
|
||||
*/
|
||||
export function isGroupInviteAllowed(
|
||||
inviterShip: string,
|
||||
allowlist: string[] | undefined,
|
||||
): boolean {
|
||||
// SECURITY: Fail-safe to deny when no allowlist configured
|
||||
if (!allowlist || allowlist.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const normalizedInviter = normalizeShip(inviterShip);
|
||||
return allowlist.map((ship) => normalizeShip(ship)).some((ship) => ship === normalizedInviter);
|
||||
}
|
||||
|
||||
// Helper to recursively extract text from inline content
|
||||
function extractInlineText(items: any[]): string {
|
||||
return items
|
||||
.map((item: any) => {
|
||||
if (typeof item === "string") {
|
||||
return item;
|
||||
}
|
||||
if (item && typeof item === "object") {
|
||||
if (item.ship) {
|
||||
return item.ship;
|
||||
}
|
||||
if ("sect" in item) {
|
||||
return `@${item.sect || "all"}`;
|
||||
}
|
||||
if (item["inline-code"]) {
|
||||
return `\`${item["inline-code"]}\``;
|
||||
}
|
||||
if (item.code) {
|
||||
return `\`${item.code}\``;
|
||||
}
|
||||
if (item.link && item.link.href) {
|
||||
return item.link.content || item.link.href;
|
||||
}
|
||||
if (item.bold && Array.isArray(item.bold)) {
|
||||
return `**${extractInlineText(item.bold)}**`;
|
||||
}
|
||||
if (item.italics && Array.isArray(item.italics)) {
|
||||
return `*${extractInlineText(item.italics)}*`;
|
||||
}
|
||||
if (item.strike && Array.isArray(item.strike)) {
|
||||
return `~~${extractInlineText(item.strike)}~~`;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function extractMessageText(content: unknown): string {
|
||||
if (!content || !Array.isArray(content)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return (
|
||||
content
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
.map((block: any) => {
|
||||
if (block.inline && Array.isArray(block.inline)) {
|
||||
return (
|
||||
block.inline
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
.map((item: any) => {
|
||||
if (typeof item === "string") {
|
||||
return item;
|
||||
}
|
||||
if (item && typeof item === "object") {
|
||||
if (item.ship) {
|
||||
return item.ship;
|
||||
}
|
||||
if (item.break !== undefined) {
|
||||
return "\n";
|
||||
}
|
||||
if (item.link && item.link.href) {
|
||||
return item.link.href;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.join("")
|
||||
);
|
||||
return content
|
||||
.map((verse: any) => {
|
||||
// Handle inline content (text, ships, links, etc.)
|
||||
if (verse.inline && Array.isArray(verse.inline)) {
|
||||
return verse.inline
|
||||
.map((item: any) => {
|
||||
if (typeof item === "string") {
|
||||
return item;
|
||||
}
|
||||
if (item && typeof item === "object") {
|
||||
if (item.ship) {
|
||||
return item.ship;
|
||||
}
|
||||
// Handle sect (role mentions like @all)
|
||||
if ("sect" in item) {
|
||||
return `@${item.sect || "all"}`;
|
||||
}
|
||||
if (item.break !== undefined) {
|
||||
return "\n";
|
||||
}
|
||||
if (item.link && item.link.href) {
|
||||
return item.link.href;
|
||||
}
|
||||
// Handle inline code (Tlon uses "inline-code" key)
|
||||
if (item["inline-code"]) {
|
||||
return `\`${item["inline-code"]}\``;
|
||||
}
|
||||
if (item.code) {
|
||||
return `\`${item.code}\``;
|
||||
}
|
||||
// Handle bold/italic/strike - recursively extract text
|
||||
if (item.bold && Array.isArray(item.bold)) {
|
||||
return `**${extractInlineText(item.bold)}**`;
|
||||
}
|
||||
if (item.italics && Array.isArray(item.italics)) {
|
||||
return `*${extractInlineText(item.italics)}*`;
|
||||
}
|
||||
if (item.strike && Array.isArray(item.strike)) {
|
||||
return `~~${extractInlineText(item.strike)}~~`;
|
||||
}
|
||||
// Handle blockquote inline
|
||||
if (item.blockquote && Array.isArray(item.blockquote)) {
|
||||
return `> ${extractInlineText(item.blockquote)}`;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
// Handle block content (images, code blocks, etc.)
|
||||
if (verse.block && typeof verse.block === "object") {
|
||||
const block = verse.block;
|
||||
|
||||
// Image blocks
|
||||
if (block.image && block.image.src) {
|
||||
const alt = block.image.alt ? ` (${block.image.alt})` : "";
|
||||
return `\n${block.image.src}${alt}\n`;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.join("\n")
|
||||
.trim()
|
||||
);
|
||||
|
||||
// Code blocks
|
||||
if (block.code && typeof block.code === "object") {
|
||||
const lang = block.code.lang || "";
|
||||
const code = block.code.code || "";
|
||||
return `\n\`\`\`${lang}\n${code}\n\`\`\`\n`;
|
||||
}
|
||||
|
||||
// Header blocks
|
||||
if (block.header && typeof block.header === "object") {
|
||||
const text =
|
||||
block.header.content
|
||||
?.map((item: any) => (typeof item === "string" ? item : ""))
|
||||
.join("") || "";
|
||||
return `\n## ${text}\n`;
|
||||
}
|
||||
|
||||
// Cite/quote blocks - parse the reference structure
|
||||
if (block.cite && typeof block.cite === "object") {
|
||||
const cite = block.cite;
|
||||
|
||||
// ChanCite - reference to a channel message
|
||||
if (cite.chan && typeof cite.chan === "object") {
|
||||
const { nest, where } = cite.chan;
|
||||
// where is typically /msg/~author/timestamp
|
||||
const whereMatch = where?.match(/\/msg\/(~[a-z-]+)\/(.+)/);
|
||||
if (whereMatch) {
|
||||
const [, author, _postId] = whereMatch;
|
||||
return `\n> [quoted: ${author} in ${nest}]\n`;
|
||||
}
|
||||
return `\n> [quoted from ${nest}]\n`;
|
||||
}
|
||||
|
||||
// GroupCite - reference to a group
|
||||
if (cite.group && typeof cite.group === "string") {
|
||||
return `\n> [ref: group ${cite.group}]\n`;
|
||||
}
|
||||
|
||||
// DeskCite - reference to an app/desk
|
||||
if (cite.desk && typeof cite.desk === "object") {
|
||||
return `\n> [ref: ${cite.desk.flag}]\n`;
|
||||
}
|
||||
|
||||
// BaitCite - reference with group+graph context
|
||||
if (cite.bait && typeof cite.bait === "object") {
|
||||
return `\n> [ref: ${cite.bait.graph} in ${cite.bait.group}]\n`;
|
||||
}
|
||||
|
||||
return `\n> [quoted message]\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
})
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function isSummarizationRequest(messageText: string): boolean {
|
||||
|
||||
438
extensions/tlon/src/security.test.ts
Normal file
438
extensions/tlon/src/security.test.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* Security Tests for Tlon Plugin
|
||||
*
|
||||
* These tests ensure that security-critical behavior cannot regress:
|
||||
* - DM allowlist enforcement
|
||||
* - Channel authorization rules
|
||||
* - Ship normalization consistency
|
||||
* - Bot mention detection boundaries
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isDmAllowed,
|
||||
isGroupInviteAllowed,
|
||||
isBotMentioned,
|
||||
extractMessageText,
|
||||
} from "./monitor/utils.js";
|
||||
import { normalizeShip } from "./targets.js";
|
||||
|
||||
describe("Security: DM Allowlist", () => {
|
||||
describe("isDmAllowed", () => {
|
||||
it("rejects DMs when allowlist is empty", () => {
|
||||
expect(isDmAllowed("~zod", [])).toBe(false);
|
||||
expect(isDmAllowed("~sampel-palnet", [])).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects DMs when allowlist is undefined", () => {
|
||||
expect(isDmAllowed("~zod", undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("allows DMs from ships on the allowlist", () => {
|
||||
const allowlist = ["~zod", "~bus"];
|
||||
expect(isDmAllowed("~zod", allowlist)).toBe(true);
|
||||
expect(isDmAllowed("~bus", allowlist)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects DMs from ships NOT on the allowlist", () => {
|
||||
const allowlist = ["~zod", "~bus"];
|
||||
expect(isDmAllowed("~nec", allowlist)).toBe(false);
|
||||
expect(isDmAllowed("~sampel-palnet", allowlist)).toBe(false);
|
||||
expect(isDmAllowed("~random-ship", allowlist)).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes ship names (with/without ~ prefix)", () => {
|
||||
const allowlist = ["~zod"];
|
||||
expect(isDmAllowed("zod", allowlist)).toBe(true);
|
||||
expect(isDmAllowed("~zod", allowlist)).toBe(true);
|
||||
|
||||
const allowlistWithoutTilde = ["zod"];
|
||||
expect(isDmAllowed("~zod", allowlistWithoutTilde)).toBe(true);
|
||||
expect(isDmAllowed("zod", allowlistWithoutTilde)).toBe(true);
|
||||
});
|
||||
|
||||
it("handles galaxy, star, planet, and moon names", () => {
|
||||
const allowlist = [
|
||||
"~zod", // galaxy
|
||||
"~marzod", // star
|
||||
"~sampel-palnet", // planet
|
||||
"~dozzod-dozzod-dozzod-dozzod", // moon
|
||||
];
|
||||
|
||||
expect(isDmAllowed("~zod", allowlist)).toBe(true);
|
||||
expect(isDmAllowed("~marzod", allowlist)).toBe(true);
|
||||
expect(isDmAllowed("~sampel-palnet", allowlist)).toBe(true);
|
||||
expect(isDmAllowed("~dozzod-dozzod-dozzod-dozzod", allowlist)).toBe(true);
|
||||
|
||||
// Similar but different ships should be rejected
|
||||
expect(isDmAllowed("~nec", allowlist)).toBe(false);
|
||||
expect(isDmAllowed("~wanzod", allowlist)).toBe(false);
|
||||
expect(isDmAllowed("~sampel-palned", allowlist)).toBe(false);
|
||||
});
|
||||
|
||||
// NOTE: Ship names in Urbit are always lowercase by convention.
|
||||
// This test documents current behavior - strict equality after normalization.
|
||||
// If case-insensitivity is desired, normalizeShip should lowercase.
|
||||
it("uses strict equality after normalization (case-sensitive)", () => {
|
||||
const allowlist = ["~zod"];
|
||||
expect(isDmAllowed("~zod", allowlist)).toBe(true);
|
||||
// Different case would NOT match with current implementation
|
||||
expect(isDmAllowed("~Zod", ["~Zod"])).toBe(true); // exact match works
|
||||
});
|
||||
|
||||
it("does not allow partial matches", () => {
|
||||
const allowlist = ["~zod"];
|
||||
expect(isDmAllowed("~zod-extra", allowlist)).toBe(false);
|
||||
expect(isDmAllowed("~extra-zod", allowlist)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles whitespace in ship names (normalized)", () => {
|
||||
// Ships with leading/trailing whitespace are normalized by normalizeShip
|
||||
const allowlist = [" ~zod ", "~bus"];
|
||||
expect(isDmAllowed("~zod", allowlist)).toBe(true);
|
||||
expect(isDmAllowed(" ~zod ", allowlist)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Security: Group Invite Allowlist", () => {
|
||||
describe("isGroupInviteAllowed", () => {
|
||||
it("rejects invites when allowlist is empty (fail-safe)", () => {
|
||||
// CRITICAL: Empty allowlist must DENY, not accept-all
|
||||
expect(isGroupInviteAllowed("~zod", [])).toBe(false);
|
||||
expect(isGroupInviteAllowed("~sampel-palnet", [])).toBe(false);
|
||||
expect(isGroupInviteAllowed("~malicious-actor", [])).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects invites when allowlist is undefined (fail-safe)", () => {
|
||||
// CRITICAL: Undefined allowlist must DENY, not accept-all
|
||||
expect(isGroupInviteAllowed("~zod", undefined)).toBe(false);
|
||||
expect(isGroupInviteAllowed("~sampel-palnet", undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts invites from ships on the allowlist", () => {
|
||||
const allowlist = ["~nocsyx-lassul", "~malmur-halmex"];
|
||||
expect(isGroupInviteAllowed("~nocsyx-lassul", allowlist)).toBe(true);
|
||||
expect(isGroupInviteAllowed("~malmur-halmex", allowlist)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects invites from ships NOT on the allowlist", () => {
|
||||
const allowlist = ["~nocsyx-lassul", "~malmur-halmex"];
|
||||
expect(isGroupInviteAllowed("~random-attacker", allowlist)).toBe(false);
|
||||
expect(isGroupInviteAllowed("~malicious-ship", allowlist)).toBe(false);
|
||||
expect(isGroupInviteAllowed("~zod", allowlist)).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes ship names (with/without ~ prefix)", () => {
|
||||
const allowlist = ["~nocsyx-lassul"];
|
||||
expect(isGroupInviteAllowed("nocsyx-lassul", allowlist)).toBe(true);
|
||||
expect(isGroupInviteAllowed("~nocsyx-lassul", allowlist)).toBe(true);
|
||||
|
||||
const allowlistWithoutTilde = ["nocsyx-lassul"];
|
||||
expect(isGroupInviteAllowed("~nocsyx-lassul", allowlistWithoutTilde)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not allow partial matches", () => {
|
||||
const allowlist = ["~zod"];
|
||||
expect(isGroupInviteAllowed("~zod-moon", allowlist)).toBe(false);
|
||||
expect(isGroupInviteAllowed("~pinser-botter-zod", allowlist)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles whitespace in allowlist entries", () => {
|
||||
const allowlist = [" ~nocsyx-lassul ", "~malmur-halmex"];
|
||||
expect(isGroupInviteAllowed("~nocsyx-lassul", allowlist)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Security: Bot Mention Detection", () => {
|
||||
describe("isBotMentioned", () => {
|
||||
const botShip = "~sampel-palnet";
|
||||
const nickname = "nimbus";
|
||||
|
||||
it("detects direct ship mention", () => {
|
||||
expect(isBotMentioned("hey ~sampel-palnet", botShip)).toBe(true);
|
||||
expect(isBotMentioned("~sampel-palnet can you help?", botShip)).toBe(true);
|
||||
expect(isBotMentioned("hello ~sampel-palnet how are you", botShip)).toBe(true);
|
||||
});
|
||||
|
||||
it("detects @all mention", () => {
|
||||
expect(isBotMentioned("@all please respond", botShip)).toBe(true);
|
||||
expect(isBotMentioned("hey @all", botShip)).toBe(true);
|
||||
expect(isBotMentioned("@ALL uppercase", botShip)).toBe(true);
|
||||
});
|
||||
|
||||
it("detects nickname mention", () => {
|
||||
expect(isBotMentioned("hey nimbus", botShip, nickname)).toBe(true);
|
||||
expect(isBotMentioned("nimbus help me", botShip, nickname)).toBe(true);
|
||||
expect(isBotMentioned("hello NIMBUS", botShip, nickname)).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT trigger on random messages", () => {
|
||||
expect(isBotMentioned("hello world", botShip)).toBe(false);
|
||||
expect(isBotMentioned("this is a normal message", botShip)).toBe(false);
|
||||
expect(isBotMentioned("hey everyone", botShip)).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT trigger on partial ship matches", () => {
|
||||
expect(isBotMentioned("~sampel-palnet-extra", botShip)).toBe(false);
|
||||
expect(isBotMentioned("my~sampel-palnetfriend", botShip)).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT trigger on substring nickname matches", () => {
|
||||
// "nimbus" should not match "nimbusy" or "animbust"
|
||||
expect(isBotMentioned("nimbusy", botShip, nickname)).toBe(false);
|
||||
expect(isBotMentioned("prenimbus", botShip, nickname)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles empty/null inputs safely", () => {
|
||||
expect(isBotMentioned("", botShip)).toBe(false);
|
||||
expect(isBotMentioned("test", "")).toBe(false);
|
||||
// @ts-expect-error testing null input
|
||||
expect(isBotMentioned(null, botShip)).toBe(false);
|
||||
});
|
||||
|
||||
it("requires word boundary for nickname", () => {
|
||||
expect(isBotMentioned("nimbus, hello", botShip, nickname)).toBe(true);
|
||||
expect(isBotMentioned("hello nimbus!", botShip, nickname)).toBe(true);
|
||||
expect(isBotMentioned("nimbus?", botShip, nickname)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Security: Ship Normalization", () => {
|
||||
describe("normalizeShip", () => {
|
||||
it("adds ~ prefix if missing", () => {
|
||||
expect(normalizeShip("zod")).toBe("~zod");
|
||||
expect(normalizeShip("sampel-palnet")).toBe("~sampel-palnet");
|
||||
});
|
||||
|
||||
it("preserves ~ prefix if present", () => {
|
||||
expect(normalizeShip("~zod")).toBe("~zod");
|
||||
expect(normalizeShip("~sampel-palnet")).toBe("~sampel-palnet");
|
||||
});
|
||||
|
||||
it("trims whitespace", () => {
|
||||
expect(normalizeShip(" ~zod ")).toBe("~zod");
|
||||
expect(normalizeShip(" zod ")).toBe("~zod");
|
||||
});
|
||||
|
||||
it("handles empty string", () => {
|
||||
expect(normalizeShip("")).toBe("");
|
||||
expect(normalizeShip(" ")).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Security: Message Text Extraction", () => {
|
||||
describe("extractMessageText", () => {
|
||||
it("extracts plain text", () => {
|
||||
const content = [{ inline: ["hello world"] }];
|
||||
expect(extractMessageText(content)).toBe("hello world");
|
||||
});
|
||||
|
||||
it("extracts @all mentions from sect null", () => {
|
||||
const content = [{ inline: [{ sect: null }] }];
|
||||
expect(extractMessageText(content)).toContain("@all");
|
||||
});
|
||||
|
||||
it("extracts ship mentions", () => {
|
||||
const content = [{ inline: [{ ship: "~zod" }] }];
|
||||
expect(extractMessageText(content)).toContain("~zod");
|
||||
});
|
||||
|
||||
it("handles malformed input safely", () => {
|
||||
expect(extractMessageText(null)).toBe("");
|
||||
expect(extractMessageText(undefined)).toBe("");
|
||||
expect(extractMessageText([])).toBe("");
|
||||
expect(extractMessageText([{}])).toBe("");
|
||||
expect(extractMessageText("not an array")).toBe("");
|
||||
});
|
||||
|
||||
it("does not execute injected code in inline content", () => {
|
||||
// Ensure malicious content doesn't get executed
|
||||
const maliciousContent = [{ inline: ["<script>alert('xss')</script>"] }];
|
||||
const result = extractMessageText(maliciousContent);
|
||||
expect(result).toBe("<script>alert('xss')</script>");
|
||||
// Just a string, not executed
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Security: Channel Authorization Logic", () => {
|
||||
/**
|
||||
* These tests document the expected behavior of channel authorization.
|
||||
* The actual resolveChannelAuthorization function is internal to monitor/index.ts
|
||||
* but these tests verify the building blocks and expected invariants.
|
||||
*/
|
||||
|
||||
it("default mode should be restricted (not open)", () => {
|
||||
// This is a critical security invariant: if no mode is specified,
|
||||
// channels should default to RESTRICTED, not open.
|
||||
// If this test fails, someone may have changed the default unsafely.
|
||||
|
||||
// The logic in resolveChannelAuthorization is:
|
||||
// const mode = rule?.mode ?? "restricted";
|
||||
// We verify this by checking undefined rule gives restricted
|
||||
type ModeRule = { mode?: "restricted" | "open" };
|
||||
const rule = undefined as ModeRule | undefined;
|
||||
const mode = rule?.mode ?? "restricted";
|
||||
expect(mode).toBe("restricted");
|
||||
});
|
||||
|
||||
it("empty allowedShips with restricted mode should block all", () => {
|
||||
// If a channel is restricted but has no allowed ships,
|
||||
// no one should be able to send messages
|
||||
const _mode = "restricted";
|
||||
const allowedShips: string[] = [];
|
||||
const sender = "~random-ship";
|
||||
|
||||
const isAllowed = allowedShips.some((ship) => normalizeShip(ship) === normalizeShip(sender));
|
||||
expect(isAllowed).toBe(false);
|
||||
});
|
||||
|
||||
it("open mode should not check allowedShips", () => {
|
||||
// In open mode, any ship can send regardless of allowedShips
|
||||
const mode: "open" | "restricted" = "open";
|
||||
// The check in monitor/index.ts is:
|
||||
// if (mode === "restricted") { /* check ships */ }
|
||||
// So open mode skips the ship check entirely
|
||||
expect(mode).not.toBe("restricted");
|
||||
});
|
||||
|
||||
it("settings should override file config for channel rules", () => {
|
||||
// Documented behavior: settingsRules[nest] ?? fileRules[nest]
|
||||
// This means settings take precedence
|
||||
type ChannelRule = { mode: "restricted" | "open" };
|
||||
const fileRules: Record<string, ChannelRule> = { "chat/~zod/test": { mode: "restricted" } };
|
||||
const settingsRules: Record<string, ChannelRule> = { "chat/~zod/test": { mode: "open" } };
|
||||
const nest = "chat/~zod/test";
|
||||
|
||||
const effectiveRule = settingsRules[nest] ?? fileRules[nest];
|
||||
expect(effectiveRule?.mode).toBe("open"); // settings wins
|
||||
});
|
||||
});
|
||||
|
||||
describe("Security: Authorization Edge Cases", () => {
|
||||
it("empty strings are not valid ships", () => {
|
||||
expect(isDmAllowed("", ["~zod"])).toBe(false);
|
||||
expect(isDmAllowed("~zod", [""])).toBe(false);
|
||||
});
|
||||
|
||||
it("handles very long ship-like strings", () => {
|
||||
const longName = "~" + "a".repeat(1000);
|
||||
expect(isDmAllowed(longName, ["~zod"])).toBe(false);
|
||||
});
|
||||
|
||||
it("handles special characters that could break regex", () => {
|
||||
// These should not cause regex injection
|
||||
const maliciousShip = "~zod.*";
|
||||
expect(isDmAllowed("~zodabc", [maliciousShip])).toBe(false);
|
||||
|
||||
const allowlist = ["~zod"];
|
||||
expect(isDmAllowed("~zod.*", allowlist)).toBe(false);
|
||||
});
|
||||
|
||||
it("protects against prototype pollution-style keys", () => {
|
||||
const suspiciousShip = "__proto__";
|
||||
expect(isDmAllowed(suspiciousShip, ["~zod"])).toBe(false);
|
||||
expect(isDmAllowed("~zod", [suspiciousShip])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Security: Sender Role Identification", () => {
|
||||
/**
|
||||
* Tests for sender role identification (owner vs user).
|
||||
* This prevents impersonation attacks where an approved user
|
||||
* tries to claim owner privileges through prompt injection.
|
||||
*
|
||||
* SECURITY.md Section 9: Sender Role Identification
|
||||
*/
|
||||
|
||||
// Helper to compute sender role (mirrors logic in monitor/index.ts)
|
||||
function getSenderRole(senderShip: string, ownerShip: string | null): "owner" | "user" {
|
||||
if (!ownerShip) return "user";
|
||||
return normalizeShip(senderShip) === normalizeShip(ownerShip) ? "owner" : "user";
|
||||
}
|
||||
|
||||
describe("owner detection", () => {
|
||||
it("identifies owner when ownerShip matches sender", () => {
|
||||
expect(getSenderRole("~nocsyx-lassul", "~nocsyx-lassul")).toBe("owner");
|
||||
expect(getSenderRole("nocsyx-lassul", "~nocsyx-lassul")).toBe("owner");
|
||||
expect(getSenderRole("~nocsyx-lassul", "nocsyx-lassul")).toBe("owner");
|
||||
});
|
||||
|
||||
it("identifies user when ownerShip does not match sender", () => {
|
||||
expect(getSenderRole("~random-user", "~nocsyx-lassul")).toBe("user");
|
||||
expect(getSenderRole("~malicious-actor", "~nocsyx-lassul")).toBe("user");
|
||||
});
|
||||
|
||||
it("identifies everyone as user when ownerShip is null", () => {
|
||||
expect(getSenderRole("~nocsyx-lassul", null)).toBe("user");
|
||||
expect(getSenderRole("~zod", null)).toBe("user");
|
||||
});
|
||||
|
||||
it("identifies everyone as user when ownerShip is empty string", () => {
|
||||
// Empty string should be treated like null (no owner configured)
|
||||
expect(getSenderRole("~nocsyx-lassul", "")).toBe("user");
|
||||
});
|
||||
});
|
||||
|
||||
describe("label format", () => {
|
||||
// Helper to compute fromLabel (mirrors logic in monitor/index.ts)
|
||||
function getFromLabel(
|
||||
senderShip: string,
|
||||
ownerShip: string | null,
|
||||
isGroup: boolean,
|
||||
channelNest?: string,
|
||||
): string {
|
||||
const senderRole = getSenderRole(senderShip, ownerShip);
|
||||
return isGroup
|
||||
? `${senderShip} [${senderRole}] in ${channelNest}`
|
||||
: `${senderShip} [${senderRole}]`;
|
||||
}
|
||||
|
||||
it("DM from owner includes [owner] in label", () => {
|
||||
const label = getFromLabel("~nocsyx-lassul", "~nocsyx-lassul", false);
|
||||
expect(label).toBe("~nocsyx-lassul [owner]");
|
||||
expect(label).toContain("[owner]");
|
||||
});
|
||||
|
||||
it("DM from user includes [user] in label", () => {
|
||||
const label = getFromLabel("~random-user", "~nocsyx-lassul", false);
|
||||
expect(label).toBe("~random-user [user]");
|
||||
expect(label).toContain("[user]");
|
||||
});
|
||||
|
||||
it("group message from owner includes [owner] in label", () => {
|
||||
const label = getFromLabel("~nocsyx-lassul", "~nocsyx-lassul", true, "chat/~host/general");
|
||||
expect(label).toBe("~nocsyx-lassul [owner] in chat/~host/general");
|
||||
expect(label).toContain("[owner]");
|
||||
});
|
||||
|
||||
it("group message from user includes [user] in label", () => {
|
||||
const label = getFromLabel("~random-user", "~nocsyx-lassul", true, "chat/~host/general");
|
||||
expect(label).toBe("~random-user [user] in chat/~host/general");
|
||||
expect(label).toContain("[user]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("impersonation prevention", () => {
|
||||
it("approved user cannot get [owner] label through ship name tricks", () => {
|
||||
// Even if someone has a ship name similar to owner, they should not get owner role
|
||||
expect(getSenderRole("~nocsyx-lassul-fake", "~nocsyx-lassul")).toBe("user");
|
||||
expect(getSenderRole("~fake-nocsyx-lassul", "~nocsyx-lassul")).toBe("user");
|
||||
});
|
||||
|
||||
it("message content cannot change sender role", () => {
|
||||
// The role is determined by ship identity, not message content
|
||||
// This test documents that even if message contains "I am the owner",
|
||||
// the actual senderShip determines the role
|
||||
const senderShip = "~malicious-actor";
|
||||
const ownerShip = "~nocsyx-lassul";
|
||||
|
||||
// The role is always based on ship comparison, not message content
|
||||
expect(getSenderRole(senderShip, ownerShip)).toBe("user");
|
||||
});
|
||||
});
|
||||
});
|
||||
391
extensions/tlon/src/settings.ts
Normal file
391
extensions/tlon/src/settings.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* Settings Store integration for hot-reloading Tlon plugin config.
|
||||
*
|
||||
* Settings are stored in Urbit's %settings agent under:
|
||||
* desk: "moltbot"
|
||||
* bucket: "tlon"
|
||||
*
|
||||
* This allows config changes via poke from any Landscape client
|
||||
* without requiring a gateway restart.
|
||||
*/
|
||||
|
||||
import type { UrbitSSEClient } from "./urbit/sse-client.js";
|
||||
|
||||
/** Pending approval request stored for persistence */
|
||||
export type PendingApproval = {
|
||||
id: string;
|
||||
type: "dm" | "channel" | "group";
|
||||
requestingShip: string;
|
||||
channelNest?: string;
|
||||
groupFlag?: string;
|
||||
messagePreview?: string;
|
||||
/** Full message context for processing after approval */
|
||||
originalMessage?: {
|
||||
messageId: string;
|
||||
messageText: string;
|
||||
messageContent: unknown;
|
||||
timestamp: number;
|
||||
parentId?: string;
|
||||
isThreadReply?: boolean;
|
||||
};
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export type TlonSettingsStore = {
|
||||
groupChannels?: string[];
|
||||
dmAllowlist?: string[];
|
||||
autoDiscover?: boolean;
|
||||
showModelSig?: boolean;
|
||||
autoAcceptDmInvites?: boolean;
|
||||
autoDiscoverChannels?: boolean;
|
||||
autoAcceptGroupInvites?: boolean;
|
||||
/** Ships allowed to invite us to groups (when autoAcceptGroupInvites is true) */
|
||||
groupInviteAllowlist?: string[];
|
||||
channelRules?: Record<
|
||||
string,
|
||||
{
|
||||
mode?: "restricted" | "open";
|
||||
allowedShips?: string[];
|
||||
}
|
||||
>;
|
||||
defaultAuthorizedShips?: string[];
|
||||
/** Ship that receives approval requests for DMs, channel mentions, and group invites */
|
||||
ownerShip?: string;
|
||||
/** Pending approval requests awaiting owner response */
|
||||
pendingApprovals?: PendingApproval[];
|
||||
};
|
||||
|
||||
export type TlonSettingsState = {
|
||||
current: TlonSettingsStore;
|
||||
loaded: boolean;
|
||||
};
|
||||
|
||||
const SETTINGS_DESK = "moltbot";
|
||||
const SETTINGS_BUCKET = "tlon";
|
||||
|
||||
/**
|
||||
* Parse channelRules - handles both JSON string and object formats.
|
||||
* Settings-store doesn't support nested objects, so we store as JSON string.
|
||||
*/
|
||||
function parseChannelRules(
|
||||
value: unknown,
|
||||
): Record<string, { mode?: "restricted" | "open"; allowedShips?: string[] }> | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// If it's a string, try to parse as JSON
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (isChannelRulesObject(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// If it's already an object, use directly
|
||||
if (isChannelRulesObject(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse settings from the raw Urbit settings-store response.
|
||||
* The response shape is: { [bucket]: { [key]: value } }
|
||||
*/
|
||||
function parseSettingsResponse(raw: unknown): TlonSettingsStore {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return {};
|
||||
}
|
||||
|
||||
const desk = raw as Record<string, unknown>;
|
||||
const bucket = desk[SETTINGS_BUCKET];
|
||||
if (!bucket || typeof bucket !== "object") {
|
||||
return {};
|
||||
}
|
||||
|
||||
const settings = bucket as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
groupChannels: Array.isArray(settings.groupChannels)
|
||||
? settings.groupChannels.filter((x): x is string => typeof x === "string")
|
||||
: undefined,
|
||||
dmAllowlist: Array.isArray(settings.dmAllowlist)
|
||||
? settings.dmAllowlist.filter((x): x is string => typeof x === "string")
|
||||
: undefined,
|
||||
autoDiscover: typeof settings.autoDiscover === "boolean" ? settings.autoDiscover : undefined,
|
||||
showModelSig: typeof settings.showModelSig === "boolean" ? settings.showModelSig : undefined,
|
||||
autoAcceptDmInvites:
|
||||
typeof settings.autoAcceptDmInvites === "boolean" ? settings.autoAcceptDmInvites : undefined,
|
||||
autoAcceptGroupInvites:
|
||||
typeof settings.autoAcceptGroupInvites === "boolean"
|
||||
? settings.autoAcceptGroupInvites
|
||||
: undefined,
|
||||
groupInviteAllowlist: Array.isArray(settings.groupInviteAllowlist)
|
||||
? settings.groupInviteAllowlist.filter((x): x is string => typeof x === "string")
|
||||
: undefined,
|
||||
channelRules: parseChannelRules(settings.channelRules),
|
||||
defaultAuthorizedShips: Array.isArray(settings.defaultAuthorizedShips)
|
||||
? settings.defaultAuthorizedShips.filter((x): x is string => typeof x === "string")
|
||||
: undefined,
|
||||
ownerShip: typeof settings.ownerShip === "string" ? settings.ownerShip : undefined,
|
||||
pendingApprovals: parsePendingApprovals(settings.pendingApprovals),
|
||||
};
|
||||
}
|
||||
|
||||
function isChannelRulesObject(
|
||||
val: unknown,
|
||||
): val is Record<string, { mode?: "restricted" | "open"; allowedShips?: string[] }> {
|
||||
if (!val || typeof val !== "object" || Array.isArray(val)) {
|
||||
return false;
|
||||
}
|
||||
for (const [, rule] of Object.entries(val)) {
|
||||
if (!rule || typeof rule !== "object") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse pendingApprovals - handles both JSON string and array formats.
|
||||
* Settings-store stores complex objects as JSON strings.
|
||||
*/
|
||||
function parsePendingApprovals(value: unknown): PendingApproval[] | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// If it's a string, try to parse as JSON
|
||||
let parsed: unknown = value;
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate it's an array
|
||||
if (!Array.isArray(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Filter to valid PendingApproval objects
|
||||
return parsed.filter((item): item is PendingApproval => {
|
||||
if (!item || typeof item !== "object") {
|
||||
return false;
|
||||
}
|
||||
const obj = item as Record<string, unknown>;
|
||||
return (
|
||||
typeof obj.id === "string" &&
|
||||
(obj.type === "dm" || obj.type === "channel" || obj.type === "group") &&
|
||||
typeof obj.requestingShip === "string" &&
|
||||
typeof obj.timestamp === "number"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single settings entry update event.
|
||||
*/
|
||||
function parseSettingsEvent(event: unknown): { key: string; value: unknown } | null {
|
||||
if (!event || typeof event !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const evt = event as Record<string, unknown>;
|
||||
|
||||
// Handle put-entry events
|
||||
if (evt["put-entry"]) {
|
||||
const put = evt["put-entry"] as Record<string, unknown>;
|
||||
if (put.desk !== SETTINGS_DESK || put["bucket-key"] !== SETTINGS_BUCKET) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
key: String(put["entry-key"] ?? ""),
|
||||
value: put.value,
|
||||
};
|
||||
}
|
||||
|
||||
// Handle del-entry events
|
||||
if (evt["del-entry"]) {
|
||||
const del = evt["del-entry"] as Record<string, unknown>;
|
||||
if (del.desk !== SETTINGS_DESK || del["bucket-key"] !== SETTINGS_BUCKET) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
key: String(del["entry-key"] ?? ""),
|
||||
value: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a single settings update to the current state.
|
||||
*/
|
||||
function applySettingsUpdate(
|
||||
current: TlonSettingsStore,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): TlonSettingsStore {
|
||||
const next = { ...current };
|
||||
|
||||
switch (key) {
|
||||
case "groupChannels":
|
||||
next.groupChannels = Array.isArray(value)
|
||||
? value.filter((x): x is string => typeof x === "string")
|
||||
: undefined;
|
||||
break;
|
||||
case "dmAllowlist":
|
||||
next.dmAllowlist = Array.isArray(value)
|
||||
? value.filter((x): x is string => typeof x === "string")
|
||||
: undefined;
|
||||
break;
|
||||
case "autoDiscover":
|
||||
next.autoDiscover = typeof value === "boolean" ? value : undefined;
|
||||
break;
|
||||
case "showModelSig":
|
||||
next.showModelSig = typeof value === "boolean" ? value : undefined;
|
||||
break;
|
||||
case "autoAcceptDmInvites":
|
||||
next.autoAcceptDmInvites = typeof value === "boolean" ? value : undefined;
|
||||
break;
|
||||
case "autoAcceptGroupInvites":
|
||||
next.autoAcceptGroupInvites = typeof value === "boolean" ? value : undefined;
|
||||
break;
|
||||
case "groupInviteAllowlist":
|
||||
next.groupInviteAllowlist = Array.isArray(value)
|
||||
? value.filter((x): x is string => typeof x === "string")
|
||||
: undefined;
|
||||
break;
|
||||
case "channelRules":
|
||||
next.channelRules = parseChannelRules(value);
|
||||
break;
|
||||
case "defaultAuthorizedShips":
|
||||
next.defaultAuthorizedShips = Array.isArray(value)
|
||||
? value.filter((x): x is string => typeof x === "string")
|
||||
: undefined;
|
||||
break;
|
||||
case "ownerShip":
|
||||
next.ownerShip = typeof value === "string" ? value : undefined;
|
||||
break;
|
||||
case "pendingApprovals":
|
||||
next.pendingApprovals = parsePendingApprovals(value);
|
||||
break;
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export type SettingsLogger = {
|
||||
log?: (msg: string) => void;
|
||||
error?: (msg: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a settings store subscription manager.
|
||||
*
|
||||
* Usage:
|
||||
* const settings = createSettingsManager(api, logger);
|
||||
* await settings.load();
|
||||
* settings.subscribe((newSettings) => { ... });
|
||||
*/
|
||||
export function createSettingsManager(api: UrbitSSEClient, logger?: SettingsLogger) {
|
||||
let state: TlonSettingsState = {
|
||||
current: {},
|
||||
loaded: false,
|
||||
};
|
||||
|
||||
const listeners = new Set<(settings: TlonSettingsStore) => void>();
|
||||
|
||||
const notify = () => {
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener(state.current);
|
||||
} catch (err) {
|
||||
logger?.error?.(`[settings] Listener error: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
/**
|
||||
* Get current settings (may be empty if not loaded yet).
|
||||
*/
|
||||
get current(): TlonSettingsStore {
|
||||
return state.current;
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether initial settings have been loaded.
|
||||
*/
|
||||
get loaded(): boolean {
|
||||
return state.loaded;
|
||||
},
|
||||
|
||||
/**
|
||||
* Load initial settings via scry.
|
||||
*/
|
||||
async load(): Promise<TlonSettingsStore> {
|
||||
try {
|
||||
const raw = await api.scry("/settings/all.json");
|
||||
// Response shape: { all: { [desk]: { [bucket]: { [key]: value } } } }
|
||||
const allData = raw as { all?: Record<string, Record<string, unknown>> };
|
||||
const deskData = allData?.all?.[SETTINGS_DESK];
|
||||
state.current = parseSettingsResponse(deskData ?? {});
|
||||
state.loaded = true;
|
||||
logger?.log?.(`[settings] Loaded: ${JSON.stringify(state.current)}`);
|
||||
return state.current;
|
||||
} catch (err) {
|
||||
// Settings desk may not exist yet - that's fine, use defaults
|
||||
logger?.log?.(`[settings] No settings found (using defaults): ${String(err)}`);
|
||||
state.current = {};
|
||||
state.loaded = true;
|
||||
return state.current;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Subscribe to settings changes.
|
||||
*/
|
||||
async startSubscription(): Promise<void> {
|
||||
await api.subscribe({
|
||||
app: "settings",
|
||||
path: "/desk/" + SETTINGS_DESK,
|
||||
event: (event) => {
|
||||
const update = parseSettingsEvent(event);
|
||||
if (!update) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger?.log?.(`[settings] Update: ${update.key} = ${JSON.stringify(update.value)}`);
|
||||
state.current = applySettingsUpdate(state.current, update.key, update.value);
|
||||
notify();
|
||||
},
|
||||
err: (error) => {
|
||||
logger?.error?.(`[settings] Subscription error: ${String(error)}`);
|
||||
},
|
||||
quit: () => {
|
||||
logger?.log?.("[settings] Subscription ended");
|
||||
},
|
||||
});
|
||||
logger?.log?.("[settings] Subscribed to settings updates");
|
||||
},
|
||||
|
||||
/**
|
||||
* Register a listener for settings changes.
|
||||
*/
|
||||
onChange(listener: (settings: TlonSettingsStore) => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
export type TlonTarget =
|
||||
| { kind: "direct"; ship: string }
|
||||
| { kind: "dm"; ship: string }
|
||||
| { kind: "group"; nest: string; hostShip: string; channelName: string };
|
||||
|
||||
const SHIP_RE = /^~?[a-z-]+$/i;
|
||||
@@ -32,7 +32,7 @@ export function parseTlonTarget(raw?: string | null): TlonTarget | null {
|
||||
|
||||
const dmPrefix = withoutPrefix.match(/^dm[/:](.+)$/i);
|
||||
if (dmPrefix) {
|
||||
return { kind: "direct", ship: normalizeShip(dmPrefix[1]) };
|
||||
return { kind: "dm", ship: normalizeShip(dmPrefix[1]) };
|
||||
}
|
||||
|
||||
const groupPrefix = withoutPrefix.match(/^(group|room)[/:](.+)$/i);
|
||||
@@ -78,7 +78,7 @@ export function parseTlonTarget(raw?: string | null): TlonTarget | null {
|
||||
}
|
||||
|
||||
if (SHIP_RE.test(withoutPrefix)) {
|
||||
return { kind: "direct", ship: normalizeShip(withoutPrefix) };
|
||||
return { kind: "dm", ship: normalizeShip(withoutPrefix) };
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -11,8 +11,15 @@ export type TlonResolvedAccount = {
|
||||
allowPrivateNetwork: boolean | null;
|
||||
groupChannels: string[];
|
||||
dmAllowlist: string[];
|
||||
/** Ships allowed to invite us to groups (security: prevent malicious group invites) */
|
||||
groupInviteAllowlist: string[];
|
||||
autoDiscoverChannels: boolean | null;
|
||||
showModelSignature: boolean | null;
|
||||
autoAcceptDmInvites: boolean | null;
|
||||
autoAcceptGroupInvites: boolean | null;
|
||||
defaultAuthorizedShips: string[];
|
||||
/** Ship that receives approval requests for DMs, channel mentions, and group invites */
|
||||
ownerShip: string | null;
|
||||
};
|
||||
|
||||
export function resolveTlonAccount(
|
||||
@@ -29,8 +36,12 @@ export function resolveTlonAccount(
|
||||
allowPrivateNetwork?: boolean;
|
||||
groupChannels?: string[];
|
||||
dmAllowlist?: string[];
|
||||
groupInviteAllowlist?: string[];
|
||||
autoDiscoverChannels?: boolean;
|
||||
showModelSignature?: boolean;
|
||||
autoAcceptDmInvites?: boolean;
|
||||
autoAcceptGroupInvites?: boolean;
|
||||
ownerShip?: string;
|
||||
accounts?: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
| undefined;
|
||||
@@ -47,8 +58,13 @@ export function resolveTlonAccount(
|
||||
allowPrivateNetwork: null,
|
||||
groupChannels: [],
|
||||
dmAllowlist: [],
|
||||
groupInviteAllowlist: [],
|
||||
autoDiscoverChannels: null,
|
||||
showModelSignature: null,
|
||||
autoAcceptDmInvites: null,
|
||||
autoAcceptGroupInvites: null,
|
||||
defaultAuthorizedShips: [],
|
||||
ownerShip: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,12 +79,25 @@ export function resolveTlonAccount(
|
||||
| null;
|
||||
const groupChannels = (account?.groupChannels ?? base.groupChannels ?? []) as string[];
|
||||
const dmAllowlist = (account?.dmAllowlist ?? base.dmAllowlist ?? []) as string[];
|
||||
const groupInviteAllowlist = (account?.groupInviteAllowlist ??
|
||||
base.groupInviteAllowlist ??
|
||||
[]) as string[];
|
||||
const autoDiscoverChannels = (account?.autoDiscoverChannels ??
|
||||
base.autoDiscoverChannels ??
|
||||
null) as boolean | null;
|
||||
const showModelSignature = (account?.showModelSignature ?? base.showModelSignature ?? null) as
|
||||
| boolean
|
||||
| null;
|
||||
const autoAcceptDmInvites = (account?.autoAcceptDmInvites ?? base.autoAcceptDmInvites ?? null) as
|
||||
| boolean
|
||||
| null;
|
||||
const autoAcceptGroupInvites = (account?.autoAcceptGroupInvites ??
|
||||
base.autoAcceptGroupInvites ??
|
||||
null) as boolean | null;
|
||||
const ownerShip = (account?.ownerShip ?? base.ownerShip ?? null) as string | null;
|
||||
const defaultAuthorizedShips = ((account as Record<string, unknown>)?.defaultAuthorizedShips ??
|
||||
(base as Record<string, unknown>)?.defaultAuthorizedShips ??
|
||||
[]) as string[];
|
||||
const configured = Boolean(ship && url && code);
|
||||
|
||||
return {
|
||||
@@ -82,8 +111,13 @@ export function resolveTlonAccount(
|
||||
allowPrivateNetwork,
|
||||
groupChannels,
|
||||
dmAllowlist,
|
||||
groupInviteAllowlist,
|
||||
autoDiscoverChannels,
|
||||
showModelSignature,
|
||||
autoAcceptDmInvites,
|
||||
autoAcceptGroupInvites,
|
||||
defaultAuthorizedShips,
|
||||
ownerShip,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk";
|
||||
import { ensureUrbitChannelOpen, pokeUrbitChannel, scryUrbitPath } from "./channel-ops.js";
|
||||
import { getUrbitContext, normalizeUrbitCookie } from "./context.js";
|
||||
import { urbitFetch } from "./fetch.js";
|
||||
|
||||
export type UrbitChannelClientOptions = {
|
||||
ship?: string;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
lookupFn?: LookupFn;
|
||||
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
};
|
||||
|
||||
export class UrbitChannelClient {
|
||||
readonly baseUrl: string;
|
||||
readonly cookie: string;
|
||||
readonly ship: string;
|
||||
readonly ssrfPolicy?: SsrFPolicy;
|
||||
readonly lookupFn?: LookupFn;
|
||||
readonly fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
private channelId: string | null = null;
|
||||
|
||||
constructor(url: string, cookie: string, options: UrbitChannelClientOptions = {}) {
|
||||
const ctx = getUrbitContext(url, options.ship);
|
||||
this.baseUrl = ctx.baseUrl;
|
||||
this.cookie = normalizeUrbitCookie(cookie);
|
||||
this.ship = ctx.ship;
|
||||
this.ssrfPolicy = options.ssrfPolicy;
|
||||
this.lookupFn = options.lookupFn;
|
||||
this.fetchImpl = options.fetchImpl;
|
||||
}
|
||||
|
||||
private get channelPath(): string {
|
||||
const id = this.channelId;
|
||||
if (!id) {
|
||||
throw new Error("Channel not opened");
|
||||
}
|
||||
return `/~/channel/${id}`;
|
||||
}
|
||||
|
||||
async open(): Promise<void> {
|
||||
if (this.channelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const channelId = `${Math.floor(Date.now() / 1000)}-${randomUUID()}`;
|
||||
this.channelId = channelId;
|
||||
|
||||
try {
|
||||
await ensureUrbitChannelOpen(
|
||||
{
|
||||
baseUrl: this.baseUrl,
|
||||
cookie: this.cookie,
|
||||
ship: this.ship,
|
||||
channelId,
|
||||
ssrfPolicy: this.ssrfPolicy,
|
||||
lookupFn: this.lookupFn,
|
||||
fetchImpl: this.fetchImpl,
|
||||
},
|
||||
{
|
||||
createBody: [],
|
||||
createAuditContext: "tlon-urbit-channel-open",
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
this.channelId = null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async poke(params: { app: string; mark: string; json: unknown }): Promise<number> {
|
||||
await this.open();
|
||||
const channelId = this.channelId;
|
||||
if (!channelId) {
|
||||
throw new Error("Channel not opened");
|
||||
}
|
||||
return await pokeUrbitChannel(
|
||||
{
|
||||
baseUrl: this.baseUrl,
|
||||
cookie: this.cookie,
|
||||
ship: this.ship,
|
||||
channelId,
|
||||
ssrfPolicy: this.ssrfPolicy,
|
||||
lookupFn: this.lookupFn,
|
||||
fetchImpl: this.fetchImpl,
|
||||
},
|
||||
{ ...params, auditContext: "tlon-urbit-poke" },
|
||||
);
|
||||
}
|
||||
|
||||
async scry(path: string): Promise<unknown> {
|
||||
return await scryUrbitPath(
|
||||
{
|
||||
baseUrl: this.baseUrl,
|
||||
cookie: this.cookie,
|
||||
ssrfPolicy: this.ssrfPolicy,
|
||||
lookupFn: this.lookupFn,
|
||||
fetchImpl: this.fetchImpl,
|
||||
},
|
||||
{ path, auditContext: "tlon-urbit-scry" },
|
||||
);
|
||||
}
|
||||
|
||||
async getOurName(): Promise<string> {
|
||||
const { response, release } = await urbitFetch({
|
||||
baseUrl: this.baseUrl,
|
||||
path: "/~/name",
|
||||
init: {
|
||||
method: "GET",
|
||||
headers: { Cookie: this.cookie },
|
||||
},
|
||||
ssrfPolicy: this.ssrfPolicy,
|
||||
lookupFn: this.lookupFn,
|
||||
fetchImpl: this.fetchImpl,
|
||||
timeoutMs: 30_000,
|
||||
auditContext: "tlon-urbit-name",
|
||||
});
|
||||
|
||||
try {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Name request failed: ${response.status}`);
|
||||
}
|
||||
const text = await response.text();
|
||||
return text.trim();
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (!this.channelId) {
|
||||
return;
|
||||
}
|
||||
const channelPath = this.channelPath;
|
||||
this.channelId = null;
|
||||
|
||||
try {
|
||||
const { response, release } = await urbitFetch({
|
||||
baseUrl: this.baseUrl,
|
||||
path: channelPath,
|
||||
init: { method: "DELETE", headers: { Cookie: this.cookie } },
|
||||
ssrfPolicy: this.ssrfPolicy,
|
||||
lookupFn: this.lookupFn,
|
||||
fetchImpl: this.fetchImpl,
|
||||
timeoutMs: 30_000,
|
||||
auditContext: "tlon-urbit-channel-close",
|
||||
});
|
||||
try {
|
||||
void response.body?.cancel();
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,3 +45,12 @@ export function ssrfPolicyFromAllowPrivateNetwork(
|
||||
): SsrFPolicy | undefined {
|
||||
return allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default SSRF policy for image uploads.
|
||||
* Uses a restrictive policy that blocks private networks by default.
|
||||
*/
|
||||
export function getDefaultSsrFPolicy(): SsrFPolicy | undefined {
|
||||
// Default: block private networks for image uploads (safer default)
|
||||
return undefined;
|
||||
}
|
||||
|
||||
49
extensions/tlon/src/urbit/foreigns.ts
Normal file
49
extensions/tlon/src/urbit/foreigns.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Types for Urbit groups foreigns (group invites)
|
||||
* Based on packages/shared/src/urbit/groups.ts from homestead
|
||||
*/
|
||||
|
||||
export interface GroupPreviewV7 {
|
||||
meta: {
|
||||
title: string;
|
||||
description: string;
|
||||
image: string;
|
||||
cover: string;
|
||||
};
|
||||
"channel-count": number;
|
||||
"member-count": number;
|
||||
admissions: {
|
||||
privacy: "public" | "private" | "secret";
|
||||
};
|
||||
}
|
||||
|
||||
export interface ForeignInvite {
|
||||
flag: string; // group flag e.g. "~host/group-name"
|
||||
time: number; // timestamp
|
||||
from: string; // ship that sent invite
|
||||
token: string | null;
|
||||
note: string | null;
|
||||
preview: GroupPreviewV7;
|
||||
valid: boolean; // tracks if invite has been revoked
|
||||
}
|
||||
|
||||
export type Lookup = "preview" | "done" | "error";
|
||||
export type Progress = "ask" | "join" | "watch" | "done" | "error";
|
||||
|
||||
export interface Foreign {
|
||||
invites: ForeignInvite[];
|
||||
lookup: Lookup | null;
|
||||
preview: GroupPreviewV7 | null;
|
||||
progress: Progress | null;
|
||||
token: string | null;
|
||||
}
|
||||
|
||||
export interface Foreigns {
|
||||
[flag: string]: Foreign;
|
||||
}
|
||||
|
||||
// DM invite structure from chat /v3 firehose
|
||||
export interface DmInvite {
|
||||
ship: string;
|
||||
// Additional fields may be present
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { scot, da } from "@urbit/aura";
|
||||
import { markdownToStory, createImageBlock, isImageUrl, type Story } from "./story.js";
|
||||
|
||||
export type TlonPokeApi = {
|
||||
poke: (params: { app: string; mark: string; json: unknown }) => Promise<unknown>;
|
||||
@@ -11,8 +12,19 @@ type SendTextParams = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
type SendStoryParams = {
|
||||
api: TlonPokeApi;
|
||||
fromShip: string;
|
||||
toShip: string;
|
||||
story: Story;
|
||||
};
|
||||
|
||||
export async function sendDm({ api, fromShip, toShip, text }: SendTextParams) {
|
||||
const story = [{ inline: [text] }];
|
||||
const story: Story = markdownToStory(text);
|
||||
return sendDmWithStory({ api, fromShip, toShip, story });
|
||||
}
|
||||
|
||||
export async function sendDmWithStory({ api, fromShip, toShip, story }: SendStoryParams) {
|
||||
const sentAt = Date.now();
|
||||
const idUd = scot("ud", da.fromUnix(sentAt));
|
||||
const id = `${fromShip}/${idUd}`;
|
||||
@@ -52,6 +64,15 @@ type SendGroupParams = {
|
||||
replyToId?: string | null;
|
||||
};
|
||||
|
||||
type SendGroupStoryParams = {
|
||||
api: TlonPokeApi;
|
||||
fromShip: string;
|
||||
hostShip: string;
|
||||
channelName: string;
|
||||
story: Story;
|
||||
replyToId?: string | null;
|
||||
};
|
||||
|
||||
export async function sendGroupMessage({
|
||||
api,
|
||||
fromShip,
|
||||
@@ -60,13 +81,25 @@ export async function sendGroupMessage({
|
||||
text,
|
||||
replyToId,
|
||||
}: SendGroupParams) {
|
||||
const story = [{ inline: [text] }];
|
||||
const story: Story = markdownToStory(text);
|
||||
return sendGroupMessageWithStory({ api, fromShip, hostShip, channelName, story, replyToId });
|
||||
}
|
||||
|
||||
export async function sendGroupMessageWithStory({
|
||||
api,
|
||||
fromShip,
|
||||
hostShip,
|
||||
channelName,
|
||||
story,
|
||||
replyToId,
|
||||
}: SendGroupStoryParams) {
|
||||
const sentAt = Date.now();
|
||||
|
||||
// Format reply ID as @ud (with dots) - required for Tlon to recognize thread replies
|
||||
let formattedReplyId = replyToId;
|
||||
if (replyToId && /^\d+$/.test(replyToId)) {
|
||||
try {
|
||||
// scot('ud', n) formats a number as @ud with dots
|
||||
formattedReplyId = scot("ud", BigInt(replyToId));
|
||||
} catch {
|
||||
// Fall back to raw ID if formatting fails
|
||||
@@ -129,3 +162,27 @@ export function buildMediaText(text: string | undefined, mediaUrl: string | unde
|
||||
}
|
||||
return cleanText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a story with text and optional media (image)
|
||||
*/
|
||||
export function buildMediaStory(text: string | undefined, mediaUrl: string | undefined): Story {
|
||||
const story: Story = [];
|
||||
const cleanText = text?.trim() ?? "";
|
||||
const cleanUrl = mediaUrl?.trim() ?? "";
|
||||
|
||||
// Add text content if present
|
||||
if (cleanText) {
|
||||
story.push(...markdownToStory(cleanText));
|
||||
}
|
||||
|
||||
// Add image block if URL looks like an image
|
||||
if (cleanUrl && isImageUrl(cleanUrl)) {
|
||||
story.push(createImageBlock(cleanUrl, ""));
|
||||
} else if (cleanUrl) {
|
||||
// For non-image URLs, add as a link
|
||||
story.push({ inline: [{ link: { href: cleanUrl, content: cleanUrl } }] });
|
||||
}
|
||||
|
||||
return story.length > 0 ? story : [{ inline: [""] }];
|
||||
}
|
||||
|
||||
@@ -1,44 +1,205 @@
|
||||
import type { LookupFn } from "openclaw/plugin-sdk";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { UrbitSSEClient } from "./sse-client.js";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
// Mock urbitFetch to avoid real network calls
|
||||
vi.mock("./fetch.js", () => ({
|
||||
urbitFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock channel-ops to avoid real channel operations
|
||||
vi.mock("./channel-ops.js", () => ({
|
||||
ensureUrbitChannelOpen: vi.fn().mockResolvedValue(undefined),
|
||||
pokeUrbitChannel: vi.fn().mockResolvedValue(undefined),
|
||||
scryUrbitPath: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
describe("UrbitSSEClient", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
mockFetch.mockReset();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("sends subscriptions added after connect", async () => {
|
||||
mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => "" });
|
||||
const lookupFn = (async () => [{ address: "1.1.1.1", family: 4 }]) as unknown as LookupFn;
|
||||
describe("subscribe", () => {
|
||||
it("sends subscriptions added after connect", async () => {
|
||||
const { urbitFetch } = await import("./fetch.js");
|
||||
const mockUrbitFetch = vi.mocked(urbitFetch);
|
||||
mockUrbitFetch.mockResolvedValue({
|
||||
response: { ok: true, status: 200 } as unknown as Response,
|
||||
finalUrl: "https://example.com",
|
||||
release: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123", {
|
||||
lookupFn,
|
||||
});
|
||||
(client as { isConnected: boolean }).isConnected = true;
|
||||
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
|
||||
// Simulate connected state
|
||||
(client as { isConnected: boolean }).isConnected = true;
|
||||
|
||||
await client.subscribe({
|
||||
app: "chat",
|
||||
path: "/dm/~zod",
|
||||
event: () => {},
|
||||
await client.subscribe({
|
||||
app: "chat",
|
||||
path: "/dm/~zod",
|
||||
event: () => {},
|
||||
});
|
||||
|
||||
expect(mockUrbitFetch).toHaveBeenCalledTimes(1);
|
||||
const callArgs = mockUrbitFetch.mock.calls[0][0];
|
||||
expect(callArgs.path).toContain("/~/channel/");
|
||||
expect(callArgs.init?.method).toBe("PUT");
|
||||
|
||||
const body = JSON.parse(callArgs.init?.body as string);
|
||||
expect(body).toHaveLength(1);
|
||||
expect(body[0]).toMatchObject({
|
||||
action: "subscribe",
|
||||
app: "chat",
|
||||
path: "/dm/~zod",
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe(client.channelUrl);
|
||||
expect(init.method).toBe("PUT");
|
||||
const body = JSON.parse(init.body as string);
|
||||
expect(body).toHaveLength(1);
|
||||
expect(body[0]).toMatchObject({
|
||||
action: "subscribe",
|
||||
app: "chat",
|
||||
path: "/dm/~zod",
|
||||
it("queues subscriptions before connect", async () => {
|
||||
const { urbitFetch } = await import("./fetch.js");
|
||||
const mockUrbitFetch = vi.mocked(urbitFetch);
|
||||
|
||||
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
|
||||
// Not connected yet
|
||||
|
||||
await client.subscribe({
|
||||
app: "chat",
|
||||
path: "/dm/~zod",
|
||||
event: () => {},
|
||||
});
|
||||
|
||||
// Should not call urbitFetch since not connected
|
||||
expect(mockUrbitFetch).not.toHaveBeenCalled();
|
||||
// But subscription should be queued
|
||||
expect(client.subscriptions).toHaveLength(1);
|
||||
expect(client.subscriptions[0]).toMatchObject({
|
||||
app: "chat",
|
||||
path: "/dm/~zod",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateCookie", () => {
|
||||
it("normalizes cookie when updating", () => {
|
||||
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
|
||||
|
||||
// Cookie with extra parts that should be stripped
|
||||
client.updateCookie("urbauth-~zod=456; Path=/; HttpOnly");
|
||||
|
||||
expect(client.cookie).toBe("urbauth-~zod=456");
|
||||
});
|
||||
|
||||
it("handles simple cookie values", () => {
|
||||
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
|
||||
|
||||
client.updateCookie("urbauth-~zod=newvalue");
|
||||
|
||||
expect(client.cookie).toBe("urbauth-~zod=newvalue");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconnection", () => {
|
||||
it("has autoReconnect enabled by default", () => {
|
||||
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
|
||||
expect(client.autoReconnect).toBe(true);
|
||||
});
|
||||
|
||||
it("can disable autoReconnect via options", () => {
|
||||
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123", {
|
||||
autoReconnect: false,
|
||||
});
|
||||
expect(client.autoReconnect).toBe(false);
|
||||
});
|
||||
|
||||
it("stores onReconnect callback", () => {
|
||||
const onReconnect = vi.fn();
|
||||
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123", {
|
||||
onReconnect,
|
||||
});
|
||||
expect(client.onReconnect).toBe(onReconnect);
|
||||
});
|
||||
|
||||
it("resets reconnect attempts on successful connect", async () => {
|
||||
const { urbitFetch } = await import("./fetch.js");
|
||||
const mockUrbitFetch = vi.mocked(urbitFetch);
|
||||
|
||||
// Mock a response that returns a readable stream
|
||||
const mockStream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
mockUrbitFetch.mockResolvedValue({
|
||||
response: {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: mockStream,
|
||||
} as unknown as Response,
|
||||
finalUrl: "https://example.com",
|
||||
release: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123", {
|
||||
autoReconnect: false, // Disable to prevent reconnect loop
|
||||
});
|
||||
client.reconnectAttempts = 5;
|
||||
|
||||
await client.connect();
|
||||
|
||||
expect(client.reconnectAttempts).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("event acking", () => {
|
||||
it("tracks lastHeardEventId and ackThreshold", () => {
|
||||
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
|
||||
|
||||
// Access private properties for testing
|
||||
const lastHeardEventId = (client as unknown as { lastHeardEventId: number }).lastHeardEventId;
|
||||
const ackThreshold = (client as unknown as { ackThreshold: number }).ackThreshold;
|
||||
|
||||
expect(lastHeardEventId).toBe(-1);
|
||||
expect(ackThreshold).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("constructor", () => {
|
||||
it("generates unique channel ID", () => {
|
||||
const client1 = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
|
||||
const client2 = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
|
||||
|
||||
expect(client1.channelId).not.toBe(client2.channelId);
|
||||
});
|
||||
|
||||
it("normalizes cookie in constructor", () => {
|
||||
const client = new UrbitSSEClient(
|
||||
"https://example.com",
|
||||
"urbauth-~zod=123; Path=/; HttpOnly",
|
||||
);
|
||||
|
||||
expect(client.cookie).toBe("urbauth-~zod=123");
|
||||
});
|
||||
|
||||
it("sets default reconnection parameters", () => {
|
||||
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123");
|
||||
|
||||
expect(client.maxReconnectAttempts).toBe(10);
|
||||
expect(client.reconnectDelay).toBe(1000);
|
||||
expect(client.maxReconnectDelay).toBe(30000);
|
||||
});
|
||||
|
||||
it("allows overriding reconnection parameters", () => {
|
||||
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123", {
|
||||
maxReconnectAttempts: 5,
|
||||
reconnectDelay: 500,
|
||||
maxReconnectDelay: 10000,
|
||||
});
|
||||
|
||||
expect(client.maxReconnectAttempts).toBe(5);
|
||||
expect(client.reconnectDelay).toBe(500);
|
||||
expect(client.maxReconnectDelay).toBe(10000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,6 +55,11 @@ export class UrbitSSEClient {
|
||||
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
streamRelease: (() => Promise<void>) | null = null;
|
||||
|
||||
// Event ack tracking - must ack every ~50 events to keep channel healthy
|
||||
private lastHeardEventId = -1;
|
||||
private lastAcknowledgedEventId = -1;
|
||||
private readonly ackThreshold = 20;
|
||||
|
||||
constructor(url: string, cookie: string, options: UrbitSseOptions = {}) {
|
||||
const ctx = getUrbitContext(url, options.ship);
|
||||
this.url = ctx.baseUrl;
|
||||
@@ -249,8 +254,12 @@ export class UrbitSSEClient {
|
||||
processEvent(eventData: string) {
|
||||
const lines = eventData.split("\n");
|
||||
let data: string | null = null;
|
||||
let eventId: number | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("id: ")) {
|
||||
eventId = parseInt(line.substring(4), 10);
|
||||
}
|
||||
if (line.startsWith("data: ")) {
|
||||
data = line.substring(6);
|
||||
}
|
||||
@@ -260,6 +269,21 @@ export class UrbitSSEClient {
|
||||
return;
|
||||
}
|
||||
|
||||
// Track event ID and send ack if needed
|
||||
if (eventId !== null && !isNaN(eventId)) {
|
||||
if (eventId > this.lastHeardEventId) {
|
||||
this.lastHeardEventId = eventId;
|
||||
if (eventId - this.lastAcknowledgedEventId > this.ackThreshold) {
|
||||
this.logger.log?.(
|
||||
`[SSE] Acking event ${eventId} (last acked: ${this.lastAcknowledgedEventId})`,
|
||||
);
|
||||
this.ack(eventId).catch((err) => {
|
||||
this.logger.error?.(`Failed to ack event ${eventId}: ${String(err)}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data) as { id?: number; json?: unknown; response?: string };
|
||||
|
||||
@@ -318,17 +342,66 @@ export class UrbitSSEClient {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the cookie used for authentication.
|
||||
* Call this when re-authenticating after session expiry.
|
||||
*/
|
||||
updateCookie(newCookie: string): void {
|
||||
this.cookie = normalizeUrbitCookie(newCookie);
|
||||
}
|
||||
|
||||
private async ack(eventId: number): Promise<void> {
|
||||
this.lastAcknowledgedEventId = eventId;
|
||||
|
||||
const ackData = {
|
||||
id: Date.now(),
|
||||
action: "ack",
|
||||
"event-id": eventId,
|
||||
};
|
||||
|
||||
const { response, release } = await urbitFetch({
|
||||
baseUrl: this.url,
|
||||
path: `/~/channel/${this.channelId}`,
|
||||
init: {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Cookie: this.cookie,
|
||||
},
|
||||
body: JSON.stringify([ackData]),
|
||||
},
|
||||
ssrfPolicy: this.ssrfPolicy,
|
||||
lookupFn: this.lookupFn,
|
||||
fetchImpl: this.fetchImpl,
|
||||
timeoutMs: 10_000,
|
||||
auditContext: "tlon-urbit-ack",
|
||||
});
|
||||
|
||||
try {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ack failed with status ${response.status}`);
|
||||
}
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
async attemptReconnect() {
|
||||
if (this.aborted || !this.autoReconnect) {
|
||||
this.logger.log?.("[SSE] Reconnection aborted or disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
// If we've hit max attempts, wait longer then reset and keep trying
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
this.logger.error?.(
|
||||
`[SSE] Max reconnection attempts (${this.maxReconnectAttempts}) reached. Giving up.`,
|
||||
this.logger.log?.(
|
||||
`[SSE] Max reconnection attempts (${this.maxReconnectAttempts}) reached. Waiting 10s before resetting...`,
|
||||
);
|
||||
return;
|
||||
// Wait 10 seconds before resetting and trying again
|
||||
const extendedBackoff = 10000; // 10 seconds
|
||||
await new Promise((resolve) => setTimeout(resolve, extendedBackoff));
|
||||
this.reconnectAttempts = 0; // Reset counter to continue trying
|
||||
this.logger.log?.("[SSE] Reconnection attempts reset, resuming reconnection...");
|
||||
}
|
||||
|
||||
this.reconnectAttempts += 1;
|
||||
|
||||
347
extensions/tlon/src/urbit/story.ts
Normal file
347
extensions/tlon/src/urbit/story.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Tlon Story Format - Rich text converter
|
||||
*
|
||||
* Converts markdown-like text to Tlon's story format.
|
||||
*/
|
||||
|
||||
// Inline content types
|
||||
export type StoryInline =
|
||||
| string
|
||||
| { bold: StoryInline[] }
|
||||
| { italics: StoryInline[] }
|
||||
| { strike: StoryInline[] }
|
||||
| { blockquote: StoryInline[] }
|
||||
| { "inline-code": string }
|
||||
| { code: string }
|
||||
| { ship: string }
|
||||
| { link: { href: string; content: string } }
|
||||
| { break: null }
|
||||
| { tag: string };
|
||||
|
||||
// Block content types
|
||||
export type StoryBlock =
|
||||
| { header: { tag: "h1" | "h2" | "h3" | "h4" | "h5" | "h6"; content: StoryInline[] } }
|
||||
| { code: { code: string; lang: string } }
|
||||
| { image: { src: string; height: number; width: number; alt: string } }
|
||||
| { rule: null }
|
||||
| { listing: StoryListing };
|
||||
|
||||
export type StoryListing =
|
||||
| {
|
||||
list: {
|
||||
type: "ordered" | "unordered" | "tasklist";
|
||||
items: StoryListing[];
|
||||
contents: StoryInline[];
|
||||
};
|
||||
}
|
||||
| { item: StoryInline[] };
|
||||
|
||||
// A verse is either a block or inline content
|
||||
export type StoryVerse = { block: StoryBlock } | { inline: StoryInline[] };
|
||||
|
||||
// A story is a list of verses
|
||||
export type Story = StoryVerse[];
|
||||
|
||||
/**
|
||||
* Parse inline markdown formatting (bold, italic, code, links, mentions)
|
||||
*/
|
||||
function parseInlineMarkdown(text: string): StoryInline[] {
|
||||
const result: StoryInline[] = [];
|
||||
let remaining = text;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
// Ship mentions: ~sampel-palnet
|
||||
const shipMatch = remaining.match(/^(~[a-z][-a-z0-9]*)/);
|
||||
if (shipMatch) {
|
||||
result.push({ ship: shipMatch[1] });
|
||||
remaining = remaining.slice(shipMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Bold: **text** or __text__
|
||||
const boldMatch = remaining.match(/^\*\*(.+?)\*\*|^__(.+?)__/);
|
||||
if (boldMatch) {
|
||||
const content = boldMatch[1] || boldMatch[2];
|
||||
result.push({ bold: parseInlineMarkdown(content) });
|
||||
remaining = remaining.slice(boldMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Italics: *text* or _text_ (but not inside words for _)
|
||||
const italicsMatch = remaining.match(/^\*([^*]+?)\*|^_([^_]+?)_(?![a-zA-Z0-9])/);
|
||||
if (italicsMatch) {
|
||||
const content = italicsMatch[1] || italicsMatch[2];
|
||||
result.push({ italics: parseInlineMarkdown(content) });
|
||||
remaining = remaining.slice(italicsMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Strikethrough: ~~text~~
|
||||
const strikeMatch = remaining.match(/^~~(.+?)~~/);
|
||||
if (strikeMatch) {
|
||||
result.push({ strike: parseInlineMarkdown(strikeMatch[1]) });
|
||||
remaining = remaining.slice(strikeMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Inline code: `code`
|
||||
const codeMatch = remaining.match(/^`([^`]+)`/);
|
||||
if (codeMatch) {
|
||||
result.push({ "inline-code": codeMatch[1] });
|
||||
remaining = remaining.slice(codeMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Links: [text](url)
|
||||
const linkMatch = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/);
|
||||
if (linkMatch) {
|
||||
result.push({ link: { href: linkMatch[2], content: linkMatch[1] } });
|
||||
remaining = remaining.slice(linkMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Markdown images: 
|
||||
const imageMatch = remaining.match(/^!\[([^\]]*)\]\(([^)]+)\)/);
|
||||
if (imageMatch) {
|
||||
// Return a special marker that will be hoisted to a block
|
||||
result.push({
|
||||
__image: { src: imageMatch[2], alt: imageMatch[1] },
|
||||
} as unknown as StoryInline);
|
||||
remaining = remaining.slice(imageMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Plain URL detection
|
||||
const urlMatch = remaining.match(/^(https?:\/\/[^\s<>"\]]+)/);
|
||||
if (urlMatch) {
|
||||
result.push({ link: { href: urlMatch[1], content: urlMatch[1] } });
|
||||
remaining = remaining.slice(urlMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Hashtags: #tag - disabled, chat UI doesn't render them
|
||||
// const tagMatch = remaining.match(/^#([a-zA-Z][a-zA-Z0-9_-]*)/);
|
||||
// if (tagMatch) {
|
||||
// result.push({ tag: tagMatch[1] });
|
||||
// remaining = remaining.slice(tagMatch[0].length);
|
||||
// continue;
|
||||
// }
|
||||
|
||||
// Plain text: consume until next special character or URL start
|
||||
// Exclude : and / to allow URL detection to work (stops before https://)
|
||||
const plainMatch = remaining.match(/^[^*_`~[#~\n:/]+/);
|
||||
if (plainMatch) {
|
||||
result.push(plainMatch[0]);
|
||||
remaining = remaining.slice(plainMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Single special char that didn't match a pattern
|
||||
result.push(remaining[0]);
|
||||
remaining = remaining.slice(1);
|
||||
}
|
||||
|
||||
// Merge adjacent strings
|
||||
return mergeAdjacentStrings(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge adjacent string elements in an inline array
|
||||
*/
|
||||
function mergeAdjacentStrings(inlines: StoryInline[]): StoryInline[] {
|
||||
const result: StoryInline[] = [];
|
||||
for (const item of inlines) {
|
||||
if (typeof item === "string" && typeof result[result.length - 1] === "string") {
|
||||
result[result.length - 1] = (result[result.length - 1] as string) + item;
|
||||
} else {
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an image block
|
||||
*/
|
||||
export function createImageBlock(
|
||||
src: string,
|
||||
alt: string = "",
|
||||
height: number = 0,
|
||||
width: number = 0,
|
||||
): StoryVerse {
|
||||
return {
|
||||
block: {
|
||||
image: { src, height, width, alt },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if URL looks like an image
|
||||
*/
|
||||
export function isImageUrl(url: string): boolean {
|
||||
const imageExtensions = /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico)(\?.*)?$/i;
|
||||
return imageExtensions.test(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process inlines and extract any image markers into blocks
|
||||
*/
|
||||
function processInlinesForImages(inlines: StoryInline[]): {
|
||||
inlines: StoryInline[];
|
||||
imageBlocks: StoryVerse[];
|
||||
} {
|
||||
const cleanInlines: StoryInline[] = [];
|
||||
const imageBlocks: StoryVerse[] = [];
|
||||
|
||||
for (const inline of inlines) {
|
||||
if (typeof inline === "object" && "__image" in inline) {
|
||||
const img = (inline as unknown as { __image: { src: string; alt: string } }).__image;
|
||||
imageBlocks.push(createImageBlock(img.src, img.alt));
|
||||
} else {
|
||||
cleanInlines.push(inline);
|
||||
}
|
||||
}
|
||||
|
||||
return { inlines: cleanInlines, imageBlocks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert markdown text to Tlon story format
|
||||
*/
|
||||
export function markdownToStory(markdown: string): Story {
|
||||
const story: Story = [];
|
||||
const lines = markdown.split("\n");
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
// Code block: ```lang\ncode\n```
|
||||
if (line.startsWith("```")) {
|
||||
const lang = line.slice(3).trim() || "plaintext";
|
||||
const codeLines: string[] = [];
|
||||
i++;
|
||||
while (i < lines.length && !lines[i].startsWith("```")) {
|
||||
codeLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
story.push({
|
||||
block: {
|
||||
code: {
|
||||
code: codeLines.join("\n"),
|
||||
lang,
|
||||
},
|
||||
},
|
||||
});
|
||||
i++; // skip closing ```
|
||||
continue;
|
||||
}
|
||||
|
||||
// Headers: # H1, ## H2, etc.
|
||||
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (headerMatch) {
|
||||
const level = headerMatch[1].length as 1 | 2 | 3 | 4 | 5 | 6;
|
||||
const tag = `h${level}` as "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
||||
story.push({
|
||||
block: {
|
||||
header: {
|
||||
tag,
|
||||
content: parseInlineMarkdown(headerMatch[2]),
|
||||
},
|
||||
},
|
||||
});
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Horizontal rule: --- or ***
|
||||
if (/^(-{3,}|\*{3,})$/.test(line.trim())) {
|
||||
story.push({ block: { rule: null } });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blockquote: > text
|
||||
if (line.startsWith("> ")) {
|
||||
const quoteLines: string[] = [];
|
||||
while (i < lines.length && lines[i].startsWith("> ")) {
|
||||
quoteLines.push(lines[i].slice(2));
|
||||
i++;
|
||||
}
|
||||
const quoteText = quoteLines.join("\n");
|
||||
story.push({
|
||||
inline: [{ blockquote: parseInlineMarkdown(quoteText) }],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Empty line - skip
|
||||
if (line.trim() === "") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular paragraph - collect consecutive non-empty lines
|
||||
const paragraphLines: string[] = [];
|
||||
while (
|
||||
i < lines.length &&
|
||||
lines[i].trim() !== "" &&
|
||||
!lines[i].startsWith("#") &&
|
||||
!lines[i].startsWith("```") &&
|
||||
!lines[i].startsWith("> ") &&
|
||||
!/^(-{3,}|\*{3,})$/.test(lines[i].trim())
|
||||
) {
|
||||
paragraphLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
|
||||
if (paragraphLines.length > 0) {
|
||||
const paragraphText = paragraphLines.join("\n");
|
||||
// Convert newlines within paragraph to break elements
|
||||
const inlines = parseInlineMarkdown(paragraphText);
|
||||
// Replace \n in strings with break elements
|
||||
const withBreaks: StoryInline[] = [];
|
||||
for (const inline of inlines) {
|
||||
if (typeof inline === "string" && inline.includes("\n")) {
|
||||
const parts = inline.split("\n");
|
||||
for (let j = 0; j < parts.length; j++) {
|
||||
if (parts[j]) {
|
||||
withBreaks.push(parts[j]);
|
||||
}
|
||||
if (j < parts.length - 1) {
|
||||
withBreaks.push({ break: null });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
withBreaks.push(inline);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract any images from inlines and add as separate blocks
|
||||
const { inlines: cleanInlines, imageBlocks } = processInlinesForImages(withBreaks);
|
||||
|
||||
if (cleanInlines.length > 0) {
|
||||
story.push({ inline: cleanInlines });
|
||||
}
|
||||
story.push(...imageBlocks);
|
||||
}
|
||||
}
|
||||
|
||||
return story;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert plain text to simple story (no markdown parsing)
|
||||
*/
|
||||
export function textToStory(text: string): Story {
|
||||
return [{ inline: [text] }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text contains markdown formatting
|
||||
*/
|
||||
export function hasMarkdown(text: string): boolean {
|
||||
// Check for common markdown patterns
|
||||
return /(\*\*|__|~~|`|^#{1,6}\s|^```|^\s*[-*]\s|\[.*\]\(.*\)|^>\s)/m.test(text);
|
||||
}
|
||||
188
extensions/tlon/src/urbit/upload.test.ts
Normal file
188
extensions/tlon/src/urbit/upload.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { describe, expect, it, vi, afterEach, beforeEach } from "vitest";
|
||||
|
||||
// Mock fetchWithSsrFGuard from plugin-sdk
|
||||
vi.mock("openclaw/plugin-sdk", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchWithSsrFGuard: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock @tloncorp/api
|
||||
vi.mock("@tloncorp/api", () => ({
|
||||
uploadFile: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("uploadImageFromUrl", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("fetches image and calls uploadFile, returns uploaded URL", async () => {
|
||||
const { fetchWithSsrFGuard } = await import("openclaw/plugin-sdk");
|
||||
const mockFetch = vi.mocked(fetchWithSsrFGuard);
|
||||
|
||||
const { uploadFile } = await import("@tloncorp/api");
|
||||
const mockUploadFile = vi.mocked(uploadFile);
|
||||
|
||||
// Mock fetchWithSsrFGuard to return a successful response with a blob
|
||||
const mockBlob = new Blob(["fake-image"], { type: "image/png" });
|
||||
mockFetch.mockResolvedValue({
|
||||
response: {
|
||||
ok: true,
|
||||
headers: new Headers({ "content-type": "image/png" }),
|
||||
blob: () => Promise.resolve(mockBlob),
|
||||
} as unknown as Response,
|
||||
finalUrl: "https://example.com/image.png",
|
||||
release: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
// Mock uploadFile to return a successful upload
|
||||
mockUploadFile.mockResolvedValue({ url: "https://memex.tlon.network/uploaded.png" });
|
||||
|
||||
const { uploadImageFromUrl } = await import("./upload.js");
|
||||
const result = await uploadImageFromUrl("https://example.com/image.png");
|
||||
|
||||
expect(result).toBe("https://memex.tlon.network/uploaded.png");
|
||||
expect(mockUploadFile).toHaveBeenCalledTimes(1);
|
||||
expect(mockUploadFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
blob: mockBlob,
|
||||
contentType: "image/png",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns original URL if fetch fails", async () => {
|
||||
const { fetchWithSsrFGuard } = await import("openclaw/plugin-sdk");
|
||||
const mockFetch = vi.mocked(fetchWithSsrFGuard);
|
||||
|
||||
// Mock fetchWithSsrFGuard to return a failed response
|
||||
mockFetch.mockResolvedValue({
|
||||
response: {
|
||||
ok: false,
|
||||
status: 404,
|
||||
} as unknown as Response,
|
||||
finalUrl: "https://example.com/image.png",
|
||||
release: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
const { uploadImageFromUrl } = await import("./upload.js");
|
||||
const result = await uploadImageFromUrl("https://example.com/image.png");
|
||||
|
||||
expect(result).toBe("https://example.com/image.png");
|
||||
});
|
||||
|
||||
it("returns original URL if upload fails", async () => {
|
||||
const { fetchWithSsrFGuard } = await import("openclaw/plugin-sdk");
|
||||
const mockFetch = vi.mocked(fetchWithSsrFGuard);
|
||||
|
||||
const { uploadFile } = await import("@tloncorp/api");
|
||||
const mockUploadFile = vi.mocked(uploadFile);
|
||||
|
||||
// Mock fetchWithSsrFGuard to return a successful response
|
||||
const mockBlob = new Blob(["fake-image"], { type: "image/png" });
|
||||
mockFetch.mockResolvedValue({
|
||||
response: {
|
||||
ok: true,
|
||||
headers: new Headers({ "content-type": "image/png" }),
|
||||
blob: () => Promise.resolve(mockBlob),
|
||||
} as unknown as Response,
|
||||
finalUrl: "https://example.com/image.png",
|
||||
release: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
// Mock uploadFile to throw an error
|
||||
mockUploadFile.mockRejectedValue(new Error("Upload failed"));
|
||||
|
||||
const { uploadImageFromUrl } = await import("./upload.js");
|
||||
const result = await uploadImageFromUrl("https://example.com/image.png");
|
||||
|
||||
expect(result).toBe("https://example.com/image.png");
|
||||
});
|
||||
|
||||
it("rejects non-http(s) URLs", async () => {
|
||||
const { uploadImageFromUrl } = await import("./upload.js");
|
||||
|
||||
// file:// URL should be rejected
|
||||
const result = await uploadImageFromUrl("file:///etc/passwd");
|
||||
expect(result).toBe("file:///etc/passwd");
|
||||
|
||||
// ftp:// URL should be rejected
|
||||
const result2 = await uploadImageFromUrl("ftp://example.com/image.png");
|
||||
expect(result2).toBe("ftp://example.com/image.png");
|
||||
});
|
||||
|
||||
it("handles invalid URLs gracefully", async () => {
|
||||
const { uploadImageFromUrl } = await import("./upload.js");
|
||||
|
||||
// Invalid URL should return original
|
||||
const result = await uploadImageFromUrl("not-a-valid-url");
|
||||
expect(result).toBe("not-a-valid-url");
|
||||
});
|
||||
|
||||
it("extracts filename from URL path", async () => {
|
||||
const { fetchWithSsrFGuard } = await import("openclaw/plugin-sdk");
|
||||
const mockFetch = vi.mocked(fetchWithSsrFGuard);
|
||||
|
||||
const { uploadFile } = await import("@tloncorp/api");
|
||||
const mockUploadFile = vi.mocked(uploadFile);
|
||||
|
||||
const mockBlob = new Blob(["fake-image"], { type: "image/jpeg" });
|
||||
mockFetch.mockResolvedValue({
|
||||
response: {
|
||||
ok: true,
|
||||
headers: new Headers({ "content-type": "image/jpeg" }),
|
||||
blob: () => Promise.resolve(mockBlob),
|
||||
} as unknown as Response,
|
||||
finalUrl: "https://example.com/path/to/my-image.jpg",
|
||||
release: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
mockUploadFile.mockResolvedValue({ url: "https://memex.tlon.network/uploaded.jpg" });
|
||||
|
||||
const { uploadImageFromUrl } = await import("./upload.js");
|
||||
await uploadImageFromUrl("https://example.com/path/to/my-image.jpg");
|
||||
|
||||
expect(mockUploadFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fileName: "my-image.jpg",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses default filename when URL has no path", async () => {
|
||||
const { fetchWithSsrFGuard } = await import("openclaw/plugin-sdk");
|
||||
const mockFetch = vi.mocked(fetchWithSsrFGuard);
|
||||
|
||||
const { uploadFile } = await import("@tloncorp/api");
|
||||
const mockUploadFile = vi.mocked(uploadFile);
|
||||
|
||||
const mockBlob = new Blob(["fake-image"], { type: "image/png" });
|
||||
mockFetch.mockResolvedValue({
|
||||
response: {
|
||||
ok: true,
|
||||
headers: new Headers({ "content-type": "image/png" }),
|
||||
blob: () => Promise.resolve(mockBlob),
|
||||
} as unknown as Response,
|
||||
finalUrl: "https://example.com/",
|
||||
release: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
mockUploadFile.mockResolvedValue({ url: "https://memex.tlon.network/uploaded.png" });
|
||||
|
||||
const { uploadImageFromUrl } = await import("./upload.js");
|
||||
await uploadImageFromUrl("https://example.com/");
|
||||
|
||||
expect(mockUploadFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fileName: expect.stringMatching(/^upload-\d+\.png$/),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
60
extensions/tlon/src/urbit/upload.ts
Normal file
60
extensions/tlon/src/urbit/upload.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Upload an image from a URL to Tlon storage.
|
||||
*/
|
||||
import { uploadFile } from "@tloncorp/api";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk";
|
||||
import { getDefaultSsrFPolicy } from "./context.js";
|
||||
|
||||
/**
|
||||
* Fetch an image from a URL and upload it to Tlon storage.
|
||||
* Returns the uploaded URL, or falls back to the original URL on error.
|
||||
*
|
||||
* Note: configureClient must be called before using this function.
|
||||
*/
|
||||
export async function uploadImageFromUrl(imageUrl: string): Promise<string> {
|
||||
try {
|
||||
// Validate URL is http/https before fetching
|
||||
const url = new URL(imageUrl);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
console.warn(`[tlon] Rejected non-http(s) URL: ${imageUrl}`);
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
// Fetch the image with SSRF protection
|
||||
// Use fetchWithSsrFGuard directly (not urbitFetch) to preserve the full URL path
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url: imageUrl,
|
||||
init: { method: "GET" },
|
||||
policy: getDefaultSsrFPolicy(),
|
||||
auditContext: "tlon-upload-image",
|
||||
});
|
||||
|
||||
try {
|
||||
if (!response.ok) {
|
||||
console.warn(`[tlon] Failed to fetch image from ${imageUrl}: ${response.status}`);
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") || "image/png";
|
||||
const blob = await response.blob();
|
||||
|
||||
// Extract filename from URL or use a default
|
||||
const urlPath = new URL(imageUrl).pathname;
|
||||
const fileName = urlPath.split("/").pop() || `upload-${Date.now()}.png`;
|
||||
|
||||
// Upload to Tlon storage
|
||||
const result = await uploadFile({
|
||||
blob,
|
||||
fileName,
|
||||
contentType,
|
||||
});
|
||||
|
||||
return result.url;
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[tlon] Failed to upload image, using original URL: ${err}`);
|
||||
return imageUrl;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
cleanupTailscaleExposureRoute,
|
||||
getTailscaleSelfInfo,
|
||||
setupTailscaleExposureRoute,
|
||||
} from "./webhook.js";
|
||||
} from "./webhook/tailscale.js";
|
||||
|
||||
type Logger = {
|
||||
info: (message: string) => void;
|
||||
|
||||
218
extensions/voice-call/src/manager.closed-loop.test.ts
Normal file
218
extensions/voice-call/src/manager.closed-loop.test.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createManagerHarness, FakeProvider, markCallAnswered } from "./manager.test-harness.js";
|
||||
|
||||
describe("CallManager closed-loop turns", () => {
|
||||
it("completes a closed-loop turn without live audio", async () => {
|
||||
const { manager, provider } = await createManagerHarness({
|
||||
transcriptTimeoutMs: 5000,
|
||||
});
|
||||
|
||||
const started = await manager.initiateCall("+15550000003");
|
||||
expect(started.success).toBe(true);
|
||||
|
||||
markCallAnswered(manager, started.callId, "evt-closed-loop-answered");
|
||||
|
||||
const turnPromise = manager.continueCall(started.callId, "How can I help?");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
manager.processEvent({
|
||||
id: "evt-closed-loop-speech",
|
||||
type: "call.speech",
|
||||
callId: started.callId,
|
||||
providerCallId: "request-uuid",
|
||||
timestamp: Date.now(),
|
||||
transcript: "Please check status",
|
||||
isFinal: true,
|
||||
});
|
||||
|
||||
const turn = await turnPromise;
|
||||
expect(turn.success).toBe(true);
|
||||
expect(turn.transcript).toBe("Please check status");
|
||||
expect(provider.startListeningCalls).toHaveLength(1);
|
||||
expect(provider.stopListeningCalls).toHaveLength(1);
|
||||
|
||||
const call = manager.getCall(started.callId);
|
||||
expect(call?.transcript.map((entry) => entry.text)).toEqual([
|
||||
"How can I help?",
|
||||
"Please check status",
|
||||
]);
|
||||
const metadata = (call?.metadata ?? {}) as Record<string, unknown>;
|
||||
expect(typeof metadata.lastTurnLatencyMs).toBe("number");
|
||||
expect(typeof metadata.lastTurnListenWaitMs).toBe("number");
|
||||
expect(metadata.turnCount).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects overlapping continueCall requests for the same call", async () => {
|
||||
const { manager, provider } = await createManagerHarness({
|
||||
transcriptTimeoutMs: 5000,
|
||||
});
|
||||
|
||||
const started = await manager.initiateCall("+15550000004");
|
||||
expect(started.success).toBe(true);
|
||||
|
||||
markCallAnswered(manager, started.callId, "evt-overlap-answered");
|
||||
|
||||
const first = manager.continueCall(started.callId, "First prompt");
|
||||
const second = await manager.continueCall(started.callId, "Second prompt");
|
||||
expect(second.success).toBe(false);
|
||||
expect(second.error).toBe("Already waiting for transcript");
|
||||
|
||||
manager.processEvent({
|
||||
id: "evt-overlap-speech",
|
||||
type: "call.speech",
|
||||
callId: started.callId,
|
||||
providerCallId: "request-uuid",
|
||||
timestamp: Date.now(),
|
||||
transcript: "Done",
|
||||
isFinal: true,
|
||||
});
|
||||
|
||||
const firstResult = await first;
|
||||
expect(firstResult.success).toBe(true);
|
||||
expect(firstResult.transcript).toBe("Done");
|
||||
expect(provider.startListeningCalls).toHaveLength(1);
|
||||
expect(provider.stopListeningCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ignores speech events with mismatched turnToken while waiting for transcript", async () => {
|
||||
const { manager, provider } = await createManagerHarness(
|
||||
{
|
||||
transcriptTimeoutMs: 5000,
|
||||
},
|
||||
new FakeProvider("twilio"),
|
||||
);
|
||||
|
||||
const started = await manager.initiateCall("+15550000004");
|
||||
expect(started.success).toBe(true);
|
||||
|
||||
markCallAnswered(manager, started.callId, "evt-turn-token-answered");
|
||||
|
||||
const turnPromise = manager.continueCall(started.callId, "Prompt");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const expectedTurnToken = provider.startListeningCalls[0]?.turnToken;
|
||||
expect(typeof expectedTurnToken).toBe("string");
|
||||
|
||||
manager.processEvent({
|
||||
id: "evt-turn-token-bad",
|
||||
type: "call.speech",
|
||||
callId: started.callId,
|
||||
providerCallId: "request-uuid",
|
||||
timestamp: Date.now(),
|
||||
transcript: "stale replay",
|
||||
isFinal: true,
|
||||
turnToken: "wrong-token",
|
||||
});
|
||||
|
||||
const pendingState = await Promise.race([
|
||||
turnPromise.then(() => "resolved"),
|
||||
new Promise<"pending">((resolve) => setTimeout(() => resolve("pending"), 0)),
|
||||
]);
|
||||
expect(pendingState).toBe("pending");
|
||||
|
||||
manager.processEvent({
|
||||
id: "evt-turn-token-good",
|
||||
type: "call.speech",
|
||||
callId: started.callId,
|
||||
providerCallId: "request-uuid",
|
||||
timestamp: Date.now(),
|
||||
transcript: "final answer",
|
||||
isFinal: true,
|
||||
turnToken: expectedTurnToken,
|
||||
});
|
||||
|
||||
const turnResult = await turnPromise;
|
||||
expect(turnResult.success).toBe(true);
|
||||
expect(turnResult.transcript).toBe("final answer");
|
||||
|
||||
const call = manager.getCall(started.callId);
|
||||
expect(call?.transcript.map((entry) => entry.text)).toEqual(["Prompt", "final answer"]);
|
||||
});
|
||||
|
||||
it("tracks latency metadata across multiple closed-loop turns", async () => {
|
||||
const { manager, provider } = await createManagerHarness({
|
||||
transcriptTimeoutMs: 5000,
|
||||
});
|
||||
|
||||
const started = await manager.initiateCall("+15550000005");
|
||||
expect(started.success).toBe(true);
|
||||
|
||||
markCallAnswered(manager, started.callId, "evt-multi-answered");
|
||||
|
||||
const firstTurn = manager.continueCall(started.callId, "First question");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
manager.processEvent({
|
||||
id: "evt-multi-speech-1",
|
||||
type: "call.speech",
|
||||
callId: started.callId,
|
||||
providerCallId: "request-uuid",
|
||||
timestamp: Date.now(),
|
||||
transcript: "First answer",
|
||||
isFinal: true,
|
||||
});
|
||||
await firstTurn;
|
||||
|
||||
const secondTurn = manager.continueCall(started.callId, "Second question");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
manager.processEvent({
|
||||
id: "evt-multi-speech-2",
|
||||
type: "call.speech",
|
||||
callId: started.callId,
|
||||
providerCallId: "request-uuid",
|
||||
timestamp: Date.now(),
|
||||
transcript: "Second answer",
|
||||
isFinal: true,
|
||||
});
|
||||
const secondResult = await secondTurn;
|
||||
|
||||
expect(secondResult.success).toBe(true);
|
||||
|
||||
const call = manager.getCall(started.callId);
|
||||
expect(call?.transcript.map((entry) => entry.text)).toEqual([
|
||||
"First question",
|
||||
"First answer",
|
||||
"Second question",
|
||||
"Second answer",
|
||||
]);
|
||||
const metadata = (call?.metadata ?? {}) as Record<string, unknown>;
|
||||
expect(metadata.turnCount).toBe(2);
|
||||
expect(typeof metadata.lastTurnLatencyMs).toBe("number");
|
||||
expect(typeof metadata.lastTurnListenWaitMs).toBe("number");
|
||||
expect(provider.startListeningCalls).toHaveLength(2);
|
||||
expect(provider.stopListeningCalls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("handles repeated closed-loop turns without waiter churn", async () => {
|
||||
const { manager, provider } = await createManagerHarness({
|
||||
transcriptTimeoutMs: 5000,
|
||||
});
|
||||
|
||||
const started = await manager.initiateCall("+15550000006");
|
||||
expect(started.success).toBe(true);
|
||||
|
||||
markCallAnswered(manager, started.callId, "evt-loop-answered");
|
||||
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const turnPromise = manager.continueCall(started.callId, `Prompt ${i}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
manager.processEvent({
|
||||
id: `evt-loop-speech-${i}`,
|
||||
type: "call.speech",
|
||||
callId: started.callId,
|
||||
providerCallId: "request-uuid",
|
||||
timestamp: Date.now(),
|
||||
transcript: `Answer ${i}`,
|
||||
isFinal: true,
|
||||
});
|
||||
const result = await turnPromise;
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.transcript).toBe(`Answer ${i}`);
|
||||
}
|
||||
|
||||
const call = manager.getCall(started.callId);
|
||||
const metadata = (call?.metadata ?? {}) as Record<string, unknown>;
|
||||
expect(metadata.turnCount).toBe(5);
|
||||
expect(provider.startListeningCalls).toHaveLength(5);
|
||||
expect(provider.stopListeningCalls).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user